Megadetector Raspi5+AI HAT+26TOPSで動かす

以前AI HAT+2(HAILO10H)にmodel「 MDV6-yolov9-c」を入れて動かせたが、今回は、それより安価なAI HAT+26TOPS(HAILO8)で試してみた。
MDV6-yolov10-cという軽量モデルもあったので今回はこちらで試す事にした。
結果上手く動いたのでAIイノシシ追っ払い装置に使うことにした。

このシステムを構築する為には、2つの壁がある、一つ目は、ダウンロードで提供されているmodel
この場合、Raspberry Pi5+ HAT+2で利用可能(収まる)な最大性能なモデル「MDV6-yolov10-c」をHAT+
用に変換する過程(hefファイルの書き出し)
そして2つ目は、そのhefファイルを使ってイノシシ検出のスクリプトを作成するという事になる。

作業は本ブログの「Jupyter Notebookが動くDockerを作る (Win11用)その1」の環境で行います。
と言ってもhefファイル書き出し作業は、DockerもJupyter Notebookは全く使用しないで、(WSL2)
Ubuntu-24.04のターミナルで行います、WSL2 Ubuntuが動く環境ならOKです。。
(注:python version 3.11指定だが3.12.3でも動いた)

HEFファイルへの書き出し手順

1.必要なもの準備

 (1) Megadetector 「MDV6-yolov10-c」モデルの入手

  model 「MDV6-yolov10-c」はここからダウンロード出来ます。(登録が必要 無料)
  https://zenodo.org/records/20348979

  解凍してみると「model.onnx」が添付されていました。
  これを使用します。

 (2) HAILOのサイトから「hailo_dataflow_compiler」というソフトを入手します。

  ここからダウンロードする。(登録が必要 無料)
  https://hailo.ai/developer-zone/software-downloads/?product=ai_accelerators&device=hailo_8_8l

  これをダウンロードする。
  hailo_dataflow_compiler-3.34.0-py3-none-linux_x86_64.whl

 (3) 量子化キャリブレーション用の画像が必要です。

  webから「AI学習用赤外線夜間動物画像 無料」と検索すると見つかります。
  ここでは「Roboflow」さんから拝借しました。(無料 ユーザー登録必要)
    https://universe.roboflow.com/kawaharalabo/far-infrared-rays-
    animals/dataset/11/download/yolov9

  5000枚程度入ったフォルダを作成ここではフォルダ名「PICTURE」。(imageのみラベルは不要)
  640×480ピクセルの画像になります、画像名は何でも構いません、PICTUREフォルダ内に収めて
  下さい。

  以上(1),(2),(3)はworkspace内に配置しておきます。
  workspaceの場所は、この例では、
  Windows上の作業フォルダー:C:\Users\name\MyDocker1\HAILO8
  WSLからのパス:/mnt/c/Users/name/MyDocker1/HAILO8
  (nameの部分はあなたのユーザー名に変えて下さい)

2.DFC(hailo dataflow compiler)用環境を作成

WSLからのパス:/mnt/c/Users/name/MyDocker1/HAILO8で実行する。(nameはあなたの名前に変える)
(あくまでここでの例なのでパスは任意の場所にして下さい)

Hailo変換環境を作るところです、WSL Ubuntuで、以下を上から順に実行してください。
WSLではWindows側のNVIDIAドライバーを使うため、Linux用ドライバーは入れず、CUDA Toolkitだけ
をインストールします。

sudo apt update
sudo apt install -y python3.12-venv python3.12-dev python3-tk graphviz libgraphviz-dev build-essential git wget

続いてCUDA Toolkit 12.8を入れます。

cd /tmp
wget https://developer.download.nvidia.com/compute/cuda/repos/wsl-ubuntu/x86_64/cuda-keyring_1.1-1_all.deb
sudo dpkg -i cuda-keyring_1.1-1_all.deb
sudo apt-get update
sudo apt-get install -y cuda-toolkit-12-8

nvidia-cuda-toolkit、cuda、cuda-12-8、cuda-driversはインストールしないでください。

次に、DFC 3.3.4用の仮想環境「hailo834」を新しく作り、手元のwheelを入れます。

