メインコンテンツまでスキップ

モデルの出力から検出ボックスを読み取る

項目
カテゴリモデルと推論
難易度中級
推定所要時間15-20 minutes
ラベルpostprocessing, boxdecode, detection

検出器は、直接バウンディングボックスを返しません。その生の出力は、意味のあるものになる前に、しきい値処理、非最大抑制、および座標マッピングが必要な特徴マップのスタックです。SimaBoxDecodeは、これらすべてを1つの最適化されたステップで実行する後処理ステージであり、推論テンソルをソース画像のピクセル単位の最終的な検出に変換します。

この章では、そのデコードを構成します。つまり、decode_typeを使用してモデルファミリーを選択し、スコアしきい値で信頼度を制御し、NMS IoUしきい値で重複を抑制し、top_kで出力を制限します。その後、モデルを実行し、検出された数を読み取ります。最終的には、構成された検出器パイプラインと、その出力から読み取った検出数のカウントが得られ、さらに(以下に示す「実践」の参照)完全なワイヤ形式も得られるため、任意のランタイムでバウンディングボックスを自分で解析できます。

ウォークスルー

デコードを構成する

これらのオプションは、入力の契約と後処理の動作の両方を設定します。decode_type(ここではYoloV8)は、モデルファミリーのデコードパスを選択します。信頼度しきい値は、NMSの前に弱い候補を削除します。NMS IoUしきい値は、重複するバウンディングボックスの結合をどの程度積極的に行うかを制御します。top_kは、決定的な下流のコストのために最終的なカウントを制限します。そして、boxdecode_original_width/boxdecode_original_heightは、デコードされた座標をソース画像のピクセルにマッピングします。これらの各項目の調整に関するガイダンスは、以下に示す「実践」にあります。

decode_typeは、BoxDecodeType::YoloV8 enumを受け取ります。しきい値/NMS/top_kの値は、Model::Optionsではなく、後でstages::BoxDecodeOptionsを通じて渡されます。

tutorials/007_read_detection_boxes/read_detection_boxes.cpp
simaai::neat::Model::Options opt;
opt.preprocess.color_convert.input_format = simaai::neat::PreprocessColorFormat::BGR;
opt.preprocess.input_max_width = bgr.cols;
opt.preprocess.input_max_height = bgr.rows;
opt.preprocess.input_max_depth = bgr.channels();
opt.decode_type = simaai::neat::BoxDecodeType::YoloV8;

モデルを構築する

アーカイブとオプションからModelを構築すると、デコード構成がモデルにバインドされ、そこから派生した推論および後処理ステージで上記の設定が使用されます。

tutorials/007_read_detection_boxes/read_detection_boxes.cpp
simaai::neat::Model model(model_path, opt);

前処理、推論、およびデコードを実行する

ここでは、フレームが前処理、MLA推論、およびボックスデコーダーを通過し、検出出力が生成されます。

処理フローは段階的に明確に定義されています。stages::Preproc は入力テンソルを生成し、stages::Infer はモデルを実行し、stages::BoxDecodeOptionsdetection_threshold = 0.55nms_iou_threshold = 0.5top_k = 100 を含む)が、次に実行されるデコードを構成します。

tutorials/007_read_detection_boxes/read_detection_boxes.cpp
simaai::neat::TensorList pre = simaai::neat::stages::Preproc(std::vector<cv::Mat>{bgr}, model);
simaai::neat::Sample infer_samples = simaai::neat::stages::Infer(
simaai::neat::Sample{simaai::neat::sample_from_tensors(pre)}, model);
if (infer_samples.empty())
throw std::runtime_error("infer stage returned no samples");
simaai::neat::Sample infer = infer_samples.front();

simaai::neat::stages::BoxDecodeOptions box(simaai::neat::BoxDecodeType::YoloV8);
(void)box.decode_type;
(void)bgr.cols;
(void)bgr.rows;
box.detection_threshold = 0.55;
box.nms_iou_threshold = 0.5;
box.top_k = 100;

ボックスの読み込み

最後に、デコードの出力を、実際に使用できる形式に変換します。

stages::BoxDecodeResults(...)BoxDecodeResultList を返し、最初の結果の boxes ベクトルは、すでにソースピクセルにクランプされた {x1, y1, x2, y2, score, class_id} にパースされているため、decoded.boxes.size() が検出の数になります。

