PR

ラズパイでデジタルピッキングシステムを自作する|Excelの部品表を読み込みFlaskのWeb UIで棚のLEDを操作【後編】

ハードウェア制御 / 電子工作

デジタルピッキング 後編

スポンサーリンク

はじめに:Excelの部品表とWeb UIでピッキングを自動化する

こんにちは、ニンジンです🥕

前編では、配線を1本にまとめられる「アドレス指定型LED(WS2812B)」をRaspberry Piにつなぎ、1個だけ点灯させるところまで進めました。

この後編では、Excelの部品表(MasterとOrderの2シート)を読み込み、Flaskでラズパイ自身をWebサーバーにして、タブレットのブラウザから「部品名・数量」を見ながら棚のLEDを光らせるピッキングシステムを組み立てます。

この記事で分かること

  • pandasでExcelの2シート(Master:部品マスタ / Order:ピッキング指示)を読み込む方法
  • 指示IDに紐づくLED番号を引いて、順番に自動巡回で点灯させるコード
  • Flaskでラズパイをタブレット向けWebサーバーにする方法(host='0.0.0.0'
  • 「完了」ボタンで次の部品に進むWeb UIの作り方
  • WS2812Bの実行に sudo が必要な理由と、タブレットからつながらないときの対処

スポンサーリンク

要素設計のロードマップ

デジタルピッキングシステムの要素設計は3ステップです。後編のこの記事はSTEP2〜STEP3を扱います。

  • STEP1:環境構築(前編)— ラズパイとWS2812Bの物理接続、rpi_ws281x 等のインストール、LEDを1個点灯(疎通確認)
  • STEP2:要素技術の連携 — ExcelをPythonで読み込み、指示に紐づくLEDを順番に点灯
  • STEP3:操作画面の実装 — Flaskでサーバーを立て、タブレットに部品名・数量を大きく表示、「完了」で次へ
🔌 前編・STEP1 ラズパイでデジタルピッキングシステムを自作する|WS2812B(アドレス指定型LED)の配線と点灯テスト【前編】 部品構成・結線図・ライブラリのインストール・1個点灯までは前編で解説しています。

スポンサーリンク

STEP2:Excelデータとの連携と自動巡回

Excelファイルの準備

読み取り用のExcelファイル parts_list.xlsx を用意します。シートは2つです。

  • Masterシート:PartsID / 部品名 / LED_Index(部品マスタ)
  • Orderシート:指示ID / 部品ID / 数量(ピッキング指示)
parts_list.xlsxのMasterシート

Masterシート。部品ごとに、対応するLEDの番号(LED_Index)を定義しておきます。

parts_list.xlsxのOrderシート

Orderシート。この行の順番どおりにLEDが切り替わります。

部品構成と結線図は前編と同じです。parts_list.xlsx はラズパイOSのデスクトップ(/home/pi/Desktop/)に置いてください。

sudo権限でThonnyを起動する

sudo thonny でThonnyを起動するターミナル

WS2812Bの制御にはroot権限が必要です。通常起動のThonnyでは動かないので、ターミナルから sudo thonny で起動します。

sudo thonny
Thonnyのモード変更メニュー

ファイル選択ボタンが無い場合は「Switch to regular mode」で通常モードに変更します。

通常モードへの切り替え確認

「OK」をクリックして通常モードに切り替えます。

Thonny通常モードのFileメニュー

左上に「File」メニューが出るので、そこから実行したいファイルを開きます。

テストコード:test_led_Excel.py

Orderシートの順番どおりにLEDが切り替わる「自動巡回ピッキング」のコードです。Enterを押すたびに次の指示へ進みます。

import board
import neopixel
import pandas as pd

# LED設定
PIXEL_PIN = board.D18
NUM_PIXELS = 60
pixels = neopixel.NeoPixel(PIXEL_PIN, NUM_PIXELS, brightness=0.1, auto_write=False)

def start_picking():
    # 1. データの読み込み (CSVファイル名に合わせる)
    try:
        path = '/home/pi/Desktop/parts_list.xlsx'
        df_master = pd.read_excel('parts_list.xlsx', sheet_name='Master', engine='openpyxl')
        df_order = pd.read_excel('parts_list.xlsx', sheet_name='Order', engine='openpyxl')
    except Exception as e:
        print(f"ファイル読み込み失敗: {e}")
        return

    print("--- ピッキング開始 ---")

    # 2. 指示(Order)を1行ずつループ
    for index, row in df_order.iterrows():
        p_id = str(row['部品ID'])
        qty = row['数量']

        # 3. MasterからLEDの場所を探す
        master_row = df_master[df_master['PartsID'].astype(str) == p_id]
        
        if not master_row.empty:
            led_idx = int(master_row['LED_Index'].values[0])
            p_name = master_row['部品名'].values[0]

            # 4. LED点灯
            pixels.fill((0, 0, 0)) # 一旦全部消す
            pixels[led_idx] = (0, 255, 0) # 緑色に点灯
            pixels.show()

            # 5. コンソールに指示表示
            print(f"\n【指示 {index + 1}/{len(df_order)}】")
            print(f"場所: {led_idx}番の棚")
            print(f"部品: {p_name} (ID: {p_id})")
            print(f"数量: {qty} 個")
            
            # 6. 作業完了の待機
            input("取り出したら [Enter] を押して次へ...")
        else:
            print(f"警告: ID {p_id} がマスタに登録されていません。")

    # 7. 全件終了
    pixels.fill((0, 0, 0))
    pixels.show()
    print("\n--- 全てのピッキングが完了しました!お疲れ様でした。 ---")

if __name__ == "__main__":
    start_picking()

テスト結果

1件目の指示と3番のLED点灯

ターミナルに指示(棚番号・部品名・数量)が表示され、3番目のLEDが点灯します。取り出したらEnterで次へ。

2件目の指示と1番のLED点灯

2件目。1番目のLEDが点灯します。

全ピッキング完了の表示

Orderに記載した5件が終わると「全てのピッキングが完了しました!」と表示されれば成功です。


スポンサーリンク

STEP3:Flaskを使ったWeb UIの実装

ラズパイ単体での動作が確認できたので、次はこの動きをネットワーク越しに再現します。Pythonの Flask でラズパイ自身をWebサーバーにすれば、タブレットに専用アプリを入れなくても、ブラウザからアクセスするだけで操作画面が使えます。

ExcelファイルもハードウェアもSTEP1・STEP2と同じです。実行時は前と同じく sudo thonny で起動します。

テストコード:web_picking.py

Flaskのルーティング(/ で表示、/next で次へ、/reset で最初に戻る)と、タブレット向けの大きな表示のHTMLテンプレートをまとめたコードです。

import board
import neopixel
import pandas as pd
from flask import Flask, render_template_string, redirect, url_for

app = Flask(__name__)

# --- 設定エリア ---
PIXEL_PIN = board.D18
NUM_PIXELS = 60
EXCEL_FILE = '/home/pi/Desktop/parts_list.xlsx'

# LED初期化
pixels = neopixel.NeoPixel(PIXEL_PIN, NUM_PIXELS, brightness=0.1, auto_write=False)

# データ読み込み
df_master = pd.read_excel(EXCEL_FILE, sheet_name='Master', engine='openpyxl')
df_order = pd.read_excel(EXCEL_FILE, sheet_name='Order', engine='openpyxl')

# 現在のピッキング進行状況を記憶する変数
current_index = 0

# --- HTMLデザイン (タブレット向けUI) ---
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Picking System</title>
    <style>
        body { font-family: sans-serif; text-align: center; background-color: #eef2f5; padding: 20px; margin: 0; }
        .logo { width: 40px; height: 40px; margin-right: 10px; }
        .card { background: white; border-radius: 15px; padding: 40px 20px; box-shadow: 0 8px 16px rgba(0,0,0,0.1); max-width: 600px; margin: 0 auto; }
        .progress { color: #666; font-size: 1.2em; margin-bottom: 10px; }
        .part-id { color: #888; font-size: 1.5em; }
        .part-name { font-size: 3em; font-weight: bold; margin: 10px 0; color: #333; }
        .qty-box { background-color: #fff4e5; border-radius: 10px; padding: 20px; margin: 20px 0; border: 2px solid #ff8c00; }
        .qty-label { font-size: 1.5em; color: #ff8c00; }
        .qty { font-size: 5em; font-weight: bold; color: #d9534f; margin: 0; }
        .btn { display: block; width: 100%; padding: 5px; font-size: 2.5em; background-color: #ff8c00; color: white; border: none; border-radius: 15px; text-decoration: none; font-weight: bold; cursor: pointer; box-shadow: 0 4px 6px rgba(0,0,0,0.2); box-sizing: border-box; }
        .btn:active { transform: translateY(4px); box-shadow: none; }
        .btn-reset { background-color: #6c757d; font-size: 1.5em; padding: 15px; margin-top: 30px; }
        .finished { font-size: 2.5em; color: #5cb85c; font-weight: bold; }
    </style>
</head>
<body>
    <div class="header">
        <h2 style="color:#ff8c00; margin:0;">Digital Picking</h2>
    </div>

    <div class="card">
        {% if finished %}
            <p class="finished">🎉 全てのピッキングが完了しました!</p>
            <a href="/reset" class="btn btn-reset">最初からやり直す</a>
        {% else %}
            <div class="progress">指示 {{ current }} / {{ total }}</div>
            <div class="part-id">ID: {{ p_id }}</div>
            <div class="part-name">{{ p_name }}</div>
            
            <div class="qty-box">
                <div class="qty-label">取り出す数量</div>
                <div class="qty">{{ qty }} <span style="font-size: 0.4em; color: #333;">個</span></div>
            </div>

            <a href="/next" class="btn">完了 (次へ)</a>
        {% endif %}
    </div>
</body>
</html>
"""

def update_led():
    """現在のインデックスに合わせてLEDを点灯する関数"""
    global current_index
    pixels.fill((0, 0, 0)) # 一旦全部消す
    
    if current_index < len(df_order):
        row = df_order.iloc[current_index]
        p_id = str(row['部品ID'])
        master_row = df_master[df_master['PartsID'].astype(str) == p_id]
        
        if not master_row.empty:
            led_idx = int(master_row['LED_Index'].values[0])
            pixels[led_idx] = (0, 255, 0) # 緑色に点灯
            
    pixels.show()

# --- Webサーバーのルーティング設定 ---
@app.route('/')
def index():
    global current_index
    
    # 全件完了している場合
    if current_index >= len(df_order):
        pixels.fill((0, 0, 0))
        pixels.show()
        return render_template_string(HTML_TEMPLATE, finished=True)
    
    # 現在の指示データを取得
    row = df_order.iloc[current_index]
    p_id = str(row['部品ID'])
    qty = row['数量']
    
    master_row = df_master[df_master['PartsID'].astype(str) == p_id]
    p_name = master_row['部品名'].values[0] if not master_row.empty else "不明な部品"
    
    # 画面描画と同時にLEDを更新
    update_led()
    
    return render_template_string(
        HTML_TEMPLATE, 
        finished=False, 
        p_name=p_name, 
        p_id=p_id, 
        qty=qty, 
        current=current_index+1, 
        total=len(df_order)
    )

@app.route('/next')
def next_item():
    global current_index
    current_index += 1
    return redirect(url_for('index'))

@app.route('/reset')
def reset():
    global current_index
    current_index = 0
    return redirect(url_for('index'))

if __name__ == "__main__":
    # 起動時にLEDをリセット
    pixels.fill((0, 0, 0))
    pixels.show()
    # 外部(タブレット)からのアクセスを許可してポート5000で起動
    app.run(host='0.0.0.0', port=5000)

テストのやり方

重要:ラズパイと、操作するPC・スマホ・タブレットは同じネットワーク(同じWi-Fi)につないでください。parts_list.xlsx はラズパイのデスクトップに置きます。
web_picking.pyの実行

▶ボタンで web_picking.py を実行します。ターミナルにアクセス用のURL(http://ラズパイのIP:5000)が表示されます。

PCブラウザで開いたピッキング画面

PCのブラウザでアクセスすると、この操作画面が開きます。

スマートフォンで開いたピッキング画面

スマートフォンでアクセスした場合の画面です。部品名と数量が大きく表示されます。

つながらないときは

使っているWi-Fiで「デバイス間の通信」がブロックされていないか確認してください(ルーターの「プライバシーセパレーター」や「ネットワーク分離」がオンだと、同じWi-Fiでも端末どうしが通信できません)。

テスト結果

「完了(次へ)」を押すたびに次のアイテムが表示され、対応するLEDが点灯すれば成功です。動画で動きを確認できます。


スポンサーリンク

まとめと今後の課題

これでデジタルピッキングの心臓部分(Excel連携+Web UI+LED制御)の要素設計が完成しました。実際の現場で数千〜数万点を扱うには、次のような課題が残ります。

  • 中継基板の導入:Arduino Unoなどを中継して、扱えるLED本数を増やす
  • データベース化:Excelは件数が増えると重くなるため、部品マスタをSQLite等のDBで持たせる
  • BOM連携:構成表(BOM)の読み込みを端末側で行う

根幹の設計はここまでです。自分の環境に合わせて拡張してみてください。

関連記事

🔌 前編 ラズパイでデジタルピッキングシステムを自作する|WS2812B(アドレス指定型LED)の配線と点灯テスト ハードウェアの構築、WS2812Bの配線、ライブラリの導入と1個点灯まで。 📊 似た構成の作例 Tapo P110M × FastAPIで電力監視ダッシュボードを作る ラズパイをWebサーバーにして、ブラウザからデータを見る別の作例です(FastAPI版)。

スポンサーリンク

よくある質問(FAQ)

Qsudo thonny で起動しないと動かないのはなぜですか?

AWS2812B(NeoPixel)は、ラズパイのPWMやDMA、メモリに直接アクセスして制御します。この操作にはroot権限が必要なため、Thonnyをsudo付きで起動する必要があります。

Qparts_list.xlsx はどこに置けばよいですか?

AラズパイOSのデスクトップ(/home/pi/Desktop/)に置きます。コード内のパス(EXCEL_FILE)と一致していれば別の場所でも動きます。

QタブレットからWeb UIにアクセスできません。

A①ラズパイとタブレットが同じWi-Fi(同じサブネット)にあるか、②ルーターの「プライバシーセパレーター/ネットワーク分離」がオフか、③http://ラズパイのIPアドレス:5000 でアクセスしているか、を確認してください。

Qhost='0.0.0.0' は何を意味しますか?

Aラズパイ自身以外(タブレットなど)からのアクセスを受け付ける設定です。これを省くと 127.0.0.1(ラズパイ本体のみ)になり、外部の端末からは開けません。

QMasterシートとOrderシートの関係が分かりません。

AMasterは「部品ID ↔ LED番号 ↔ 部品名」の対応表です。Orderは「今回ピッキングする指示(部品IDと数量)」のリストです。コードはOrderを1行ずつ読み、部品IDでMasterを引いてLED番号を求めます。

Q実運用で数千点を扱うにはどうすればよいですか?

AExcelは件数が増えると読み込みが重くなるので、部品マスタをSQLiteなどのデータベースに移します。LEDの本数はArduinoを中継基板にして拡張します。記事末尾の「今後の課題」も参照してください。


ニンジン🥕アイコン

Follow me on X

本ブログの中の人「ニンジン🥕」はXやってます。新着記事はいち早くポストでお知らせしているので、見逃したくない方は @ninjin_py_vba をフォローしてみてください🥕

@ninjin_py_vba をフォロー

この記事が役に立ったら、シェアしてもらえると嬉しいです🥕

Xでポスト はてブ LINE Pocket

PROJECT LOG — SERIES INDEX
スポンサーリンク

シリーズ記事一覧

電子工作・自動化ツールの製作記録をシリーズごとにまとめています。気になるテーマからお読みください。


超音波距離センサーHC-SR04をRaspberry Pi Picoで使う|仕組み・配線・精度検証
.uds1-block { position: relative; overflow: hidden; border-radius: 12px; border: 1px solid #f0cba8; background: linear-g…
今週の注目ガジェット〜09/07週〜|電子工作・自作向け新製品まとめ(TickrCast/Radxa Linkr ほか)
ニンジンのガジェット通信 09/07週。ESP32-P4の自作気象モニター、極小IP-KVM「Radxa Linkr」、ESP32のDINレール型PLC、LTE開発キットなど、電子工作で使えそうな新製品5つを解説。
サーボモータの角度をタクトスイッチで可変|OLED(SSD1306)に現在角度をリアルタイム表示
Raspberry Pi Pico 2W×MG996RにタクトスイッチとOLED(SSD1306)を追加。ボタンで角度を可変しながら現在角度をリアルタイム表示するMicroPython実装を、framebufの文字拡大やサーボの遊び対策まで実機ベースで解説します。
MG996RサーボモーターをRaspberry Pi Pico 2Wで動かす:PWM制御とキャリブレーション実践
MG996RサーボモーターをRaspberry Pi Pico 2Wで動かす方法を解説。公称180度のはずが実測198度だった個体差の原因究明から、ホーンのズレ補正、タクトスイッチでの角度制御まで実機検証の様子を紹介します。
Tapo P110Mの消費電力をExcelレポート化する【年次集計・電気代試算・セキュリティ判断編】
FastAPI+openpyxlでTapo P110Mの消費電力をExcelレポート化。15秒間隔の生ログを日次集計する設計や、31円/kWhでの電気代試算、外部公開を見送った理由まで解説します。

コメント