sudo apt update &&
sudo apt install -y \
  python3.12-venv python3.12-dev python3-tk \
  graphviz libgraphviz-dev build-essential git wget \
  cmake ninja-build &&
python3.12 -m venv ~/hailo834-env &&
source ~/hailo834-env/bin/activate &&
python -m pip install --upgrade pip setuptools wheel &&
python -m pip install \
  /mnt/c/Users/name/MyDocker1/HAILO8/hailo_dataflow_compiler-3.34.0-py3-none-linux_x86_64.whl

次は、同じ(hailo834-env)のターミナルでHailo Model Zoo v2.19.0を導入
DFC 3.34.0 と組み合わせる Model Zoo v2.19.0 を取得します。

cd ~
git clone --depth 1 --branch v2.19.0 \
  https://github.com/hailo-ai/hailo_model_zoo.git \
  hailo_model_zoo_h8
cd ~/hailo_model_zoo_h8

source ~/hailo834-env/bin/activate
python -m pip install --no-build-isolation -e .
hailomz --version
hailomz info yolov10n

Hailo Model Zoo v2.19.0とyolov10nのモデル情報が表示されれば完了です。ここでは HMZ のベース
yolov10n` のネットワーク構造を利用し、MDV6 の 3 クラス用として解析・最適化します。v2.19.0 は DF
C 3.34.0 と対応する版です。

3. Hailo用HARを作る

onnxモデルの確認

ONNX="/mnt/c/Users/name/MyDocker1/HAILO8/model.onnx"
CALIB="$HOME/mdv6_yolov10c_h8_calib_1024"
OUT="$HOME/mdv6_yolov10c_h8"
mkdir -p "$OUT"
cd "$OUT"
test -f "$ONNX" && echo "ONNX: $ONNX"
test -d "$CALIB" && find "$CALIB" -maxdepth 1 -type f | wc -l

ONNX を解析して HAR を作る

source ~/hailo834-env/bin/activate
export PATH=/usr/local/cuda-12.8/bin:$PATH
export XLA_FLAGS=--xla_gpu_cuda_data_dir=/usr/local/cuda-12.8
cd "$OUT"

hailomz parse yolov10n \
  --ckpt "$ONNX" \
  --hw-arch hailo8

正常終了すると、作業フォルダに yolov10n.har ができます。今回のログではモデル出力の NMS 終端6
ノードが検出され、HAR の保存まで完了しました。

3.校正画像を1,024枚コピー

  5,000枚から、再現性のある方法で1,024枚を新しい Linux 側フォルダにコピーします。
  WSの /mnt/c 上で直接キャリブレーションするより、ホームディレクトリ側にコピーして使い
  ます。

cd /mnt/c/Users/name/MyDocker1/HAILO8
mkdir -p ~/mdv6_calib_1024

python - <<'PY'
from pathlib import Path
import random
import shutil

src = next(
    (Path(name) for name in ("OICTURE", "PICTURE") if Path(name).is_dir()),
    None,
)
if src is None:
    raise SystemExit("OICTURE または PICTURE フォルダーが見つかりません")

files = sorted(p for p in src.iterdir() if p.is_file() and p.suffix.lower() == ".jpg")
if len(files) < 1024:
    raise SystemExit(f"JPGが1024枚未満です: {len(files)}枚")

random.seed(42)
dst = Path.home() / "mdv6_calib_1024"
for p in random.sample(files, 1024):
    shutil.copy2(p, dst / p.name)

print(f"校正画像: {len(list(dst.glob('*.jpg')))}枚")
print(f"フォルダー: {dst}")
PY

  最後に 1024 と表示されることを確認します.

4. 最適化を実行 

hailomz optimize yolov10n \
cd ~/mdv6_yolov10c_h8

hailomz optimize yolov10n \
  --har ~/mdv6_yolov10c_h8/yolov10n.har \
  --calib-path /home/name/mdv6_calib_1024 \
  --classes 3 \
  --hw-arch hailo8

classes 3は MegaDetector の animal / person / vehicle の3クラスに合わせる指定です。入力正規化やYOLOv10の出力処理は Model Zoo の設定を利用します。ログの最後に最適化済み HAR の保存先が表
示されます。
今回のログではyolov10n.harとして保存されました。

5.Hailo-8向けHEFへコンパイル

hailomz compile yolov10n \
  --hw-arch hailo8 \
  --har "yolov10n.har" 

HEFは/mnt/c/Users/name/MyDocker1/HAILO8にあります。
yolov10n.hef
こちらをPiへコピーすれば、使用できます。





Raspberry Pi5 + AI HAT2+のセットアップ

Megadetector model 「yolov10n.hef」ファイルをゲットしたのでいよいよRaspberry Pi5の
セットアップ作業に入ります。

Raspberry Pi5とAI HAT+ (26TOPS)を接続組み立てて下さい。(13TOPS Typeでは動きません)
準備が出来たらソフトのインストール作業に入ります

インストール手順

1,1,Raspberry Pi Imagerを使ってRaspberry Pi OS(64bit)
  A port of Debian Trixie with the Raspberry Pi Desktopをインストール

2.下記のコマンドを実行して下さい。

sudo apt update
sudo apt full-upgrade -y
sudo apt install -y dkms hailo-all
sudo reboot

再起動を省略しないでください。起動後に確認します。

hailortcli fw-control identify
hailortcli scan

3.Python 実行環境とライブラリーのインストール

  hailo-allが導入したhailo_platform` を venv から使えるように、システムサイトパッケージを見え
  る venv を作ります。(この例では仮想環境HATを作る)