tutorials/007_read_detection_boxes/read_detection_boxes.cpp
// BoxDecode parses the "BBOX" tensor into {x1, y1, x2, y2, score, class_id}
// entries clamped to original_width x original_height source pixels.
simaai::neat::BoxDecodeResultList decoded_results =
simaai::neat::stages::BoxDecodeResults(simaai::neat::Sample{infer}, model, box);
if (decoded_results.empty())
throw std::runtime_error("boxdecode result parser returned no results");
const simaai::neat::BoxDecodeResult& decoded = decoded_results.front();

実行

Python および C++ (事前にビルドされたもの) コマンドを、Neat インストールルート ( share/lib/ を含むディレクトリ) から実行します。ソースからビルド コマンドは、リポジトリルート から実行します。

C++ (prebuilt):

./lib/sima-neat/tutorials/tutorial_007_read_detection_boxes \
--model /tmp/yolo_v8s.tar.gz --image /path/to/frame.jpg

C++ (build from source):

./build.sh --target tutorial_007_read_detection_boxes
./build/tutorials-standalone/tutorial_007_read_detection_boxes \
--model /tmp/yolo_v8s.tar.gz --image /path/to/frame.jpg

期待される出力 (ボックスの数はフレームによって異なります。合成フレームではゼロになります):

boxes=0
[OK] 007_read_detection_boxes

(Pythonビルドでは、detections=...が出力されます。ランタイムでBoxDecodeをmodel.runに接続していない場合は、raw_output_heads=...が出力されます。)この章のC++ソースを、カスタムのCMakeLists.txtを使用して独自のプロジェクトに統合する方法(追加のフォルダーは不要)については、ランディングページにあるチュートリアルの実行方法を参照してください。

実践

SimaBoxDecode は、BBOX というタグが付けられた単一の出力テンソルを出力します。このテンソルには、ランタイムパーサーが浮動小数点数の検出に解釈する、パックされたバイトバッファが含まれています。この2層の契約(ワイヤバッファとパースされた Box レコード)を理解することが、PythonまたはC++のいずれかから出力を読み取るための鍵となります。

BBOX テンソル

デコードステージは、入力フレームごとに1つの BBOX テンソルを生成します。

フィールド
semantic.detection.format"BBOX"
dtypeUInt8
shapeランク1: [N_bytes]。ここで、N_bytes は、モデルアーカイブにパックされたバッファの容量です(たとえば、標準の YOLOv8 パックでは [20160])。

テンソルの形状は、バイト数であり、検出の数ではありません。パックされたバイトには、小さなヘッダーと、固定サイズのボックスレコードの連続した配列が含まれています。N_bytes は、モデルアーカイブの buffers.input[0].size フィールド(ボックスデコードステージの構成JSON内)によって決定され、デコーダーが1つのフレームで出力できる最大検出数を制限します(ランタイムの次元がパッケージ化された値とどのように相互作用するかについては、「契約のオーバーライド」を参照)。

パックされたワイヤ形式

uint8 バッファは、リトルエンディアン形式でレイアウトされています。

offset size content
------ ---- -------
0 4 uint32 N = number of valid detections in this frame
4 24 RawBox[0]
28 24 RawBox[1]
. . ...
. . RawBox[N-1]
(trailing bytes up to buffer capacity are padding, ignored)

RawBoxレコードは24バイトです。

レコード内のオフセットサイズフィールド意味
04int32xソースピクセルにおける左上のx座標
44int32yソースピクセルにおける左上のy座標
84int32wソースピクセルにおける幅
124int32hソースピクセルにおける高さ
164float32scoreNMS後の検出信頼度([0.0, 1.0]における値で、detection_thresholdの値でフィルタリングされる)
204int32class_id予測されたクラスID(モデル定義、0から始まるインデックス、クラス名マップはモデルアーカイブのメタデータに格納)

1つのレコードに一致する標準的なPython struct形式は"<iiiifi"です(リトルエンディアン、4つの符号付き整数、1つの浮動小数点数、1つの符号付き整数)。

ランタイムの解析ヘルパー(parse_bbox_bytes / decode_bbox_tensorinclude/pipeline/DetectionTypes.h内)、tests/unit_testing/unit_detection_types_bbox_test.cppはワイヤ契約を固定します)は、各RawBoxを、後続のコードで使用するためのBox構造体に拡張します。

struct Box {
float x1, y1, x2, y2; // x2 = x + w, y2 = y + h; clamped to [0, img_w|h]
float score;
int class_id;
};

