본문으로 건너뛰기

모델 출력에서 감지 상자 읽기

필드
범주모델 및 추론
난이도중급
예상 소요 시간15-20 minutes
레이블postprocessing, boxdecode, detection

감지기는 직접적으로 바운딩 박스를 반환하지 않습니다. 감지기의 원시 출력은 여전히 임계값 처리, 비최대값 억제 및 좌표 매핑이 필요한 특징 맵의 스택입니다. SimaBoxDecode는 이 세 가지를 하나의 최적화된 단계로 수행하여 추론 텐서를 소스 이미지 픽셀의 최종 감지 결과로 변환하는 후처리 단계입니다.

이 장에서는 해당 디코드를 구성합니다. 즉, decode_type을 사용하여 모델 패밀리를 선택하고, 점수 임계값을 사용하여 신뢰도를 조절하고, NMS IoU 임계값을 사용하여 겹치는 부분을 억제하고, top_k을 사용하여 출력을 제한한 다음 모델을 실행하고 반환된 감지 결과의 수를 확인합니다. 이 과정을 마치면 구성된 감지기 파이프라인과 출력에서 읽은 감지 결과 수가 있으며, 또한 (아래의 "실제 적용" 참조) 전체 와이어 형식을 통해 모든 런타임에서 직접 바운딩 박스를 파싱할 수 있습니다.

둘러보기

디코드 구성

이러한 옵션은 입력 계약과 후처리 동작을 모두 설정합니다. decode_type (YoloV8 사용)는 모델 패밀리 디코드 경로를 선택합니다. 신뢰도 임계값은 NMS 전에 약한 후보를 제거합니다. NMS IoU 임계값은 겹치는 바운딩 박스가 얼마나 적극적으로 병합되는지를 제어합니다. top_k은 결정론적 다운스트림 비용을 위해 최종 수를 제한하고, boxdecode_original_width/boxdecode_original_height은 디코드된 좌표를 소스 이미지 픽셀로 다시 매핑합니다. 이러한 각 값에 대한 조정 지침은 아래의 "실제 적용"에서 확인할 수 있습니다.

decode_typeBoxDecodeType::YoloV8 열거형을 사용합니다. 임계값/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::BoxDecodeOptions (detection_threshold = 0.55, nms_iou_threshold = 0.5, top_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();

실행

PythonC++ (사전 빌드) 명령을 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

예상 출력 (경계 상자 수는 프레임에 따라 다르며, 합성 프레임은 0을 반환합니다):

boxes=0
[OK] 007_read_detection_boxes

(Python 빌드는 detections=...를 출력하거나, 런타임에서 BoxDecode를 model.run에 연결하지 않으면 raw_output_heads=...를 출력합니다.) 이 장의 C++ 소스 코드를 사용자 지정 CMakeLists.txt를 사용하여 자신의 프로젝트에 통합하려면 (추가 폴더는 필요하지 않음), 랜딩 페이지의 튜토리얼 실행 방법을 참조하십시오.

실전 활용

SimaBoxDecodeBBOX 태그가 지정된 단일 출력 텐서를 출력합니다. 텐서에는 런타임 파서가 부동 소수점 감지로 해석하는 패킹된 바이트 버퍼가 포함됩니다. 두 단계의 계약(와이어 버퍼와 파싱된 Box 레코드)을 이해하는 것이 Python 또는 C++에서 출력을 읽는 핵심입니다.

BBOX 텐서

디코드 단계는 각 입력 프레임에 대해 다음 속성을 가진 하나의 BBOX 텐서를 생성합니다.

필드
semantic.detection.format"BBOX"
dtypeUInt8
shape랭크-1: [N_bytes], 여기서 N_bytes는 모델 아카이브에 패킹된 버퍼 용량입니다(예: 표준 YOLOv8 패키지의 경우 [20160]).

텐서의 형태는 바이트 수이며, 감지된 객체의 개수가 아닙니다. 패킹된 바이트에는 작은 헤더와 고정 크기 박스 레코드의 연속된 배열이 포함됩니다. N_bytes는 모델 아카이브의 buffers.input[0].size 필드(박스 디코드 단계의 구성 JSON 내)에 의해 결정되며, 디코더가 단일 프레임에서 출력할 수 있는 최대 감지 객체 수를 제한합니다("런타임 차원과 패키지 값의 상호 작용" 아래 참조).

패킹된 와이어 형식

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부터 시작; 클래스 이름 매핑은 모델 아카이브 메타데이터에 있음)

단일 레코드와 일치하는 표준 Python struct 형식은 "<iiiifi"입니다. (리틀 엔디안, 4개의 부호 있는 정수, 하나의 부동 소수점, 하나의 부호 있는 정수).

런타임의 파싱 도우미(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 = 640, original_height = 640, top_k = 100) 및 표준 YOLOv8 패키지(buffers.input[0].size = 20160는 박스 디코딩 구성에 있음)를 사용하면 단일 디코딩된 프레임에서 다음이 생성됩니다.

  • out.kind == SampleKind.Tensor
  • out.payload_tag == "BBOX"
  • out.tensor.dtype == UInt8, out.tensor.shape == [20160]
  • 바이트 [0:4]는 리틀 엔디안으로 N를 제공합니다. 0 <= N <= 100top_k = 100 때문입니다. N0이면 "이 프레임에서 임계값보다 높은 탐지가 없음"을 의미합니다. 따라서 0번 반복하고 아무것도 출력하지 않습니다.
  • 바이트 [4 : 4 + 24 * N]에는 유효한 탐지가 포함됩니다. 그 이후의 모든 바이트는 0/패딩이며 무시해야 합니다.

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입니다. 또한 이미지에 맞게 값이 조정됩니다.

오버라이드 계약: 런타임 차원 대 패키지된 모델 아카이브 기본값

SimaBoxDecodedecode_type, detection_threshold, nms_iou_threshold, top_k, original_widthoriginal_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, ...)`은 각 필드에 대해 간단한 "양수 값은 우선 적용, 0/빈 값은 보존" 규칙을 사용합니다.

> **이름 지정 참고 사항:** `detection_threshold`는 `SimaBoxDecode`의 생성자에서 사용하는 이름입니다. `ModelOptions.score_threshold` (Python 튜토리얼에서 사용)는 동일한 인수로 전달됩니다. 두 이름은 동일한 기본 제어를 나타냅니다.

| 런타임 인자 | 전달 값 | 동작 |
|---|---|---|
| `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 비용은 생존 박스 수의 제곱에 따라 증가하므로 매우 낮은 임계값은 후처리 계산 및 지연 시간을 크게 증가시킬 수 있습니다. 약한 감지를 감지해야 하는 만큼만 낮추고, `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;
}
}

소스