sudo apt install -y python3-venv python3-dev python3-opencv
python3 -m venv --system-site-packages ~/HAT
source ~/HAT/bin/activate
python -m pip install --upgrade pip
python -m pip install "numpy==1.26.4" "opencv-python==4.11.0.86" paho-mqtt

  opencv-python を入れるときは NumPy 1.26.4 と同時に指定します。Pi の venv 内で NumPy 2.x に
  戻さないでください。既にこの venv が存在する場合は、作り直さず有効化してバージョンを確認
  します。

source ~/HAT/bin/activate
python - <<'PY'
import sys, numpy, cv2
import paho.mqtt.client as mqtt
from hailo_platform import HEF, VDevice
print('Python:', sys.executable)
print('NumPy:', numpy.__version__)
print('OpenCV:', cv2.__version__)
print('HailoRT Python: OK')
print('Paho MQTT: OK')
PY

  この作業で hailo_platform が見つからない場合、venv が --system-site-packages 付きで作られ
  ているか、hailo-all の導入と再起動が完了しているかを確認します。

HEF を Pi に転送して単体検査

Raspberry Pi5に書き出したhefファイルをコピーして配置します。
/home/pi/HAT/yolov10n.hef

HEF の内容を確認します。

hailortcli run /home/pi/HAT/yolov10n.hef --batch-size 1

約103 FPSを表示しました。これで HEF と HailoRT の基本動作を、カメラ/MQTTから切り離して確認で
きます。

スクリプトは以下の内容になります。
Raspberry Pi5に付属している「Thonny」から呼び出して使用します。
Thonnyの設定では、Thonny画面の右下角にある三のところをクリックして「Cofigure Interpreter」
をクリック
次に表示される画面でPython Executableのところのパスを「/home/pi/HAT/bin/python3.13」にしてOK
をクリックして下さい。

import cv2
import json
import os
import time
import numpy as np
import paho.mqtt.client as mqtt

from contextlib import ExitStack
from hailo_platform import (
    HEF,
    VDevice,
    HailoStreamInterface,
    ConfigureParams,
    InferVStreams,
    InputVStreamParams,
    OutputVStreamParams,
    FormatType,
)


# ============================================================
# 設定
# ============================================================
HEF_PATH = "/home/pi/HAT/yolov10n.hef"

CAMERAS = [
    {
        "id": "tapo310a_1",
        # USER:PASSWORDを、現在使用している認証情報に置き換えてください
        "rtsp_url": "rtsp://your_ID:Your_Passwd@192.168.1.101:554/stream1",
    },
]

MQTT_BROKER = "192.168.1.200"#Your MQTT Server Address
MQTT_PORT = 1883
MQTT_TOPIC = "megadt"

SAVE_DIR = "/home/pi/HAT/detected_images"
SAVE_INTERVAL_SEC = 5
CLASS_COOLDOWN_SEC = 10
DEBUG_INTERVAL_SEC = 5