座標空間

BBOXからデコードされた座標は、元の画像ピクセルにあり、これは、original_width / original_heightとして渡された(またはモデルアーカイブにパッケージ化された)のと同じ座標系です。これらは[0, 1]に正規化されておらず、モデルの内部のレターボックス形式の入力空間で表現されていません。パーサーは(x1, y1, x2, y2)[0, original_width] / [0, original_height]にクリップするため、呼び出しコードはこれらをソースフレームに直接描画できます。

動作例

チュートリアルのランタイム構成(original_width = 640original_height = 640top_k = 100)と、標準のYOLOv8パック(boxdecode構成内のbuffers.input[0].size = 20160)を使用すると、デコードされた単一のフレームは次のようになります。

  • out.kind == SampleKind.Tensor
  • out.payload_tag == "BBOX"
  • out.tensor.dtype == UInt8out.tensor.shape == [20160]
  • バイト[0:4]はリトルエンディアンでNを表します。0 <= N <= 100は、top_k = 100のためです。N0の場合、「このフレームで閾値を超える検出がない」という意味であり、0回反復して何も出力しません。
  • バイト[4 : 4 + 24 * N]には有効な検出が含まれており、それ以降のすべてのバイトはゼロ/パディングであり、無視する必要があります。

Pythonでボックスを読み取るには、struct.unpack_fromを使用します。

import struct
payload = out.tensor.copy_payload_bytes()
count = struct.unpack_from("<I", payload, 0)[0]
for i in range(count):
x, y, w, h, score, cls = struct.unpack_from("<iiiifi", payload, 4 + 24 * i)
# (x, y, w, h) in source pixels; x2 = x + w, y2 = y + h

C++では、stages::BoxDecodeヘルパー関数は、この処理を済ませたBoxDecodeResultを返します。result.boxes[i]は、(x, y, x+w, y+h)から(x1, y1, x2, y2)がすでに設定され、画像に合わせてクリップされたBoxです。

オーバーライド契約:ランタイムの次元とパッケージ化されたモデルアーカイブのデフォルト値

