実に7年ぶりの記事更新です。
前回の記事で音声認識ができるようになり、次は物体認識したいと思って調査と構築を試みました。
当時、レガシーなAI(生成AIじゃないAI)の新しい物体認識モデルがいろいろ発表され、一つ一つ勉強して実際に動かして検証してということを行ったのですが、思ったような物体認識を実現できない状況で、仕事も忙しくなったり、他に記事にしたい内容が出てきたりということがあり、なかなか記事にできませんでした。
数年前にSonyが物体認識機能が付いたRaspberry Pi Camera(IMX500)の発売を開始したので、会社の援助で購入しました。(1万円ぐらい)
これで先に進められるかと思ったのですが、思ったより認識精度が低く、結局先に進むことができませんでした。
ここ1年ぐらいでClaudeなどの生成AIを利用したプログラム開発がかなり品質が良くなり、仕事でも生成AI任せでプログラムを書くことが増えてきたので、「この品質なら物体認識も出来るかも」と思って再挑戦してみました。
それでも、結構、大変なことにはなったのですが、結果としては、念願かなって物体検出できた!という内容です。
概要
前述の通り、今回の記事は生成AI(以降、Claudeさん)と協力して作業を行いました。
この記事の大半もClaudeさんに書いてもらったものを私のほうでアレンジした内容となっています。
ちなみに、最近、生成AIが作った文書や画像に電子透かしが入るようになった(なる?)そうです。
今回の記事もコピペしたものがあるので、電子透かしが入っているかもしれませんが、「Claudeさんが作った」と宣言しているので特に気にしないでコピペしてます。
今回のゴール
R2D2に私の部屋のテレビやエアコンを赤外線LEDでオンオフしてもらいたいというのが考えている最終的な姿ですが、そのためにはテレビなどの物体検出が出来なければいけません。
そこで、今回はテレビを物体検出する事をゴールとします。
私の部屋には大小2台のテレビがあり(大:TV1、小:TV2)、電源が入っていてもいなくても検出できるようにするのが今回のゴールとなります。
ミニ用語集
Claudeさんがいろいろ説明してくれるのですが、わからない言葉がいっぱい出てきましたのでまとめておきます。
すべて、Claudeさんの説明です。
COCO / COCO版YOLO11n
COCO(Common Objects in Context)は、person・car・tv・laptop など日常的な 80 種類の 物体にラベルが付いた大規模な公開データセットで、物体検出の標準ベンチマークです。
「COCO 版 YOLO11n」は、この 80 クラスで学習済みの YOLO11n モデルのこと。
何も追加学習しなくても 80 クラスの物体をそのまま検出できます。
YOLO11n は Ultralytics 社の物体検出モデル YOLO11 ファミリーの最小サイズ (n = nano、パラメータ約 260 万)で、n/s/m/l/x とサイズ展開があり、 小さいほど高速・省メモリな代わりに精度は下がります。
転移学習(Transfer Learning)
学習済みモデルの重みを出発点にして、別のタスクを学習させる手法です。
ゼロから学習させると数万〜数十万枚のデータが必要ですが、「物体の輪郭・質感を捉える力」を学習済みの重みから引き継げるため、数百枚のデータでも実用的な精度が出せます。
今回は COCO 版 YOLO11n を出発点に、 tv1 / tv2 / laptop の 3 クラスを 480 枚で学習させました。
エポック(epoch)
学習データ全体を 1 回学習し切る単位です。
「100 エポック」は同じ 384 枚(train 分)を 100 周学習させるという意味。
周回ごとに少しずつ重みが調整され、精度が上がっていきます。
周回しすぎると学習データの丸暗記(過学習)が始まるため、検証データ(val)の成績が 改善しなくなったら打ち切るのが定石です(早期終了、本文の patience)
mAP50
物体検出の精度指標(mean Average Precision)です。
検出枠が正解の枠と IoU 0.5 以上(面積の重なり率 50% 以上)で重なっていれば正解とみなして クラスごとの平均適合率(AP)を計算し、それを全クラスで平均した値が mAP50 です。
1.0 が満点で、本文の「mAP50 = 0.99」は「検証データ上ではほぼ取りこぼしなく 検出できている」ことを意味します。
似た指標の mAP50-95 は、IoU の合格ラインを 0.5〜0.95 まで厳しく振って平均したもので、枠の位置の正確さまで含めた評価になります。
AI Camera(IMX500)
Sony IMX500 は「NPU を内蔵したイメージセンサー」です。
撮像素子と同じチップに ニューラルネットワークのアクセラレータと 8MB のメモリが載っており、 推論がセンサー内部で完結します。
ホスト(Raspberry Pi)には画像と一緒に 推論結果のテンソルがメタデータとして届くだけなので、CPU 負荷がほぼゼロになります。
RAM 415MB・4 コアの Zero 2 W でもリアルタイム物体検出ができるのはこの仕組みのおかげです。
制約はモデルサイズです。
ネットワークの重みと実行時メモリの合計を チップ内蔵の 8MB に収める必要があります(後述のメモリレポートで確認できます)。
背景 — なぜカスタムモデルが必要だったか(Claudeさん説明)
AI Camera には Sony / Raspberry Pi 提供の学習済みモデル(COCO データセットの 80 クラス)が 同梱されており、tv や laptop クラスもあります。まずはこれで済むか実測しました。結果は不採用でした。
imx500_network_ssd_mobilenetv2_fpnlite_320x320_pp.rpk での実測値:
- テレビが写っていない条件でも、99.8% のフレームで
tvが検出される(スコア 0.32〜0.50) - テレビが写っている条件でも
tvの最大スコアは 0.56 で、誤検出のlaptop(0.68)の方が高い - スコアは INT8 量子化の影響で 0.06 刻みの離散値になり、本物と誤検出の分離マージンが 量子化 1 ステップ分しかない
つまり閾値をどこに引いても本物と誤検出を分けられません。
また COCO の tv クラスでは 2 台のテレビ(リモコン信号が異なる)を区別できません。
そこで 自分の部屋の対象そのものを学習させた 3 クラスのカスタムモデルに方針転換しました。
対象を「テレビ一般」ではなく tv1(大)と tv2(小)という個体として 学習させるのがポイントです。
ロボットは機種ごとに違う赤外線信号を送る必要があるため、 クラス設計の段階で個体を区別しておきます。
ハードウェアと環境構築
実機(Raspberry Pi Zero2W)の構成
今回の開発の環境は以下の通りです。
RaspberryPi5あたりを使うつもりだったのですが、Raspberry Pi Zero2W で物体検出出来るっていうので、採用してみました。
OSはCUIのみのほうがいいといわれたのですが、画像を見ながら検出を確認したかったのでGUI版(デスクトップ付き)を使っています。
今回は転移学習をさせるのでGPUが載ったPCでモデルの作成を行いたかったのですが、そんな贅沢なものは持っていない(会社が買ってくれなかった。。)ので、いつも開発で使っている3年前のノートPCで行っています。
| 項目 | 内容 |
|---|---|
| 実機 | Raspberry Pi Zero 2 W(RAM 512MB、実質空き約 100MB) |
| カメラ | Raspberry Pi AI Camera(IMX500、4056×3040 10-bit RGGB) |
| 実機 OS | Raspberry Pi OS(Trixie / Debian 13)+ python3-picamera2, imx500-all, imx500-tools |
| 開発機 | WSL2(Ubuntu)、12 コア CPU、RAM 7.8GB、GPU なし |
開発機(WSL)の構成
学習と IMX500 変換に使うパッケージ群はバージョンの組み合わせが厳密です。 Ultralytics が IMX500 export 用に想定している組み合わせ(isolated-imx プロファイル)から 外れると動きません。
| パッケージ | バージョン |
|---|---|
| Python | 3.11 |
| torch / torchvision | 2.11.0+cpu / 0.26.0+cpu |
| ultralytics | 8.4.115 |
| model-compression-toolkit (MCT) | 2.4.5 |
| imx500-converter | 3.17.3 |
| edge-mdt-cl | 1.0.0 |
実機(Raspberry Pi Zero2W)のセットアップ
Raspberry Pi OS(デスクトップ付き)をインストールした Zero2W に AI Camera を接続し、必要なパッケージを入れます。
picamera2(カメラの Python ライブラリ)は libcamera の Python バインディングに依存し apt でしか入らないため、 実機では venv を使わずシステムの Python をそのまま使います。
sudo apt update
sudo apt full-upgrade # AI Camera はファームウェアが新しい必要がある
sudo apt install python3-picamera2 imx500-all imx500-tools
sudo reboot
imx500-all: IMX500 のセンサーファームウェアと、COCO 学習済みのプリセットモデル群 (/usr/share/imx500-models/)imx500-tools: rpk 梱包ツールimx500-package(8 章で使用)
再起動後、カメラが認識されていることを確認します。
rpicam-hello --list-cameras
# → "imx500" が表示されれば OK
なお OpenCV(python3-opencv)はあえて入れません(理由と回避策は後述)。
開発機(WSL)のセットアップ
Python 3.11 が必要です(Ubuntu 標準が 3.12 以降の場合は deadsnakes PPA や pyenv、uv などで用意してください)。
# 学習・変換用の venv
python3.11 -m venv .venv-train
# PyTorch は CPU 版を明示(GPU 版を引くと数 GB 級のダウンロードになる)
.venv-train/bin/pip install torch==2.11.0 torchvision==0.26.0 \
--index-url https://download.pytorch.org/whl/cpu
# Ultralytics と IMX500 変換ツールチェーン(バージョン固定)
.venv-train/bin/pip install ultralytics==8.4.115 \
model-compression-toolkit==2.4.5 edge-mdt-cl==1.0.0 "imx500-converter[pt]==3.17.3"
ハマりどころを 2 つ
pip install edge-mdt[pt](Sony のメタパッケージ)を使ってはいけない。
これは MCT 2.5.x を入れますが、MCT 2.5.x はedge-mdt-cl>=1.1.0を要求し、 一方 Ultralytics はedge-mdt-cl<1.1.0を強制します。
両立しないので、 Ultralytics 側の指定に合わせて MCT 2.4.x / edge-mdt-cl 1.0.0 を個別に入れます。- 実行時は必ず
ULTRALYTICS_AUTOINSTALL=falseを付ける。
Ultralytics は export 実行時に依存パッケージを勝手に書き換える(上げ直しても戻される)ため、 自動インストールを止めておかないと 1. の調整が崩されます。
アノテーション環境(labelImg)の構築とパッチ
転移学習を行うとき、学習データとして対象の画像を大量に撮影してラベルを設定する(アノテーション=対象物部分を四角く線で囲う)のですが、その時に使用するツールがlabelImgです。
labelImg は2021年で更新が止まっており、Python 3.10 以降ではそのままだと ドラッグ・ホイールスクロール・ズーム操作でクラッシュし(落ち)ます。
Claudeさんに落ちるって言ったらライブラリソースを修正してくれました。(ライセンス的にはどうなんでしょうね。。。)
修正内容は以下の通り。
libs/canvas.py の paintEvent(bbox のドラッグ描画。528 行付近):
# 修正前
p.drawRect(left_top.x(), left_top.y(), rect_width, rect_height)
...
p.drawLine(self.prev_point.x(), 0, self.prev_point.x(), self.pixmap.height())
p.drawLine(0, self.prev_point.y(), self.pixmap.width(), self.prev_point.y())
# 修正後
p.drawRect(int(left_top.x()), int(left_top.y()), int(rect_width), int(rect_height))
...
px, py = int(self.prev_point.x()), int(self.prev_point.y())
p.drawLine(px, 0, px, int(self.pixmap.height()))
p.drawLine(0, py, int(self.pixmap.width()), py)
labelImg/labelImg.py の 3 メソッド:
# scroll_request(ホイールスクロール。966 行付近)
# 修正前
bar.setValue(bar.value() + bar.singleStep() * units)
# 修正後
bar.setValue(int(bar.value() + bar.singleStep() * units))
# set_zoom(ズーム値の設定。972 行付近)
# 修正前
self.zoom_widget.setValue(value)
# 修正後
self.zoom_widget.setValue(int(value))
# zoom_request(ズーム時のスクロール位置調整。1026 行付近)
# 修正前
h_bar.setValue(new_h_bar_value)
v_bar.setValue(new_v_bar_value)
# 修正後
h_bar.setValue(int(new_h_bar_value))
v_bar.setValue(int(new_v_bar_value))
注意: pip install --force-reinstall labelImg するとパッチが消えて症状が再発します。
学習データの撮影
データの内容
学習データはスマホ等ではなく、推論に使うのと同じ AI Cameraで撮影します。
レンズの画角・歪み・色味・ISP のチューニングが学習時と推論時で一致していることは、数百枚規模の小さいデータセットでは特に効きます。
もう1つ重要なのが電源オフ状態を撮ることです。
目的は「消えているテレビを見つけて点ける」なので、電源オンの画面だけを学習させると肝心の場面で検出できないモデルになります。
オン/オフの両方を同じ枚数ずつ集めました。
| セッション | 枚数 | 内容 |
|---|---|---|
| tv1_on / tv1_off | 80 / 80 | 大テレビ(電源オン / オフ) |
| tv2_on / tv2_off | 80 / 80 | 小テレビ(電源オン / オフ) |
| laptop | 80 | ノート PC |
| mixed | 80 | 複数の対象が同時に写る構図 |
合計 480 枚、解像度 1280×960(推論時と同じ 4:3)
撮影中はカメラを手で少しずつ動かし、 角度・距離のバリエーションを稼ぎます。
撮影スクリプト
連続撮影するスクリプトをClaudeさんが作ってくれました。
RaspberryPiZero2Wに仕込んで実行すると1秒間隔でカメラ画像(JPEG)を指定フォルダに保存してくれます。
中断しても連番が続きから振られるので、「50 枚撮って構図を変えてまた 50枚」という運用ができます。
推論をしないので IMX500 へのファームウェア転送が発生せず、起動してすぐ撮影が始まります。
capture_dataset.py:
#!/usr/bin/env python3
"""カスタムモデル学習用のデータセットを Zero 2 W の AI Camera で収集する。
一定間隔で JPEG を連番保存する。カメラを手で動かしながら実行し、
角度・距離・照明のバリエーションを稼ぐ。
推論はしないので IMX500 のネットワークファーム転送は発生せず、すぐ撮影が始まる。
本番と同じ光学系・画角で集めることが目的なので、スマホ等ではなくこのカメラで撮る。
例:
# テレビ電源オフの状態を 100 枚(1 秒間隔)
python3 capture_dataset.py --out ~/dataset/tv_off --count 100
# 中断しても連番は続きから振られるので、そのまま再実行してよい
python3 capture_dataset.py --out ~/dataset/tv_off --count 50
"""
import argparse
import sys
import time
from pathlib import Path
from picamera2 import Picamera2
def parse_size(text):
try:
w, h = text.lower().split("x")
return int(w), int(h)
except ValueError:
raise argparse.ArgumentTypeError(f"WIDTHxHEIGHT の形式で指定する: {text}") from None
def parse_args():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--out", required=True, type=Path, help="保存先ディレクトリ(無ければ作る)")
p.add_argument("--count", type=int, default=100, help="撮影枚数")
p.add_argument("--interval", type=float, default=1.0, help="撮影間隔(秒)")
p.add_argument("--size", type=parse_size, default=(1280, 960),
help="解像度。推論時と同じ 4:3 を保つこと(既定 1280x960)")
p.add_argument("--prefix", help="ファイル名の接頭辞(既定: 出力ディレクトリ名)")
p.add_argument("--delay", type=float, default=3.0, help="開始までの猶予秒数")
return p.parse_args()
def next_index(out_dir, prefix):
"""既存の連番の続きを返す。中断して再開しても上書きしないため。"""
existing = sorted(out_dir.glob(f"{prefix}_*.jpg"))
for path in reversed(existing):
tail = path.stem.rsplit("_", 1)[-1]
if tail.isdigit():
return int(tail) + 1
return 1
def main():
args = parse_args()
out_dir = args.out.expanduser()
out_dir.mkdir(parents=True, exist_ok=True)
prefix = args.prefix or out_dir.name
picam2 = Picamera2()
picam2.configure(picam2.create_still_configuration(main={"size": args.size}))
picam2.start()
time.sleep(2) # AE / AWB が落ち着くのを待つ
index = next_index(out_dir, prefix)
w, h = args.size
print(f"# 保存先: {out_dir} 接頭辞: {prefix} 開始番号: {index:04d}", file=sys.stderr)
print(f"# {w}x{h} を {args.interval}s 間隔で {args.count} 枚", file=sys.stderr)
for remaining in range(int(args.delay), 0, -1):
print(f"# {remaining}...", file=sys.stderr, flush=True)
time.sleep(1)
print("# 開始(カメラを少しずつ動かしてください / Ctrl-C で中断)", file=sys.stderr, flush=True)
saved = 0
try:
for i in range(args.count):
started = time.monotonic()
path = out_dir / f"{prefix}_{index:04d}.jpg"
picam2.capture_file(str(path))
saved += 1
index += 1
print(f"[{i + 1:4d}/{args.count}] {path.name}", flush=True)
# 撮影自体にかかった時間を差し引いて間隔を一定に保つ
time.sleep(max(0.0, args.interval - (time.monotonic() - started)))
except KeyboardInterrupt:
print(file=sys.stderr)
finally:
picam2.stop()
total = len(list(out_dir.glob(f"{prefix}_*.jpg")))
print(f"# {saved} 枚保存({out_dir} の合計 {total} 枚)", file=sys.stderr)
if __name__ == "__main__":
main()
撮影の実行手順
スクリプトを実機へ配置し、セッション(撮影条件)ごとに実行します。
1セッション 80枚 × 1秒間隔なので、1回あたり1分半ほどです。
撮影中はカメラをゆっくり動かして角度と距離を変え続けます。
# WSL 側: スクリプトを実機へ転送
rsync -az --exclude '__pycache__' pi/ <実機ホスト>:~/r2d2_dev/pi/
# 実機側: セッションごとに撮影(対象の状態を変えながら 6 回)
cd ~/r2d2_dev/pi
python3 capture_dataset.py --out ~/dataset/tv1_on --count 80 # 大テレビ 電源オン
python3 capture_dataset.py --out ~/dataset/tv1_off --count 80 # 大テレビ 電源オフ
python3 capture_dataset.py --out ~/dataset/tv2_on --count 80 # 小テレビ 電源オン
python3 capture_dataset.py --out ~/dataset/tv2_off --count 80 # 小テレビ 電源オフ
python3 capture_dataset.py --out ~/dataset/laptop --count 80 # ノート PC
python3 capture_dataset.py --out ~/dataset/mixed --count 80 # 複数対象の構図
# WSL 側: 撮影した画像を開発機へ回収
rsync -az <実機ホスト>:~/dataset/ dataset/
アノテーション
YOLO 形式のラベル
YOLO の学習データは、画像1枚につき同名の .txt を置き、1行に1物体を
<クラス番号> <中心x> <中心y> <幅> <高さ>
(座標はすべて画像サイズで正規化した 0〜1 の値)で記述する形式です。
アノテーションには、前述の通り、定番GUI ツールの labelImg を使いました。
クラス名を列挙した classes.txt を用意し、フォルダを指定して起動します。
printf 'tv1\ntv2\nlaptop\n' > dataset/classes.txt
.venv-annot/bin/labelImg dataset/tv1_on dataset/classes.txt dataset/tv1_on
WSL上で実行するとWindowsアプリみたいに画面が起動します。
画面で読み込み元フォルダ選択もできますが、保存先とかも設定が必要になるので、tv1_on、tv1_offなどの切り替えの時は一度画面を終了させて、上記コマンドのフォルダを変更して起動するようにしました。
起動後に 2 つ設定します。
- 左側の形式切り替えボタンで
PascalVOC→YOLOに変更する (既定は PascalVOC 形式の XML。YOLO 形式の既存ラベルを読み書きするために必須) - View → Auto Save mode を有効にする(画像を移動するたびに自動保存)
あとは W キーで矩形作成 → 対象を囲んでクラスを選ぶ、A/D キーで前後の画像へ移動、の繰り返しです。
最重要ルール: 「写っているものはすべてラベルを付ける」
物体検出の学習では、ラベルが付いていない領域は「背景」として学習されます。
Claudeさんの提案でtv1、tv2などの撮影対象ごとにフォルダを分けたのですが、アノテーションは映っているすべての対象物にマークを付けないといけないのでフォルダ分けする意味がないことに気が付きました。
Claudeさん的には2つ以上の対象物は映らない想定だったみたいで、事前説明がないし、撮影のたびに模様替えするわけにもいかないので普通に撮影しました。
結果的に各tv1などのフォルダに含まれる映像には、tv1,tv2,raptopのすべてのアノテーションが入っている状態になりました。
ちなみに対象物が映っていない画像はマーク無しでそのまま学習データに含めました。
付け忘れの機械チェック
Claudeさんにアノテーションしてもらうこともできるようですが、「家の中の情報がクラウドに送信されるのはちょっと。。。」と思ったので、手作業で実施しました。
しっかり確認しながら実施したので数時間かかってしまいました。。。
作業結果についてはClaudeさんがチェックツールを作ってくれたので、これでチェックして問題ないことが確認できました。
このツールは、COCO版YOLO11n を「別の目」として使い、 「画面らしきものが検出されたのに、既存ラベルと重なっていない領域」がある画像を 洗い出すスクリプトだそうです。
既存ラベルとの重なりは IoU(Intersection over Union、 2 つの矩形の重なり率)で判定します。
COCO モデルはテレビの検出が苦手(実測で 3 割取りこぼす)なので、これはあくまで 取りこぼしを減らす補助で、最終確認は目視です。
それでも「不要クラスの残骸が 0 件になった」 ことの機械的な確認には十分役立ちました。
audit_labels.py:
#!/usr/bin/env python3
"""アノテーションの抜けと古いクラスを洗い出す。WSL 側で実行する。
物体検出の学習では、ラベルが付いていない領域は「背景」として学習される。
そのため写り込んだ対象にラベルを付け忘れると、同じ物体に対して
「あるセッションでは前景、別のセッションでは背景」という矛盾した教師信号を与え、
クラスの区別が壊れる。
ここでは COCO 版 YOLO11n で画面らしきものを検出し、既存のラベルと IoU で
照合して「検出されたのにラベルが無い領域」を持つ画像を報告する。
COCO モデルはテレビを苦手(実測で tv セッションの 3 割が未検出)なので、
これは取りこぼしを減らす補助であって、目視確認の代わりにはならない。
例:
.venv-train/bin/python tools/audit_labels.py --dataset dataset
.venv-train/bin/python tools/audit_labels.py --dataset dataset --conf 0.15
"""
import argparse
import sys
from pathlib import Path
# 最終的に学習させたいクラス。これ以外が残っていれば修正漏れ。
WANTED = {"tv1", "tv2", "laptop"}
# COCO 側で画面らしきものとして拾うクラス
COCO_SCREENS = {"tv", "laptop"}
def parse_args():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--dataset", type=Path, default=Path("dataset"))
p.add_argument("--model", default="yolo11n.pt")
p.add_argument("--conf", type=float, default=0.20, help="検出閾値。低くすると取りこぼしが減る")
p.add_argument("--iou", type=float, default=0.35,
help="この IoU 以上で既存ラベルと重なれば「ラベル済み」と見なす")
return p.parse_args()
def to_corners(box):
"""YOLO 形式 (cx, cy, w, h) を (x1, y1, x2, y2) にする。"""
cx, cy, w, h = box
return cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2
def iou(a, b):
ax1, ay1, ax2, ay2 = to_corners(a)
bx1, by1, bx2, by2 = to_corners(b)
ix1, iy1 = max(ax1, bx1), max(ay1, by1)
ix2, iy2 = min(ax2, bx2), min(ay2, by2)
if ix2 <= ix1 or iy2 <= iy1:
return 0.0
inter = (ix2 - ix1) * (iy2 - iy1)
area_a = (ax2 - ax1) * (ay2 - ay1)
area_b = (bx2 - bx1) * (by2 - by1)
return inter / (area_a + area_b - inter)
def read_labels(txt_path):
"""YOLO 形式のラベルを [(class_id, (cx, cy, w, h)), ...] で返す。"""
if not txt_path.exists():
return []
rows = []
for line in txt_path.read_text().splitlines():
parts = line.split()
if len(parts) >= 5:
rows.append((int(parts[0]), tuple(float(x) for x in parts[1:5])))
return rows
def main():
args = parse_args()
dataset = args.dataset.expanduser().resolve()
classes = (dataset / "classes.txt").read_text().splitlines()
print(f"# classes.txt: {classes}")
stale = [] # 学習対象でないクラスが残っているもの
empty = [] # ラベルが空のもの
missing = {} # セッション → 未ラベル領域がある画像
from ultralytics import YOLO
model = YOLO(args.model)
coco_ids = [i for i, name in model.names.items() if name in COCO_SCREENS]
for session_dir in sorted(d for d in dataset.iterdir() if d.is_dir()):
images = sorted(session_dir.glob("*.jpg"))
if not images:
continue
# 先にラベルの中身だけを検査する(推論より速いので分けておく)
labels = {}
for image in images:
rows = read_labels(image.with_suffix(".txt"))
labels[image] = rows
if not rows:
empty.append(image.stem)
for class_id, _ in rows:
name = classes[class_id] if class_id < len(classes) else f"?{class_id}"
if name not in WANTED:
stale.append((image.with_suffix(".txt").relative_to(dataset), name))
predictions = model.predict(source=images, conf=args.conf, classes=coco_ids,
stream=True, verbose=False)
found = []
for image, result in zip(images, predictions):
existing = [box for _, box in labels[image]]
uncovered = 0
for box in result.boxes:
cand = tuple(box.xywhn[0].tolist())
if all(iou(cand, ref) < args.iou for ref in existing):
uncovered += 1
if uncovered:
found.append((image.stem, uncovered))
if found:
missing[session_dir.name] = found
print()
print("=== 学習対象でないクラスが残っているラベル ===")
if stale:
for path, name in stale:
print(f" {path} → {name}")
else:
print(" なし")
print()
print("=== COCO が検出したのにラベルが無い領域を含む画像 ===")
print("(COCO はテレビを苦手なので、ここに出ないものも目視確認が必要)")
if not missing:
print(" なし")
for session, found in missing.items():
names = ", ".join(f"{stem}({n})" for stem, n in found[:8])
more = f" ほか{len(found) - 8}枚" if len(found) > 8 else ""
print(f" {session:<12} {len(found):3d} 枚: {names}{more}")
print()
print(f"=== ラベルが空の画像: {len(empty)} 枚 ===")
print("(対象が写っていないなら背景サンプルとして有効。意図しない空なら要修正)")
if empty:
print(" " + ", ".join(empty[:12]) + (f" ほか{len(empty) - 12}枚" if len(empty) > 12 else ""))
return 1 if stale else 0
if __name__ == "__main__":
sys.exit(main())
train/val 分割(層化分割)
YOLOの学習では、データを学習用(train)と検証用(val)に分けます。
val は「学習に使っていない画像でどれだけ当たるか」を測るためのものです。
480枚を単純にランダム分割すると、運が悪いと val に特定の撮影条件(例えば「小テレビの電源オフ」)がほとんど入らない偏りが起こり得ます。
そこでセッション(撮影条件)ごとに同じ比率で val を抜く層化分割にしました。
6セッション × 80枚から一律 64/16 を抜くので、train 384枚 / val 96枚になり、 すべての撮影条件が両方に含まれます。
乱数シードを固定して再現可能にし、元データは残してコピーで出力します。
ラベルが空の画像(背景サンプル)もそのままコピーします。
(YOLO はラベルファイルが 無い画像を「何も写っていない背景」として学習に使ってくれます。)
split_dataset.py:
#!/usr/bin/env python3
"""dataset/ を YOLO 学習形式へ統合し、train/val に分割する。WSL 側で実行する。
セッションフォルダ(撮影管理の単位)ごとに同じ比率で val を抽出する層化分割。
単純ランダムだと val に特定の撮影条件(例: tv2_off)がほとんど入らない偏りが
起こり得るため、全条件が両方の分割に含まれるようにする。
dataset/ は原本としてそのまま残し、datasets/<名前>/ へコピーする。
ラベルが空の画像も背景サンプルとしてコピーする(YOLO はラベル無しを背景として扱う)。
例:
.venv-train/bin/python tools/split_dataset.py
.venv-train/bin/python tools/split_dataset.py --val-ratio 0.2 --seed 42
"""
import argparse
import random
import shutil
import sys
from pathlib import Path
CLASSES = ["tv1", "tv2", "laptop"]
def parse_args():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--dataset", type=Path, default=Path("dataset"), help="原本(セッションフォルダ群)")
p.add_argument("--out", type=Path, default=Path("datasets/appliances"), help="出力先")
p.add_argument("--val-ratio", type=float, default=0.2)
p.add_argument("--seed", type=int, default=42)
return p.parse_args()
def main():
args = parse_args()
dataset = args.dataset.expanduser().resolve()
out = args.out.expanduser().resolve()
if out.exists():
print(f"出力先 {out} が既に存在する → 中断(消すか --out を変えてから再実行)")
return 1
classes = (dataset / "classes.txt").read_text().splitlines()
if classes != CLASSES:
print(f"classes.txt が想定と違う: {classes}(想定: {CLASSES})→ 中断")
return 1
# セッションごとに収集し、ファイル名の重複が無いことを確かめる
# (出力先はフラットに置くため、重複すると上書きが起こる)
sessions = {}
seen = {}
for session_dir in sorted(d for d in dataset.iterdir() if d.is_dir()):
images = sorted(session_dir.glob("*.jpg"))
if not images:
continue
sessions[session_dir.name] = images
for image in images:
if image.name in seen:
print(f"ファイル名が重複: {seen[image.name]} と {image} → 中断")
return 1
seen[image.name] = image
rng = random.Random(args.seed)
plan = {"train": [], "val": []}
print(f"層化分割 (val {args.val_ratio:.0%}, seed {args.seed}):")
for name, images in sessions.items():
shuffled = images[:]
rng.shuffle(shuffled)
n_val = round(len(images) * args.val_ratio)
plan["val"] += shuffled[:n_val]
plan["train"] += shuffled[n_val:]
print(f" {name:<10} {len(images):3d} 枚 → train {len(images) - n_val:3d} / val {n_val:2d}")
for split, images in plan.items():
img_dir = out / "images" / split
lbl_dir = out / "labels" / split
img_dir.mkdir(parents=True)
lbl_dir.mkdir(parents=True)
empty = 0
for image in images:
shutil.copy2(image, img_dir / image.name)
label = image.with_suffix(".txt")
if label.exists() and label.read_text().strip():
shutil.copy2(label, lbl_dir / label.name)
else:
empty += 1 # ラベル無し = 背景サンプル。ファイルは置かなくてよい
print(f"{split}: 画像 {len(images)} 枚(うち背景 {empty} 枚)")
yaml_path = out / "data.yaml"
names = "\n".join(f" {i}: {name}" for i, name in enumerate(CLASSES))
yaml_path.write_text(
f"path: {out}\ntrain: images/train\nval: images/val\nnames:\n{names}\n"
)
print(f"\ndata.yaml: {yaml_path}")
return 0
if __name__ == "__main__":
sys.exit(main())
YOLO11n の転移学習
モデル選定
今回の転移学習はCOCO80クラスで学習済みの重みを出発点に、自分のデータで微調整する方法です。
「物体の輪郭やテクスチャを捉える力」は学習済みの重みから引き継ぎ、 「tv1 / tv2 / laptop の見分け方」だけを新しく学習させるイメージです。
IMX500には8MB制約があるので、モデルは YOLO11n(nano、パラメータ 2.6M)を選びました。
事前に COCO版YOLO11n を変換してメモリレポートを確認したところ 90%なので、このモデルが利用できる最大のモデルになります。
実行
学習は GPU なしの CPU(12 コア)で実行しました。設定は次の通りです。
imgsz=640: IMX500 変換時と同じ入力サイズに揃えるepochs=100 patience=20: 上限 100 エポック、val 指標が 20 エポック改善しなければ早期終了batch=8 workers=4: RAM 7.8GB の WSL で安全側の値
ULTRALYTICS_AUTOINSTALL=false .venv-train/bin/yolo train \
model=yolo11n.pt data=datasets/appliances/data.yaml \
imgsz=640 epochs=100 patience=20 batch=8 workers=4 device=cpu
面白いのは起動時のこのログで、クラスヘッドの重みがクラス名の一致で引き継がれます。 laptop は COCO にもあるので学習済みの行がそのまま移植され、tv1 / tv2 だけが 新規学習になります:
Remapped 1/3 cls head rows from pretrained weights by class name
Transferred 451/499 items from pretrained weights
結果は 81 エポックで早期終了(ベストは 61 エポック目)、所要 2 時間 29 分でした。 1 エポック約 2 分なので、GPU が無くてもこの規模なら十分現実的です。
学習結果(val 96枚 / 180物体)
Precision は「検出したもののうち正解だった割合」(誤検出の少なさ)、 Recall は「正解のうち検出できた割合」(取りこぼしの少なさ)です。
| クラス | Precision | Recall | mAP50 | mAP50-95 |
|---|---|---|---|---|
| 全体 | 0.956 | 0.987 | 0.988 | 0.951 |
| tv1(大テレビ) | 0.997 | 0.976 | 0.990 | 0.938 |
| tv2(小テレビ) | 0.966 | 0.985 | 0.990 | 0.954 |
| laptop | 0.904 | 1.000 | 0.983 | 0.959 |
懸念していた「2台のテレビの区別」は、両方とも mAP50 = 0.99 で問題ありませんでした。
COCO プリセットでは分離不可能だったものが、対象そのものを学習させることで解決しています。
IMX500 形式への変換(INT8 量子化と rpk 生成)
変換パイプラインの中身
学習済みの best.pt(float32)を IMX500で動く形式にするには、2段階の変換が必要です。
best.pt
│
(WSL: yolo export format=imx)
↓
packerOut.zip
│
(実機: imx500-package)
↓
network.rpk
コマンドyolo export format=imx で以下が実行され、packerOut.zipが作成されます。
- ONNX 形式への変換
- MCT(Sony Model Compression Toolkit)による INT8 量子化
IMX500 の NPU は 8bit 整数演算のため、float32 の重みを INT8 に落とす。
このとき学習画像の一部をキャリブレーションデータとして流し、 各層の値域を実測して量子化パラメータを決める。(Post-Training Quantization) - NMS(Non-Maximum Suppression、重複検出の抑制)のモデルへの組み込み
MCT がMultiClassNMSWithIndices層を追加するため、Pi 側での後処理が不要になる。 - IMX500 コンバータによるコンパイルと
packerOut.zipの生成
imx500-package(実機側のツール、imx500-tools パッケージ)は、packerOut.zipをセンサーへ転送できる rpk 形式に梱包します。
実行
Claudeさんの指示に従い以下の手順で実行しました。
GPU無し、CPUのみで学習にかかったトータル時間は2.5時間ぐらいです。
WSL 側で export
ULTRALYTICS_AUTOINSTALL=false .venv-train/bin/yolo export \
model=runs/detect/train/weights/best.pt format=imx \
data=datasets/appliances/data.yaml imgsz=640
出力先 best_imx_model/ の packerOut.zip と labels.txt を実機へ転送し、rpk 化
# WSL 側
scp runs/detect/train/weights/best_imx_model/{packerOut.zip,labels.txt} \
<実機>:~/models/yolo11n_appliances/
# 実機側
cd ~/models/yolo11n_appliances
imx500-package -i packerOut.zip -o . # → network.rpk (3.2MB)
メモリレポートの確認
export 時に生成される model_imx_MemoryReport.json で 8MB 制約を確認します:
{
"Memory Report": {
"Runtime Memory Physical Size": "4.48MB",
"Model Memory Physical Size": "2.64MB",
"Memory Usage": "7.12MB",
"Total Memory Available On Chip": "8.00MB",
"Memory Utilization": "90%",
"Fit In Chip": true
}
}
利用率 90%で収まりました。
ちなみに、COCO80クラス版 YOLO11n も同じく 90% です。
出力層は小さくなっているのですが、バックボーンが支配的なので大差は出ません。
実機での検出テスト
OpenCV を入れずにスタブで済ませる
picamera2 の IMX500 サポート(picamera2.devices.imx500)は、import しただけで 後処理モジュール群経由で import cv2 が走ります。
しかし cv2 の実際の用途は 「検出結果の描画」と「Pi 側後処理が必要な一部モデルのリサイズ」だけで、 NMS 内蔵済みモデルをヘッドレスで使う今回の構成ではどちらも通りません。
一方 Debian の python3-opencv は依存で VTK / GDAL / Qt5 まで引き込み 72 パッケージ増えます。
RAM 415MB の Zero2Wには重すぎるので、「import は成功するが、実際に呼ばれたら何が足りないか明示して失敗する」スタブを差し込みました。
本物の cv2 が入っていればそちらを使うので、後から opencv を入れても壊れません。
_cv2_stub.py:
"""cv2 が無い環境で picamera2 の IMX500 サポートを import できるようにする。
`picamera2.devices.imx500.__init__` が postprocess モジュール群を無条件に import し、
それらが `import cv2` する。ただし cv2 の実際の呼び出しは
- 検出結果の描画 (rectangle / putText / line / circle)
- Pi 側で後処理が必要なモデル (nanodet / yolov5 / yolov8) の resize
に限られる。pp 済みモデル (imx500_network_*_pp.rpk) をヘッドレスで使う本プロジェクトでは
どちらも通らない。
Debian の python3-opencv は libopencv-viz 経由で VTK / GDAL / Qt5 まで引き込み
72 パッケージ増えるため、RAM 415MB の Zero 2 W では割に合わない。
そこで cv2 が無ければスタブを差す。本物が入っていればそちらを使うので、
後から `sudo apt install python3-opencv` しても壊れない。
"""
import sys
import types
class _Cv2Stub(types.ModuleType):
"""属性アクセスされた時点で、何が足りないかを明示して失敗する。
黙って None を返すと検出結果が静かに壊れるので、必ず例外にする。
"""
def __getattr__(self, name):
raise ModuleNotFoundError(
f"cv2.{name} が必要な処理が呼ばれた。描画または Pi 側後処理"
"(nanodet / yolo 系モデル)を使うなら実機に opencv が必要:\n"
" sudo apt install python3-opencv # 72 パッケージ増える\n"
" もしくは venv に opencv-python-headless"
)
def install() -> bool:
"""cv2 が無ければスタブを sys.modules に差す。picamera2 の import より前に呼ぶ。
Returns:
True なら本物の cv2、False ならスタブを使っている。
"""
existing = sys.modules.get("cv2")
if existing is not None:
return not isinstance(existing, _Cv2Stub)
try:
import cv2 # noqa: F401
except ModuleNotFoundError:
sys.modules["cv2"] = _Cv2Stub("cv2")
return False
return True
自作rpkにはinstrinsicsが入らない
Sonyが提供しているIMX500のモデルには intrinsics(ラベル一覧や bbox 形式などの メタ情報)が埋め込まれているらしく、picamera2 が自動で読んでくれます。
例えば画像に人が映るとその位置を四角で囲った左上と右下の座標(4つの数値)がRaspberryPiZero2Wに送信されますが、どの数値が左上X、左上Y、右下X、右下Yで、人が映ってるということも送信されてきます。
しかし、intrinsicsがないと、どの数値がどの座標を示すか、何が検出されたのかもわからないという状態になるそうです。
自作rpkには intrinsics が埋め込まれていないらしく、追加してあげる必要があるそうです。
これは、check_detect.pyのオプションで--bbox-order xy --bbox-normalizationと指定することで解消しました。
検証スクリプト
検証用に用意したスクリプトの全文です。
ポイント:
- ヘッドレス動作が基本。 推論は IMX500 内で完結するので、Pi 側は
capture_metadata()で検出結果のテンソルを読むだけ。SSH 越しでそのまま動く - 誤検出対策のフィルタを内蔵。 スコア閾値(
--threshold)、画角に占める面積の上限 (--max-area、画角全体を覆う誤検出を切る)、N フレーム連続検出の要求(--stable) - 終了時にラベル別統計を表示。 出現率とスコアの分布(min/median/max)が出るので、 「テレビ有り / 無し」など条件を変えた比較で閾値を決められる
--previewで目視確認。 cv2 を使わずに、picamera2 のプレビュー機能と オーバーレイ(numpy で描いた RGBA 画像を映像に重ねる機能)で検出枠を表示する。 文字が描けないためクラスは枠の色で区別する。 なおshow_preview=Trueの自動選択に任せると、SSH など DISPLAY の無い環境では DRM プレビューが選ばれ、デスクトップが画面を握っている実機では “Failed to reserve DRM plane” で落ちる。Qt プレビューを明示するのが確実
check_detect.py:
#!/usr/bin/env python3
"""M1: AI Camera (IMX500) 単体で物体検出できることをヘッドレスで確認する。
Zero 2 W 上で実行する。公式デモ (imx500_object_detection_demo.py) と違い
プレビュー表示も OpenCV も使わないので SSH 越しにそのまま動く。
推論は IMX500 内で完結し、ここでは検出結果のメタデータを読むだけ。
SSD MobileNetV2 は画角全体を弱いスコアで何かのクラスに当てはめる誤検出が多い。
それを切り分けられるよう、面積比 (bbox が画角に占める割合) を常に出力し、
終了時にラベル別の統計を表示する。
例:
python3 check_detect.py --list-labels # モデルの認識クラス一覧
python3 check_detect.py --summary # 統計のみ(誤検出の実態調査用)
python3 check_detect.py --label tv -t 0 # tv だけを Ctrl-C まで監視
python3 check_detect.py --max-area 0.8 --stable 5 # 誤検出を絞る
python3 check_detect.py --json > det.jsonl # 1 行 1 JSON で記録
python3 check_detect.py --preview -t 0 # デスクトップで映像+検出枠を表示
--preview は実機のデスクトップ(またはそのターミナル)から使う。cv2 は使わず、
picamera2 のプレビューにオーバーレイ(numpy 描画の RGBA 画像)で枠を重ねる。
文字は描けないためクラスは枠の色で区別する(起動時に色の対応を表示)。
"""
import argparse
import json
import statistics
import sys
import time
from collections import deque
from pathlib import Path
import numpy as np
import _cv2_stub
# picamera2 の IMX500 サポートは cv2 を無条件 import する。実機に opencv を
# 入れていない場合はスタブで代替する(詳細は _cv2_stub の docstring)。
HAVE_REAL_CV2 = _cv2_stub.install()
from picamera2 import Picamera2
from picamera2.devices import IMX500
from picamera2.devices.imx500 import NetworkIntrinsics
DEFAULT_MODEL = "/usr/share/imx500-models/imx500_network_ssd_mobilenetv2_fpnlite_320x320_pp.rpk"
def parse_args():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--model", default=DEFAULT_MODEL, help="rpk モデルのパス")
p.add_argument("--threshold", type=float, default=0.55, help="検出スコアの閾値")
p.add_argument("--label", action="append", default=None,
help="対象クラス名(複数指定可)。省略時は全クラス")
p.add_argument("--labels", metavar="PATH",
help="ラベルファイル(1 行 1 クラス)。自作 rpk はラベルを持たないので必須")
p.add_argument("--bbox-order", choices=["yx", "xy"], default=None,
help="bbox の座標順。yx=(y0,x0,y1,x1) / xy=(x0,y0,x1,y1)。自作 rpk では明示が必要")
p.add_argument("--bbox-normalization", action=argparse.BooleanOptionalAction, default=None,
help="bbox が入力サイズで正規化されている場合に指定")
p.add_argument("--dump-outputs", action="store_true",
help="最初の推論フレームの出力テンソルの形と値を表示して終了(出力形式の実測用)")
p.add_argument("-t", "--duration", type=float, default=30.0,
help="観測秒数。0 なら Ctrl-C まで無期限")
p.add_argument("--max-area", type=float, default=1.0, metavar="FRAC",
help="bbox が画角に占める割合の上限。画角全体を覆う誤検出を切る(例: 0.8)")
p.add_argument("--stable", type=int, default=1, metavar="N",
help="直近 N フレーム連続で出たラベルだけ採用する。ちらつく誤検出を除去(既定 1 = 無効)")
p.add_argument("--interval", type=float, default=0.0, metavar="SEC",
help="同一ラベルの出力を最短 SEC 間隔に間引く(既定 0 = 全件出力)")
p.add_argument("--summary", action="store_true",
help="毎フレームの行を出さず、終了時の統計だけ表示する")
p.add_argument("--preview", action="store_true",
help="デスクトップにプレビューを表示し、検出枠をオーバーレイで重ねる"
"(クラスは枠の色で区別。実機の GUI 環境が必要)")
p.add_argument("--json", action="store_true", help="1 行 1 JSON で出力する")
p.add_argument("--snapshot", metavar="PATH",
help="画角確認用に JPEG を 1 枚保存して終了(ヘッドレスでの向き合わせ用)")
p.add_argument("--list-labels", action="store_true", help="クラス一覧を表示して終了")
p.add_argument("--print-intrinsics", action="store_true",
help="モデルの network_intrinsics を表示して終了")
return p.parse_args()
def load_intrinsics(imx500, args):
"""モデルに埋め込まれた intrinsics を取得し、コマンドライン指定で上書きする。
自作の rpk にはラベルも bbox の形式も埋め込まれない(imx500-package の -f は
入力テンソルの正規化設定であってこれらの情報ではない)。そのため
--labels / --bbox-order / --bbox-normalization での明示が必要になる。
"""
intrinsics = imx500.network_intrinsics
if not intrinsics:
intrinsics = NetworkIntrinsics()
intrinsics.task = "object detection"
elif intrinsics.task != "object detection":
sys.exit(f"物体検出用のモデルではない: task={intrinsics.task}")
if args.labels:
intrinsics.labels = Path(args.labels).expanduser().read_text().splitlines()
if args.bbox_order is not None:
intrinsics.bbox_order = args.bbox_order
if args.bbox_normalization is not None:
intrinsics.bbox_normalization = args.bbox_normalization
if intrinsics.labels is None:
sys.exit("モデルにラベルが埋め込まれていない。--labels でラベルファイル(1 行 1 クラス)を渡す")
intrinsics.update_with_defaults()
return intrinsics
def get_labels(intrinsics):
labels = intrinsics.labels
if intrinsics.ignore_dash_labels:
labels = [x for x in labels if x and x != "-"]
return labels
# --preview 用。クラス番号順に割り当てる枠の色 (RGBA)。文字描画は cv2 無しでは
# 難しいので、クラスの区別は色で行う(起動時に対応表を stderr へ出す)。
PREVIEW_COLORS = [
("赤", (255, 60, 60, 220)),
("青", (60, 120, 255, 220)),
("緑", (60, 255, 60, 220)),
("黄", (255, 255, 60, 220)),
]
def start_qt_preview(picam2):
"""デスクトップに Qt プレビューウィンドウを開く。
show_preview=True の自動選択に任せると、DISPLAY の無い環境(SSH など)では
DRM プレビューが選ばれ、デスクトップが画面を握っている実機では
"Failed to reserve DRM plane" で(別スレッド内で)落ちる。
そのため Qt を明示して開き、開けなければ対処法を出して終了する。
"""
from picamera2 import Preview
errors = []
for kind in (Preview.QTGL, Preview.QT):
try:
picam2.start_preview(kind)
return
except Exception as e: # ImportError / Qt のディスプレイ接続失敗など
errors.append(f"{kind.name}: {e}")
sys.exit(
"プレビューを開けなかった:\n " + "\n ".join(errors) + "\n"
"実機デスクトップのターミナルから実行する。SSH からは DISPLAY=:0 を付けて試す。\n"
"それでも駄目なら: sudo apt install python3-pyqt5 python3-opengl"
)
def draw_box(overlay, x, y, w, h, color, thickness=4):
"""RGBA オーバーレイ配列に枠線だけの矩形を描く(塗りつぶさない)。"""
frame_h, frame_w = overlay.shape[:2]
x0, y0 = max(0, x), max(0, y)
x1, y1 = min(frame_w, x + w), min(frame_h, y + h)
if x1 <= x0 or y1 <= y0:
return
t = thickness
overlay[y0:min(y0 + t, y1), x0:x1] = color # 上辺
overlay[max(y1 - t, y0):y1, x0:x1] = color # 下辺
overlay[y0:y1, x0:min(x0 + t, x1)] = color # 左辺
overlay[y0:y1, max(x1 - t, x0):x1] = color # 右辺
def parse_detections(imx500, intrinsics, picam2, metadata, labels, threshold):
"""メタデータの出力テンソルを検出リストへ変換する。
推論結果がまだ乗っていないフレームでは None を返す。
座標は convert_inference_coords で ISP 出力(main ストリーム)の
ピクセル座標 (x, y, w, h) に変換される。
"""
np_outputs = imx500.get_outputs(metadata, add_batch=True)
if np_outputs is None:
return None
if intrinsics.postprocess == "nanodet":
# nanodet 系は Pi 側で NMS が必要。既定の SSD MobileNetV2 系のみ対象。
sys.exit("nanodet 系モデルは未対応(postprocess_nanodet_detection が必要)")
boxes, scores, classes = np_outputs[0][0], np_outputs[1][0], np_outputs[2][0]
_, input_h = imx500.get_input_size()
if intrinsics.bbox_normalization:
boxes = boxes / input_h
if intrinsics.bbox_order == "xy":
boxes = boxes[:, [1, 0, 3, 2]]
frame_w, frame_h = picam2.camera_configuration()["main"]["size"]
frame_area = frame_w * frame_h
detections = []
for box, score, category in zip(boxes, scores, classes):
if score <= threshold:
continue
x, y, w, h = imx500.convert_inference_coords(box, metadata, picam2)
detections.append({
"label": labels[int(category)],
"score": round(float(score), 3),
"box": [int(x), int(y), int(w), int(h)],
# 画角中心を (0.5, 0.5) とする正規化中心座標。M5 の正対制御で使う。
"center": [round((x + w / 2) / frame_w, 4), round((y + h / 2) / frame_h, 4)],
# bbox が画角に占める割合。1.0 に近いものは「画角全体を何かに当てはめた」誤検出の疑い。
"area": round(w * h / frame_area, 3),
})
return detections
def print_summary(stats, frames, elapsed):
"""ラベル別の統計を stderr に出す。テレビ有無など条件を変えて比較するのに使う。
1 フレームに同じラベルの bbox が複数出ることがあるため、
「件数」(bbox の総数)と「出現F」(そのラベルが 1 件以上出たフレーム数)を分けて示す。
"""
print(f"# --- 統計: {elapsed:.1f}s / 推論 {frames} フレーム ---", file=sys.stderr)
if not stats:
print("# 検出なし", file=sys.stderr)
return
print(f"# {'label':<14}{'件数':>7}{'出現F':>7}{'出現率':>8} "
f"{'score min/med/max':<22}{'area med':>9}", file=sys.stderr)
for label, rec in sorted(stats.items(), key=lambda kv: -kv[1]["frame_count"]):
scores, areas = rec["scores"], rec["areas"]
rate = rec["frame_count"] / frames * 100 if frames else 0.0
span = f"{min(scores):.2f}/{statistics.median(scores):.2f}/{max(scores):.2f}"
print(f"# {label:<14}{len(scores):>7}{rec['frame_count']:>7}{rate:>7.1f}% "
f"{span:<22}{statistics.median(areas):>9.2f}", file=sys.stderr)
def main():
args = parse_args()
# Picamera2 のインスタンス化より前に呼ぶ必要がある
imx500 = IMX500(args.model)
intrinsics = load_intrinsics(imx500, args)
labels = get_labels(intrinsics)
if args.print_intrinsics:
print(intrinsics)
return
if args.list_labels:
for i, name in enumerate(labels):
print(f"{i:3d} {name}")
return
picam2 = Picamera2(imx500.camera_num)
config = picam2.create_preview_configuration(
controls={"FrameRate": intrinsics.inference_rate}, buffer_count=12
)
# 初回はカメラへのネットワークファーム転送に時間がかかるので進捗を出す
imx500.show_network_fw_progress_bar()
picam2.configure(config)
if args.preview:
start_qt_preview(picam2)
picam2.start()
if intrinsics.preserve_aspect_ratio:
imx500.set_auto_aspect_ratio()
if args.snapshot:
time.sleep(2) # AE / AWB が落ち着くのを待つ
picam2.capture_file(args.snapshot)
picam2.stop()
print(f"# snapshot 保存: {args.snapshot}", file=sys.stderr)
return
if args.dump_outputs:
# 出力テンソルの並びと値域を実測する。自作 rpk では bbox の座標順も
# 正規化の有無も rpk からは分からないため、ここで確かめてから指定する。
outputs = None
for _ in range(60): # 推論結果がメタデータに乗るまで数フレームかかる
outputs = imx500.get_outputs(picam2.capture_metadata(), add_batch=True)
if outputs is not None:
break
picam2.stop()
if outputs is None:
sys.exit("60 フレーム待っても推論結果が得られなかった")
print(f"# input_size={imx500.get_input_size()} 出力テンソル数={len(outputs)}")
for i, tensor in enumerate(outputs):
values = tensor[0] if tensor.ndim > 1 else tensor
print(f"# output_{i}: shape={tensor.shape} dtype={tensor.dtype}")
print(f"# 先頭3件: {values[:3].tolist()}")
return
wanted = set(args.label) if args.label else None
frame_w, frame_h = picam2.camera_configuration()["main"]["size"]
overlay = None
if args.preview:
overlay = np.zeros((frame_h, frame_w, 4), dtype=np.uint8)
legend = " ".join(
f"{name}={PREVIEW_COLORS[i % len(PREVIEW_COLORS)][0]}"
for i, name in enumerate(labels)
)
print(f"# preview 枠の色: {legend}", file=sys.stderr)
print(f"# model={args.model}", file=sys.stderr)
print(f"# cv2={'real' if HAVE_REAL_CV2 else 'stub'} frame={frame_w}x{frame_h} "
f"inference_rate={intrinsics.inference_rate}", file=sys.stderr)
print(f"# threshold={args.threshold} max_area={args.max_area} stable={args.stable} "
f"interval={args.interval}", file=sys.stderr)
history = deque(maxlen=max(1, args.stable))
last_printed = {}
stats = {}
start = time.monotonic()
frames = 0
try:
while True:
elapsed = time.monotonic() - start
if args.duration and elapsed >= args.duration:
break
detections = parse_detections(
imx500, intrinsics, picam2, picam2.capture_metadata(), labels, args.threshold
)
if detections is None:
continue
frames += 1
# 画角全体を覆う bbox は「何もないシーンを丸ごと分類した」誤検出の典型
detections = [d for d in detections if d["area"] <= args.max_area]
if wanted:
detections = [d for d in detections if d["label"] in wanted]
# プレビューには --stable の確定を待たず、フィルタ通過分をそのまま描く
# (ちらつきも含めて生の検出挙動を見るための表示なので)
if overlay is not None:
overlay.fill(0)
for d in detections:
color = PREVIEW_COLORS[labels.index(d["label"]) % len(PREVIEW_COLORS)][1]
draw_box(overlay, *d["box"], color)
picam2.set_overlay(overlay)
# 直近 N フレームすべてに出ているラベルだけを確定扱いにする
history.append({d["label"] for d in detections})
if len(history) < history.maxlen:
continue
confirmed = set.intersection(*history)
seen_this_frame = set()
for d in detections:
if d["label"] not in confirmed:
continue
rec = stats.setdefault(d["label"], {"scores": [], "areas": [], "frame_count": 0})
rec["scores"].append(d["score"])
rec["areas"].append(d["area"])
if d["label"] not in seen_this_frame:
seen_this_frame.add(d["label"])
rec["frame_count"] += 1
if args.summary:
continue
if args.interval and elapsed - last_printed.get(d["label"], -1e9) < args.interval:
continue
last_printed[d["label"]] = elapsed
if args.json:
print(json.dumps({"t": round(elapsed, 2), **d}), flush=True)
else:
x, y, w, h = d["box"]
cx, cy = d["center"]
print(f"[{elapsed:6.1f}s] {d['label']:<16} {d['score']:.2f} "
f"box=({x},{y},{w},{h}) center=({cx:.3f},{cy:.3f}) area={d['area']:.2f}",
flush=True)
except KeyboardInterrupt:
pass
finally:
picam2.stop()
print_summary(stats, frames, time.monotonic() - start)
if __name__ == "__main__":
main()
実行コマンド(プレビュー付き・目視確認用)
python3 check_detect.py --model ~/models/yolo11n_appliances/network.rpk \
--labels ~/models/yolo11n_appliances/labels.txt \
--bbox-order xy --bbox-normalization \
--threshold 0.55 --stable 3 --preview -t 0
実機のGUIで上記のコマンドをたたくと実機のディスプレイに画面が表示されます。
ただ、実機で実行して結果サマリをWindowsにコピー(VNC Viewerを使ってます)すると文字化けしちゃうので、避けたい場合はSSHで実行してください。
SSHでリモート実行するときは画面表示するディスプレイ指定が必要です。(先頭にDISPLAY=:0をつける)
DISPLAY=:0 python3 check_detect.py --model ~/models/yolo11n_appliances/network.rpk \
--labels ~/models/yolo11n_appliances/labels.txt \
--bbox-order xy --bbox-normalization \
--threshold 0.55 --stable 3 --preview -t 0
検証結果と運用パラメータ
閾値 0.55 + --stable 3(3 フレーム連続で確定)で、条件を変えて各 1〜2 分計測しました。
「出現率」は、そのラベルが検出されたフレームの割合です。
テレビ 2 台とも電源オフ(本命の条件):
# label 件数 出現F 出現率 score min/med/max
# tv2 156 156 98.1% 0.87/0.96/0.98
# tv1 156 156 98.1% 0.88/0.93/0.96
真っ黒な画面のテレビ 2台を、ほぼ全フレームで正しく検出・区別できています。
誤検出なし。
電源オフ状態を学習データに入れた狙いがそのまま結果に出ました。
テレビ 2 台とも電源オン:
# label 件数 出現F 出現率 score min/med/max
# tv2 186 186 98.9% 0.90/0.95/0.97
# tv1 155 155 82.4% 0.56/0.85/0.95
# laptop 23 22 11.7% 0.56/0.68/0.82
対象なし(壁に向ける): 検出なし。
ノート PC + テレビ:
# label 件数 出現F 出現率 score min/med/max
# tv1 185 182 97.3% 0.56/0.90/0.95
# laptop 181 175 93.6% 0.56/0.93/0.97
# tv2 165 165 88.2% 0.90/0.95/0.97
まとめ
数年間解決できなかった物体検出がClaudeさんの協力でようやく実現できました。
IMX500のプリセットモデルで検出できず、YOLOの転移学習が必要そうだとは気が付いていたのですが、画像の撮影やアノテーションの手間を考えるとなかなか手が出せなかったのですが、Claudeさんがツールや手順を説明してくれて、それほど苦労なく実現できました。
それでも2日ほどかかりましたが。。。
COCO プリセットでは「閾値をどこに引いても本物と誤検出を分けられない」状態だったものが、対象そのものを 480 枚学習させることで、量子化後の実機でも以下のレベルに到達しました。
- 電源オフのテレビ 2 台を出現率 98.1% で検出・区別(誤検出ゼロ)
- 本物のスコア(中央値 0.93〜0.96)と誤検出(〜0.82)が明確に分離
実際に画面を見ながら検出した感じだと、テレビ全体がが核に入っていなくても検出できるし、誤検出はほとんどありませんでした。
かなり使える状態と思います。
やっと前に進めた感じがします。
今回作ったのは物体検出するプログラムだけなので、今後、サーボモーター制御で検出したものの方向を向かせて赤外線LEDでテレビを制御して。。。といろいろやっていきたいです。