INPUT_SIZE = 640

CLASS_NAMES = {
    0: "animal",
    1: "person",
    2: "vehicle",
}

# HEFのScore threshold
CLASS_THRESHOLDS = {
    0: 0.80,
    1: 0.80,
    2: 0.80,
}

os.makedirs(SAVE_DIR, exist_ok=True)


# ============================================================
# 前処理
# ============================================================
def letterbox_rgb(frame_bgr, size=INPUT_SIZE):
    """縦横比を維持して640x640へリサイズし、RGBに変換する。"""
    height, width = frame_bgr.shape[:2]
    scale = min(size / width, size / height)

    resized_w = int(round(width * scale))
    resized_h = int(round(height * scale))

    resized = cv2.resize(
        frame_bgr,
        (resized_w, resized_h),
        interpolation=cv2.INTER_LINEAR,
    )

    pad_w = size - resized_w
    pad_h = size - resized_h
    left = pad_w // 2
    right = pad_w - left
    top = pad_h // 2
    bottom = pad_h - top

    padded = cv2.copyMakeBorder(
        resized,
        top,
        bottom,
        left,
        right,
        cv2.BORDER_CONSTANT,
        value=(114, 114, 114),
    )

    rgb = cv2.cvtColor(padded, cv2.COLOR_BGR2RGB)
    return np.ascontiguousarray(rgb), scale, left, top


# ============================================================
# NMS出力をクラス別の配列へ整形
# InferVStreamsの戻り値は、空のときと検出ありのときで
# listの構造が異なる場合があるため、両方を処理する。
# ============================================================
def nms_output_by_class(nms_output):
    """HailoRTのNMS出力をクラス別の (N, 5) 配列3つにそろえる。"""

    # バッチなどの1要素ラッパーを外す
    value = nms_output
    while isinstance(value, (list, tuple)) and len(value) == 1:
        value = value[0]

    def as_rows(item, class_id):
        # クラスごとの出力にも1要素ラッパーがある場合に備える
        while isinstance(item, (list, tuple)) and len(item) == 1:
            item = item[0]

        try:
            arr = np.asarray(item, dtype=np.float32)
        except (TypeError, ValueError) as exc:
            raise RuntimeError(
                f"NMSのクラス{class_id}出力を配列化できません。"
                f"type={type(item)}, value={item!r}"
            ) from exc

        if arr.size == 0:
            return np.empty((0, 5), dtype=np.float32)

        if arr.ndim >= 2 and arr.shape[-1] == 5:
            return arr.reshape(-1, 5)

        if arr.ndim == 1 and arr.size % 5 == 0:
            return arr.reshape(-1, 5)

        raise RuntimeError(
            f"NMSのクラス{class_id}出力形状が想定外です: "
            f"shape={arr.shape}, dtype={arr.dtype}"
        )

    # クラスごとの可変長配列を持つリスト/タプル
    if isinstance(value, (list, tuple)) and len(value) == 3:
        return [as_rows(value[i], i) for i in range(3)]

    # (3, N, 5) 形式。N=0の未検出出力もここで扱う
    try:
        arr = np.asarray(value, dtype=np.float32)
    except (TypeError, ValueError) as exc:
        details = []
        if isinstance(value, (list, tuple)):
            for i, item in enumerate(value):
                details.append(
                    f"[{i}] type={type(item).__name__}, "
                    f"shape={getattr(item, 'shape', None)}"
                )
        raise RuntimeError(
            "NMS出力を解釈できません。"
            f"外側の型={type(value).__name__}; " + "; ".join(details)
        ) from exc

    if arr.ndim == 4 and arr.shape[0] == 1:
        arr = arr[0]

    if arr.ndim == 3 and arr.shape[0] == 3 and arr.shape[-1] == 5:
        return [arr[i].reshape(-1, 5) for i in range(3)]

    raise RuntimeError(
        f"NMS出力形状が想定外です: shape={arr.shape}, dtype={arr.dtype}"
    )