SimaBoxDecodeは、decode_typedetection_thresholdnms_iou_thresholdtop_koriginal_width、およびoriginal_heightのパッケージ化されたデフォルト値を含む、トレーニング済みのモデルアーカイブから構築されます。パブリックコンストラクタは```cpp SimaBoxDecode(const Model& model, const std::string& decode_type = "", int original_width = 0, int original_height = 0, double detection_threshold = 0.0, double nms_iou_threshold = 0.0, int top_k = 0);


そして、その Python 版である `pyneat.nodes.sima_box_decode(model, ...)` は、フィールドごとに単純な「肯定的な値は優先され、ゼロ/空の値は保持される」というルールを使用します。

> **命名に関する注意:** `detection_threshold` は、`SimaBoxDecode` のコンストラクタで使用される名前です。`ModelOptions.score_threshold` (Python のチュートリアルで使用) は、同じ引数に渡されます。これら 2 つの名前は、同じ基盤となる制御を指します。

| ランタイム引数 | 渡される値 | 動作 |
|---|---|---|
| `decode_type` | `""` (空) | モデルアーカイブ / モデルパス推論を保持 |
| `decode_type` | 空でない文字列 | この実行のためにモデルアーカイブの値を上書き |
| `original_width` / `original_height` | `0` | モデルアーカイブにパッケージ化された次元を保持 |
| `original_width` / `original_height` | 正の整数 | 有効な構成における `original_width` / `original_height` を書き換える |
| `detection_threshold` | `0.0` | モデルアーカイブにパッケージ化された閾値を保持 |
| `detection_threshold` | `> 0.0` | 上書き (YOLOv8 のクリフ警告もトリガー) |
| `nms_iou_threshold` | `0.0` | モデルアーカイブにパッケージ化された NMS IoU を保持 |
| `nms_iou_threshold` | `> 0.0` | 上書き |
| `top_k` | `0` | モデルアーカイブにパッケージ化されたトップ K を保持 |
| `top_k` | `> 0` | 上書き |

このルールは、フィールドごとに厳密に適用されます。

- **Python パス** — チュートリアルではすべてのフィールドが上書きされます。なぜなら、`ModelOptions` が正の値に設定されるからです。
- **C++ パス** — `read_detection_boxes.cpp` は `0.55f, 0.5f, 100` を渡します (したがって、`detection_threshold`、`nms_iou_threshold`、および `top_k` が上書きされます) さらに `bgr.cols, bgr.rows` を正の値で渡します (したがって、`original_width` / `original_height` も上書きされます)。

実用的な影響:

- モデルアーカイブが、ソースフレームとは異なる解像度でパックされている場合、`original_width` と `original_height` を明示的に渡して、座標がソースピクセルに一致するようにします。
- `detection_threshold` と `nms_iou_threshold` を `0.0` のままにしておくことは、モデルアーカイブの検証済みのデフォルトを取得する最も安全な方法です。意図的に再調整する場合にのみ上書きしてください。
- `detection_threshold` を低い値に設定する場合は、注意してください。値が低いほど、しきい値処理を通過する候補ボックスが多くなり、NMS のコストは、生き残ったボックスの数の 2 乗に比例して増加します。したがって、非常に低いしきい値は、後処理の計算量とレイテンシーを大幅に増加させる可能性があります。弱い検出を捉えるために必要な範囲までのみ値を下げ、`top_k` と組み合わせて、最悪の場合の数を制限します。

### デコードタイプとテンソルの契約

`BoxDecodeType` は型付き API (`simaai::neat::BoxDecodeType` / `neat.BoxDecodeType`) であり、デコードステージでは常に明示的に設定する必要があります。 以下のランタイムコントラクトは、`internals/gst_plugins/genericboxdecode_v2/gstneatboxdecode.cpp` (`infer_num_classes`、`infer_yolo_decoupled_classes`、`infer_yolo_packed_classes`、`compute_required_output_size`) から派生します。

主要なテンソルコントラクトルール:
- YOLO ファミリーのデコードタイプ (`yolo`、`yolov5*`、`yolov7*`、`yolov8*`、`yolov9*`、`yolov10*`):
- 分離されたヘッド:クラスヘッドの深さは繰り返し可能で、`> 4` である必要があります。
- パックされたヘッド:各ヘッドの深さは、`depth = 3 * (num_classes + 5)` を満たし、ヘッド間で一貫している必要があります。
- `yolo26`:4チャンネルの生の l/t/r/b バウンディングボックステンソルと、繰り返し可能なクラスヘッドの深さ `> 4` を持つ、分離されたグループ化されたヘッド。
- `detr`:クラスチャネルは、ヘッド全体の最大深度から推測され、`> 4` である必要があります。
- その他の非 YOLO デコードタイプ (`effdet`、`rcnn-stage1`、`centernet`): フォールバッククラス推論では、最大深度を使用し、`> 4` が必要です。
- セグメンテーションデコードトークン (`*-seg`) は、v2 でセグメンテーションのような出力サイズを有効にします(検出ごとにマスクペイロードを追加します)。

| API 列挙型 | バックエンド・トークン | 期待される契約 |
|---|---|---|
| `BoxDecodeType::Yolo` | `yolo` | YOLO 分離またはパックされた深度契約 |
| `BoxDecodeType::YoloV5` | `yolov5` | YOLO 分離またはパックされた深度契約 |
| `BoxDecodeType::YoloV5Seg` | `yolov5-seg` | YOLO 深度契約 + セグメンテーションパス |
| `BoxDecodeType::YoloV7` | `yolov7` | YOLO 分離またはパックされた深度契約 |
| `BoxDecodeType::YoloV7Seg` | `yolov7-seg` | YOLO 深度契約 + セグメンテーションパス |
| `BoxDecodeType::YoloV8` | `yolov8` | YOLO 分離またはパックされた深度契約 |
| `BoxDecodeType::YoloV8Seg` | `yolov8-seg` | YOLO 深度契約 + セグメンテーションパス |
| `BoxDecodeType::YoloV8Pose` | `yolov8-pose` | YOLO 分離またはパックされた深度契約 |
| `BoxDecodeType::YoloV9` | `yolov9` | YOLO 分離またはパックされた深度契約 |
| `BoxDecodeType::YoloV9Seg` | `yolov9-seg` | YOLO 深度契約 + セグメンテーションパス |
| `BoxDecodeType::YoloV10` | `yolov10` | YOLO 分離またはパックされた深度契約 |
| `BoxDecodeType::YoloV10Seg` | `yolov10-seg` | YOLO 深度契約 + セグメンテーションパス |
| `BoxDecodeType::YoloV26` | `yolo26` | YOLO26 グループ化された生の l/t/r/b バウンディングボックスヘッド + クラススコアヘッド |
| `BoxDecodeType::Detr` | `detr` | `num_classes = max(depth)` (必ず `> 4` であること) |
| `BoxDecodeType::EffDet` | `effdet` | フォールバック最大深度推論 (`> 4`) |
| `BoxDecodeType::RcnnStage1` | `rcnn-stage1` | フォールバック最大深度推論 (`> 4`) |
| `BoxDecodeType::Centernet` | `centernet` | フォールバック最大深度推論 (`> 4`) |

早期失敗動作:
- `stages::BoxDecodeOptions` は、デコードタイプを使用して明示的に構築する必要があります。
- `stages::BoxDecode(...)` および `nodes::SimaBoxDecode(...)` は、`BoxDecodeType::Unspecified` の場合に早期に失敗します。

デコードタイプを明示的に設定する:

```cpp
simaai::neat::stages::BoxDecodeOptions opt(simaai::neat::BoxDecodeType::YoloV8);
opt.detection_threshold = 0.25;
opt.nms_iou_threshold = 0.5;
opt.top_k = 100;
opt = neat.ModelOptions()
opt.decode_type = neat.BoxDecodeType.YoloV8

完全なソース

完全なソースプログラムを表示
tutorials/007_read_detection_boxes/read_detection_boxes.cpp
// Decompose model execution into stages: Preproc -> Infer -> BoxDecode.
//
// Usage:
// tutorial_007_read_detection_boxes --model /path/to/yolo_v8s.tar.gz --image /path/to.jpg

#include "neat.h"

#include "pipeline/StageRun.h"

#include <opencv2/imgcodecs.hpp>

#include <iostream>
#include <stdexcept>
#include <string>

namespace {

bool get_arg(int argc, char** argv, const std::string& key, std::string& out) {
for (int i = 1; i + 1 < argc; ++i) {
if (key == argv[i]) {
out = argv[i + 1];
return true;
}
}
return false;
}

} // namespace

int main(int argc, char** argv) {
try {
std::string model_path, image;
if (!get_arg(argc, argv, "--model", model_path) || !get_arg(argc, argv, "--image", image)) {
std::cerr << "Usage: tutorial_007_read_detection_boxes --model <path> --image <path>\n";
return 1;
}

cv::Mat bgr = cv::imread(image, cv::IMREAD_COLOR);
if (bgr.empty())
throw std::runtime_error("failed to load image: " + image);

simaai::neat::Model::Options opt;
opt.preprocess.color_convert.input_format = simaai::neat::PreprocessColorFormat::BGR;
opt.preprocess.input_max_width = bgr.cols;
opt.preprocess.input_max_height = bgr.rows;
opt.preprocess.input_max_depth = bgr.channels();
opt.decode_type = simaai::neat::BoxDecodeType::YoloV8;

simaai::neat::Model model(model_path, opt);

// CORE LOGIC
// Stage-by-stage: each stages::* call runs one piece of the model pipeline.
simaai::neat::TensorList pre = simaai::neat::stages::Preproc(std::vector<cv::Mat>{bgr}, model);
simaai::neat::Sample infer_samples = simaai::neat::stages::Infer(
simaai::neat::Sample{simaai::neat::sample_from_tensors(pre)}, model);
if (infer_samples.empty())
throw std::runtime_error("infer stage returned no samples");
simaai::neat::Sample infer = infer_samples.front();

simaai::neat::stages::BoxDecodeOptions box(simaai::neat::BoxDecodeType::YoloV8);
(void)box.decode_type;
(void)bgr.cols;
(void)bgr.rows;
box.detection_threshold = 0.55;
box.nms_iou_threshold = 0.5;
box.top_k = 100;

// BoxDecode parses the "BBOX" tensor into {x1, y1, x2, y2, score, class_id}
// entries clamped to original_width x original_height source pixels.
simaai::neat::BoxDecodeResultList decoded_results =
simaai::neat::stages::BoxDecodeResults(simaai::neat::Sample{infer}, model, box);
if (decoded_results.empty())
throw std::runtime_error("boxdecode result parser returned no results");
const simaai::neat::BoxDecodeResult& decoded = decoded_results.front();

std::cout << "boxes=" << decoded.boxes.size() << "\n";
std::cout << "[OK] 007_read_detection_boxes\n";
return 0;
} catch (const std::exception& e) {
std::cerr << "[FAIL] " << e.what() << "\n";
return 1;
}
}

ソース