# ============================================================
# 後処理
# 検出1件は y_min, x_min, y_max, x_max, score
# ============================================================
def decode_hailo_nms(
    nms_output,
    scale,
    pad_left,
    pad_top,
    orig_w,
    orig_h,
):
    class_arrays = nms_output_by_class(nms_output)
    detections = []

    for class_id, boxes in enumerate(class_arrays):
        for row in boxes:
            if not np.all(np.isfinite(row)):
                continue

            y1, x1, y2, x2, score = map(float, row)

            if score < CLASS_THRESHOLDS[class_id]:
                continue

            # 640x640の正規化座標を元画像座標へ戻す
            x1 = (x1 * INPUT_SIZE - pad_left) / scale
            x2 = (x2 * INPUT_SIZE - pad_left) / scale
            y1 = (y1 * INPUT_SIZE - pad_top) / scale
            y2 = (y2 * INPUT_SIZE - pad_top) / scale

            x1 = float(np.clip(x1, 0, orig_w - 1))
            x2 = float(np.clip(x2, 0, orig_w - 1))
            y1 = float(np.clip(y1, 0, orig_h - 1))
            y2 = float(np.clip(y2, 0, orig_h - 1))

            if x2 <= x1 or y2 <= y1:
                continue

            detections.append({
                "class": CLASS_NAMES[class_id],
                "confidence": round(score, 3),
                "box": [
                    int(round(x1)),
                    int(round(y1)),
                    int(round(x2)),
                    int(round(y2)),
                ],
            })

    return detections


def print_nms_summary(nms_output):
    class_arrays = nms_output_by_class(nms_output)
    summaries = []

    for class_id, class_name in CLASS_NAMES.items():
        boxes = class_arrays[class_id]

        if boxes.size > 0:
            scores = boxes[:, 4]
            scores = scores[np.isfinite(scores) & (scores > 0)]
        else:
            scores = np.empty((0,), dtype=np.float32)

        count = len(scores)
        max_score = float(np.max(scores)) if count else 0.0

        summaries.append(
            f"{class_name}: count={count}, max_score={max_score:.3f}"
        )

    print("[NMS] " + " | ".join(summaries))


# ============================================================
# HEF・MQTT・カメラの初期化
# ============================================================
print("Hailo-8用HEFを読み込み中...")

hef = HEF(HEF_PATH)
input_info = hef.get_input_vstream_infos()[0]
output_infos = hef.get_output_vstream_infos()

if len(output_infos) != 1:
    raise RuntimeError(
        f"HEF出力数が想定外です。期待=1、実際={len(output_infos)}"
    )

output_info = output_infos[0]
OUTPUT_NMS_NAME = output_info.name

print(f"入力: {input_info.name}, shape={input_info.shape}")
print(f"出力: {output_info.name}, shape={output_info.shape}")

mqtt_client = mqtt.Client(client_id="mdv6-pi5")
mqtt_connected = False

try:
    mqtt_client.connect(MQTT_BROKER, MQTT_PORT, 60)
    mqtt_client.loop_start()
    mqtt_connected = True
    print(f"MQTT接続に成功しました: {MQTT_BROKER}:{MQTT_PORT}")
except Exception as exc:
    print(f"MQTT接続に失敗しました。検出処理は続行します: {exc}")

print("カメラストリームを開いています...")

for camera in CAMERAS:
    camera["cap"] = cv2.VideoCapture(
        camera["rtsp_url"],
        cv2.CAP_FFMPEG,
    )
    camera["cap"].set(cv2.CAP_PROP_BUFFERSIZE, 1)
    camera["last_saved_time"] = 0.0
    camera["last_detected_by_class"] = {}
    camera["last_debug_time"] = 0.0

    if camera["cap"].isOpened():
        print(f"{camera['id']}: カメラ接続完了")
    else:
        print(f"{camera['id']}: カメラ接続失敗。RTSP URLを確認してください。")


# ============================================================
# 推論ループ
# ============================================================
mqtt_started = False

print("MegaDetector監視を開始しました。終了するには Ctrl+C を押してください。")

try:
    with VDevice() as target:
        configure_params = ConfigureParams.create_from_hef(
            hef=hef,
            interface=HailoStreamInterface.PCIe,
        )

        network_group = target.configure(hef, configure_params)[0]
        network_group_params = network_group.create_params()

        input_vstreams_params = InputVStreamParams.make(
            network_group,
            format_type=FormatType.UINT8,
        )
        output_vstreams_params = OutputVStreamParams.make(
            network_group,
            format_type=FormatType.FLOAT32,
        )

        # 推論パイプラインとネットワークを開いたまま連続推論する
        with ExitStack() as stack:
            infer_pipeline = stack.enter_context(
                InferVStreams(
                    network_group,
                    input_vstreams_params,
                    output_vstreams_params,
                )
            )
            stack.enter_context(
                network_group.activate(network_group_params)
            )

            while True:
                active_camera_count = 0

                for camera in CAMERAS:
                    cap = camera["cap"]

                    if not cap.isOpened():
                        continue

                    active_camera_count += 1
                    ret, frame = cap.read()

                    if not ret:
                        print(f"{camera['id']}: フレームを取得できません")
                        time.sleep(0.1)
                        continue

                    frame_h, frame_w = frame.shape[:2]
                    input_rgb, scale, pad_left, pad_top = letterbox_rgb(frame)

                    input_buffer = np.expand_dims(input_rgb, axis=0)
                    input_buffer = input_buffer.astype(
                        np.uint8,
                        copy=False,
                    )

                    outputs = infer_pipeline.infer({
                        input_info.name: input_buffer
                    })
                    nms_output = outputs[OUTPUT_NMS_NAME]

                    if (
                        time.monotonic() - camera["last_debug_time"]
                        >= DEBUG_INTERVAL_SEC
                    ):
                        camera["last_debug_time"] = time.monotonic()
                        print_nms_summary(nms_output)

                    detections = decode_hailo_nms(
                        nms_output,
                        scale,
                        pad_left,
                        pad_top,
                        frame_w,
                        frame_h,
                    )

                    now = time.time()
                    last_by_class = camera["last_detected_by_class"]

                    active_detections = [
                        detection
                        for detection in detections
                        if now - last_by_class.get(
                            detection["class"], 0.0
                        ) >= CLASS_COOLDOWN_SEC
                    ]

                    if not active_detections:
                        continue

                    active_classes = sorted({
                        detection["class"]
                        for detection in active_detections
                    })

                    for class_name in active_classes:
                        last_by_class[class_name] = now
                        print(f"{camera['id']}: 検出 -> {class_name}")

                    if mqtt_connected:
                        payload = {
                            "timestamp": now,
                            "camera_id": camera["id"],
                            "detected": True,
                            "count": len(active_detections),
                            "objects": active_detections,
                        }
                        mqtt_client.publish(
                            MQTT_TOPIC,
                            json.dumps(payload),
                        )

                    if now - camera["last_saved_time"] >= SAVE_INTERVAL_SEC:
                        timestamp = time.strftime("%Y%m%d_%H%M%S")
                        save_path = os.path.join(
                            SAVE_DIR,
                            f"{camera['id']}_detected_{timestamp}.jpg",
                        )
                        cv2.imwrite(save_path, frame)
                        camera["last_saved_time"] = now
                        print(f"画像保存: {save_path}")

                if active_camera_count == 0:
                    print("有効なカメラがありません。RTSP URLを確認してください。")
                    time.sleep(5)
                else:
                    time.sleep(0.01)

except KeyboardInterrupt:
    print("\n停止します。")

finally:
    for camera in CAMERAS:
        cap = camera.get("cap")
        if cap is not None:
            cap.release()

    if mqtt_connected:
        mqtt_client.loop_stop()
        mqtt_client.disconnect()

    print("カメラとMQTTを終了しました。")
    

検出間隔が短いと対象が留まっている場合、連続して検出が発布されますので具合が悪いです。
上記スクリプトの以下の部分で調整して下さい。(現在は5s)
・同じ種類を無視する時間(秒)
 CLASS_COOLDOWN_SEC = 5

・種類ごとに誤検出の出やすさを見て調整します。
 HEF内蔵NMSのスコア閾値より高い値で、アプリ側でも絞り込みます

 CLASS_THRESHOLDS = {
 0: 0.50,
 1: 0.50,
 2: 0.50,
 }

・検出画像静止画で保存されます。(枠無し)
 SAVE_INTERVAL_SEC = 5

さてセンサーは出来ましたので今後は、「わんわん吠えまくる装置」
を作って行きます。