From 7e437bddb401bcf3f99e963c1de7223067bc4e7c Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 12 Aug 2026 15:27:50 +0900 Subject: [PATCH 01/89] =?UTF-8?q?feat(nn):=20group=5Fnorm=20affine=20?= =?UTF-8?q?=EA=B3=BC=202D=20reflect=20=ED=8C=A8=EB=94=A9=20=ED=97=AC?= =?UTF-8?q?=ED=8D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 둘 다 **크래시 없이 값만 틀리던** 부류를 막는다. - `group_norm(m, x, groups, eps)` — `ggml_group_norm` 은 정규화만 하고 per-channel affine(γ·x̂+β)을 안 한다. γ=1·β=0 으로 초기화되므로 **랜덤 가중치로는 안 드러난다**(mmdet dyhead 실측 cos 0.405 → 0.999999). - `pad_reflect_ext(m, x, l0, r0, l1, r1)` — ggml 에는 `pad_reflect_1d` 밖에 없어 2D 거울 패딩을 못 쓴다. 새 커널이 아니라 **view + ggml_concat_n 조합**이다. `depend/llama/ggml` 은 건드리지 않았다. 축 하나당 out[k]=x[lo-k], out[n+lo+k]=x[n-2-k] (경계 자신은 복제하지 않음), W→H 순으로 접으면 모서리는 torch reflection_pad2d 와 같은 이중 반사가 된다. Co-Authored-By: Claude Opus 5 (1M context) --- src/visp/nn.cpp | 52 +++++++++++++++++++++++++++++++++++++++++++++++++ src/visp/nn.h | 10 ++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/visp/nn.cpp b/src/visp/nn.cpp index d614d01..ecd2d91 100644 --- a/src/visp/nn.cpp +++ b/src/visp/nn.cpp @@ -1,3 +1,4 @@ +#include #include "nn.h" #include "util/string.h" @@ -216,6 +217,57 @@ tensor conv_2d_deform( return x; } +namespace { + +// ggml 축 `dim` 에서 인덱스 `i` 한 칸만 잘라 **연속** 텐서로 만든다. +// `ggml_concat` 은 비연속 src 도 받지만, view 를 그대로 넘기면 stride 해석이 축마다 +// 달라져 디버깅이 어렵다 — 한 칸짜리라 복사 비용이 무시할 만하므로 cont 로 고정한다. +tensor pad_reflect_slice(model_ref m, tensor x, int dim, int64_t i) { + int64_t ne[4] = {x->ne[0], x->ne[1], x->ne[2], x->ne[3]}; + ne[dim] = 1; + return ggml_cont(m, ggml_view_4d(m, x, ne[0], ne[1], ne[2], ne[3], + x->nb[1], x->nb[2], x->nb[3], + (size_t)i * x->nb[dim])); +} + +// 한 축만 거울 반사. torch 규약: out[k] = x[lo-k] (kne[dim]; + ASSERT(lo < n && hi < n, "reflect 패딩이 축 길이보다 크다"); + std::vector parts; + for (int k = lo; k >= 1; --k) { + parts.push_back(pad_reflect_slice(m, x, dim, k)); + } + parts.push_back(x); + for (int k = 1; k <= hi; ++k) { + parts.push_back(pad_reflect_slice(m, x, dim, n - 1 - k)); + } + return ggml_concat_n(m, parts.data(), (int)parts.size(), dim); +} + +} // namespace + +tensor pad_reflect_ext(model_ref m, tensor x, int l0, int r0, int l1, int r1) { + x = pad_reflect_axis(m, x, 0, l0, r0); + x = pad_reflect_axis(m, x, 1, l1, r1); + return x; +} + +tensor group_norm(model_ref m, tensor x, int groups, float eps) { + x = ggml_group_norm(m, x, groups, eps); + // 채널축 broadcast 규약은 batch_norm_2d 와 같다 — CWHN 은 ne0 이 채널이라 그대로, + // WHCN 은 ne2 라 [1,1,C,1] 로 편다. + const bool whcn = !(m.flags & model_build_flag::cwhn); + auto ch = [&](tensor t) { return whcn ? ggml_reshape_4d(m, t, 1, 1, t->ne[0], 1) : t; }; + if (tensor weight = m.find("weight")) x = ggml_mul(m, x, ch(weight)); + if (tensor bias = m.find("bias")) x = ggml_add(m, x, ch(bias)); + return named(m, x); +} + tensor batch_norm_2d(model_ref m, tensor x) { // Batch norm is expected to be have been fused into mul+add. See convert.py ASSERT(m.find("running_mean") == nullptr, "Batch norm was not fused"); diff --git a/src/visp/nn.h b/src/visp/nn.h index 63c61a9..ed5e046 100644 --- a/src/visp/nn.h +++ b/src/visp/nn.h @@ -9,6 +9,16 @@ namespace visp { tensor linear(model_ref, tensor x); tensor layer_norm(model_ref, tensor x, float eps = 1e-5f); +// GroupNorm = 정규화 + **per-channel affine(γ·x̂+β)**. `ggml_group_norm` 은 정규화만 한다 — +// affine 을 빼먹으면 shape 이 그대로라 크래시가 없고 값만 틀린다. 그리고 γ=1·β=0 으로 초기화되므로 +// **랜덤 가중치로는 안 드러난다**(학습된 모델에서만 어긋난다). +// affine=False 인 GroupNorm 은 weight/bias 가 없으므로 그때만 건너뛴다. +tensor group_norm(model_ref, tensor x, int groups, float eps = 1e-5f); + +// 거울(reflect) 패딩. ggml 에는 `ggml_pad_reflect_1d` 밖에 없어 2D 를 못 쓴다. +// 인자 순서는 `ggml_pad_ext` 와 맞췄다 — ggml 축 0(W)·1(H) 만 지원한다. +// torch `reflection_pad2d` 와 동일하게 축을 차례로 접으므로 모서리는 이중 반사가 된다. +tensor pad_reflect_ext(model_ref, tensor x, int l0, int r0, int l1, int r1); // space-to-depth quadrant: x[..., sh::2, sw::2] (YOLO Focus). // `ggml_view` 는 ne0(=W) 축에 step 을 못 줘서 view 로는 표현이 안 된다 — step 이 무시돼 From b4a2af3616577e7901295b6bbc01ffc6f965a202 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 12 Aug 2026 15:35:34 +0900 Subject: [PATCH 02/89] =?UTF-8?q?feat(cli):=20g2c=20=EC=83=9D=EC=84=B1=20a?= =?UTF-8?q?rch=20=EB=A5=BC=20=EC=9D=B4=EB=A6=84=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=B0=BE=EC=95=84=20=EC=8B=A4=ED=96=89=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=A0=88=EC=A7=80=EC=8A=A4=ED=8A=B8=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모델 하나를 붙일 때마다 **7곳**(enum·도움말·인자파싱·전방선언·switch·본체·CMake)을 손으로 고쳐야 했다. `src/cli/inference_yolov9t.cpp` 가 그 중 CMake 한 줄을 빠뜨려 **파일 전체가 빌드에서 빠진 채** 남아 있다 — 죽은 코드인 걸 아무도 몰랐다. g2c 생성물은 시그니처가 균일하므로(`_forward` / `_detect_params`) 등록으로 대신할 수 있다. 이제 모델을 추가해도 `cli.cpp` 를 안 고친다. - `arch_registry.{h,cpp}` — 이름 → forward 표. 생성 arch 옆의 `_register.cpp` 전역 객체가 스스로 등록한다. - `draw.{h,cpp}` — 박스 + 5x7 비트맵 폰트 라벨. 기존 CLI 5개가 전부 이미지→이미지라 사각형을 그릴 일이 없어 없던 기능이다. - `postproc` — `detect_yolo_dense()`. 기존 `gen_points`·`distance2bbox`·`nms` 를 재사용한다. - `cli.cpp` — `run_generated()` 하나가 모든 생성 모델을 처리한다. 모르는 명령이면 레지스트리를 뒤진다. **손코딩 arch 5개는 그대로 둔다**(전처리·타일링이 제각각). - `CMakeLists.txt` — `arch/*.cpp` glob(CONFIGURE_DEPENDS). - `tools/install_arch.py`·`tools/example_yolo.sh` — 등록 자동화와 E2E 예제. 조용히 틀리는 걸 막은 자리 셋: gguf 의 `general.architecture` 와 명령 이름 불일치 거부, stride 로 계산한 앵커 수와 그래프 앵커 수 불일치 거부, 박스/점수 출력을 뒤에서부터 탐색 (YOLOv10/26 은 one2many 가 앞이고 추론용 one2one 이 뒤다). Co-Authored-By: Claude Opus 5 (1M context) --- src/cli/cli.cpp | 141 +++++++++++++++++++++++++++++- src/visp/CMakeLists.txt | 17 ++-- src/visp/arch_registry.cpp | 33 +++++++ src/visp/arch_registry.h | 71 +++++++++++++++ src/visp/draw.cpp | 167 +++++++++++++++++++++++++++++++++++ src/visp/draw.h | 28 ++++++ src/visp/postproc.cpp | 78 +++++++++++++++++ src/visp/postproc.h | 19 ++++ tools/example_yolo.sh | 53 ++++++++++++ tools/install_arch.py | 173 +++++++++++++++++++++++++++++++++++++ 10 files changed, 771 insertions(+), 9 deletions(-) create mode 100644 src/visp/arch_registry.cpp create mode 100644 src/visp/arch_registry.h create mode 100644 src/visp/draw.cpp create mode 100644 src/visp/draw.h create mode 100755 tools/example_yolo.sh create mode 100644 tools/install_arch.py diff --git a/src/cli/cli.cpp b/src/cli/cli.cpp index 6715d74..31ffe28 100644 --- a/src/cli/cli.cpp +++ b/src/cli/cli.cpp @@ -1,5 +1,8 @@ #include "util/math.h" #include "util/string.h" +#include "visp/arch_registry.h" +#include "visp/draw.h" +#include "visp/postproc.h" #include "visp/vision.h" #include @@ -13,10 +16,13 @@ namespace visp { using std::filesystem::path; -enum class cli_command { none, sam, birefnet, depth_anything, migan, esrgan }; +// `generated` = g2c 산출 arch. **모델마다 항목을 늘리지 않는다** — 이름은 런타임에 +// `arch_registry` 에서 찾는다. 손코딩 arch 5개만 여기 남는다(전처리·후처리가 제각각). +enum class cli_command { none, sam, birefnet, depth_anything, migan, esrgan, generated }; struct cli_args { cli_command command = cli_command::none; + std::string_view arch; // command == generated 일 때 arch 이름 std::vector inputs; // -i --input char const* output = "output.png"; // -o --output char const* model = nullptr; // -m --model @@ -41,6 +47,7 @@ Usage: vision-cli [options] depthany - Depth-Anything depth estimation migan - MI-GAN inpainting esrgan - ESRGAN/Real-ESRGAN upscaling + - g2c generated model (see "Generated archs" below) Options: -i, --input [ ...] Input image(s) @@ -59,6 +66,16 @@ Usage: vision-cli [options] vision-cli esrgan -m ESRGAN-x4-F16.gguf -i image.jpg -o upscaled.png )"; printf("%s", usage); + + // 등록된 생성 arch 는 **빌드에 무엇이 들어갔느냐**에 달렸다 — 하드코딩할 수 없다. + auto archs = arch_all(); + printf("Generated archs (%zu):\n", archs.size()); + if (archs.empty()) { + printf(" (none - build one with g2c, then tools/install_arch.py)\n"); + } + for (arch_entry const& e : archs) { + printf(" %.*s\n", (int)e.name.size(), e.name.data()); + } } char const* const short_usage = R"( @@ -128,6 +145,10 @@ cli_args cli_parse(int argc, char** argv) { r.command = cli_command::esrgan; } else if (arg1 == "-h" || arg1 == "--help") { print_usage(); + } else if (arch_find(arg1)) { + // g2c 생성 arch. `src/visp/arch/_register.cpp` 가 스스로 등록한 것. + r.command = cli_command::generated; + r.arch = arg1; } else { throw except("Unknown command: '{}'\n{}", arg1, short_usage); } @@ -168,6 +189,7 @@ void run_birefnet(cli_args const&); void run_depth_anything(cli_args const&); void run_migan(cli_args const&); void run_esrgan(cli_args const&); +void run_generated(cli_args const&); } // namespace visp @@ -186,6 +208,7 @@ int main(int argc, char** argv) { case cli_command::depth_anything: run_depth_anything(args); break; case cli_command::migan: run_migan(args); break; case cli_command::esrgan: run_esrgan(args); break; + case cli_command::generated: run_generated(args); break; case cli_command::none: break; } @@ -443,6 +466,122 @@ void run_sam(cli_args const& args) { // // BirefNet +// +// g2c 생성 arch (레지스트리 경유). **모델이 늘어도 이 함수는 안 바뀐다.** + +void run_generated(cli_args const& args) { + arch_entry const* e = arch_find(args.arch); + ASSERT(e != nullptr, "arch not registered"); // 파싱에서 이미 확인했다 + + backend_device backend = backend_init(args); + auto [file, weights] = load_model_weights(args, backend, nullptr, 0, backend.preferred_layout()); + + // gguf 이름과 부를 함수가 어긋나면 **엉뚱한 그래프에 남의 가중치**를 태운다. + // 크래시 없이 값만 틀리므로 여기서 막는다. + if (file.arch() != e->name) { + throw except("Model arch is '{}' but command is '{}'", file.arch(), e->name); + } + + require_inputs(args.inputs, 1, ""); + image_data image = image_load(args.inputs[0]); + + arch_task const& task = e->task; + const int SZ = task.input_size; + // YOLO 전처리: 정사각 리사이즈 + 0..1. **letterbox 가 아니라 단순 리사이즈**다 — + // 종횡비가 바뀌므로 박스를 되돌릴 때 x·y 배율을 따로 쓴다. + const float mean[3] = {0.0f, 0.0f, 0.0f}; + const float stdv[3] = {255.0f, 255.0f, 255.0f}; + const int nch = n_channels(image.format); + std::vector input_cwhn = preprocess(image.data.get(), image.extent[1], image.extent[0], + nch, SZ, mean, stdv, /*to_rgb=*/false); + + compute_graph graph = compute_graph_init(262144); + model_ref m(weights, graph); + tensor input = compute_graph_input(m, GGML_TYPE_F32, {3, SZ, SZ, 1}, "x"); + ggml_build_forward_expand(graph, input); + ggml_build_forward_expand(graph, e->forward(m, input, file)); + compute_graph_allocate(graph, backend); + transfer_to_backend(input, std::span(input_cwhn.data(), input_cwhn.size())); + compute_timed(graph, backend); + + // 등록된 out_0.. 를 전부 읽는다. **개수를 강제하지 않는다** — 계열마다 분기 수가 다르다 + // (YOLO26 은 one2many/one2one 두 벌을 낸다). + std::vector> outs; + std::vector> hw; + for (int i = 0;; ++i) { + tensor o = ggml_graph_get_tensor(graph, ("out_" + std::to_string(i)).c_str()); + if (!o) { + break; + } + std::vector d((size_t)ggml_nelements(o)); + transfer_from_backend(o, std::span(d.data(), d.size())); + outs.push_back(std::move(d)); + hw.push_back({(int)o->ne[2], (int)o->ne[1]}); + } + printf("- outputs: %zu\n", outs.size()); + + if (task.kind != arch_kind::detect_yolo) { + for (size_t i = 0; i < outs.size(); ++i) { + std::string path = std::string(args.output) + "." + std::to_string(i) + ".bin"; + if (FILE* f = fopen(path.c_str(), "wb")) { + fwrite(outs[i].data(), sizeof(float), outs[i].size(), f); + fclose(f); + } + } + printf("-> raw outputs saved to %s..bin\n", args.output); + return; + } + + // 박스/점수 출력 고르기. 지정이 없으면 **뒤에서부터** 찾는다 — YOLO26 은 one2many 가 앞이고 + // 추론에 쓰는 one2one 이 뒤다. 앞 것을 쓰면 조용히 다른 분기를 재게 된다. + int bi = task.box_out, si = task.score_out; + if (bi < 0 || si < 0) { + for (int i = (int)outs.size() - 1; i >= 0; --i) { + if (si < 0 && hw[i].first == task.num_classes) { + si = i; + } else if (si >= 0 && bi < 0 && hw[i].first == 4) { + bi = i; + } + } + } + if (bi < 0 || si < 0) { + throw except("Could not find box/score outputs (num_classes={})", task.num_classes); + } + const int n_anchor = hw[si].second; + printf("- decode: box=out_%d score=out_%d anchors=%d\n", bi, si, n_anchor); + + // 레벨별 격자는 stride 와 입력 크기로 정해진다. 합이 앵커 수와 안 맞으면 stride 가 틀린 것 — + // 그대로 디코드하면 박스가 통째로 엉뚱한 데 찍힌다. + std::vector> feat_hw; + int sum = 0; + for (float s : task.strides) { + const int g = int(float(SZ) / s); + feat_hw.push_back({g, g}); + sum += g * g; + } + if (sum != n_anchor) { + throw except("anchor mismatch: strides give {} but graph has {}", sum, n_anchor); + } + + yolo_dense_params dp; + dp.strides = task.strides; + dp.num_classes = task.num_classes; + dp.score_thr = task.score_thr; + dp.nms_thr = task.nms_thr; + dp.nms_free = task.nms_free; + dp.max_det = task.max_det; + dp.input_w = SZ; + dp.input_h = SZ; + std::vector dets = + detect_yolo_dense(outs[bi].data(), outs[si].data(), feat_hw, dp); + + const float sx = float(image.extent[0]) / float(SZ); + const float sy = float(image.extent[1]) / float(SZ); + draw_detections(image_span(image), dets, task.class_names, sx, sy); + image_save(image, args.output); + printf("-> %zu boxes drawn, saved to %s\n", dets.size(), args.output); +} + void run_birefnet(cli_args const& args) { backend_device backend = backend_init(args); auto [file, weights] = load_model_weights( diff --git a/src/visp/CMakeLists.txt b/src/visp/CMakeLists.txt index 4ebaa67..43c7262 100644 --- a/src/visp/CMakeLists.txt +++ b/src/visp/CMakeLists.txt @@ -1,14 +1,15 @@ add_library(visioncpp) +# arch/*.cpp 는 **자동으로 모은다.** 예전엔 손으로 나열했는데, g2c 생성물을 붙일 때마다 +# 한 줄 추가를 빠뜨리면 그 파일이 통째로 빌드에서 빠진 채 아무도 모른다 +# (`src/cli/inference_yolov9t.cpp` 가 실제로 그렇게 죽은 코드가 됐다). +# ⚠️ CONFIGURE_DEPENDS 가 있어야 파일을 추가했을 때 CMake 가 다시 돈다. +file(GLOB VISP_ARCH_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/arch/*.cpp") + target_sources(visioncpp PRIVATE - arch/birefnet.cpp - arch/depth-anything.cpp - arch/dino.cpp - arch/esrgan.cpp - arch/migan.cpp - arch/mobile-sam.cpp - arch/swin.cpp - arch/yolov9t.cpp + ${VISP_ARCH_SOURCES} + arch_registry.cpp + draw.cpp c-api.cpp image.cpp ml.cpp diff --git a/src/visp/arch_registry.cpp b/src/visp/arch_registry.cpp new file mode 100644 index 0000000..5fd7c78 --- /dev/null +++ b/src/visp/arch_registry.cpp @@ -0,0 +1,33 @@ +#include "visp/arch_registry.h" + +#include + +namespace visp { + +namespace { + +// ⚠️ **함수 지역 static 이어야 한다.** 파일 스코프 전역으로 두면 다른 TU 의 `arch_registrar` +// 전역 생성자가 이 벡터보다 **먼저** 돌 수 있다(정적 초기화 순서 미정의) — 아직 생성되지 +// 않은 벡터에 push_back 하게 된다. 함수 지역 static 은 첫 호출 때 확실히 만들어진다. +std::vector& registry() { + static std::vector r; + return r; +} + +} // namespace + +arch_registrar::arch_registrar(std::string_view name, arch_forward_fn forward, arch_task task) { + registry().push_back(arch_entry{name, forward, std::move(task)}); +} + +arch_entry const* arch_find(std::string_view name) { + auto& r = registry(); + auto it = std::find_if(r.begin(), r.end(), [&](arch_entry const& e) { return e.name == name; }); + return it == r.end() ? nullptr : &*it; +} + +std::span arch_all() { + return std::span(registry()); +} + +} // namespace visp diff --git a/src/visp/arch_registry.h b/src/visp/arch_registry.h new file mode 100644 index 0000000..63110f4 --- /dev/null +++ b/src/visp/arch_registry.h @@ -0,0 +1,71 @@ +// arch_registry.h — g2c 가 생성한 arch 를 **이름으로** 찾아 실행한다. +// +// 왜 있나: 생성물을 CLI 에 붙일 때마다 enum·도움말·인자파싱·전방선언·switch·본체를 +// 손으로 고쳐야 했다. `inference_yolov9t.cpp` 가 그렇게 7곳을 고쳤는데 CMake 한 줄을 +// 빠뜨려 **빌드에서 통째로 빠진 채 죽은 코드**가 됐다(아무도 모르고 있었다). +// 생성물은 시그니처가 균일하므로 등록으로 대신할 수 있다: +// +// tensor _forward(model_ref, tensor, _params const&); +// _params _detect_params(model_file const&); +// +// 손코딩 arch(birefnet·esrgan·migan·sam·depth-anything)는 전처리·타일링·후처리가 +// 제각각이라 **여기 넣지 않는다.** 이 레지스트리는 g2c 생성물 전용이다. +// +// ⚠️ **정적 링크에서는 등록이 통째로 사라진다.** 등록 객체를 아무도 참조하지 않으므로 +// 링커가 그 TU 를 버린다. `libvisioncpp` 가 공유 라이브러리라 지금은 안전하다. +// static 으로 바꾸려면 `--whole-archive` 가 필요하다 → `DECISIONS.md`. +#pragma once + +#include "visp/ml.h" + +#include +#include +#include +#include + +namespace visp { + +// 타입 지움: `_params` 는 arch 마다 **다른 타입**이라 시그니처에 못 싣는다. +// 등록 TU 가 `_detect_params(f)` 를 안에서 부르고 forward 까지 호출한다. +using arch_forward_fn = tensor (*)(model_ref, tensor, model_file const&); + +// 그래프 뒤에 무엇을 해야 하는지. **gguf 에 이 정보가 없다** — g2c 가 안 쓴다. +// g2c 를 고치지 않기로 했으므로 `tools/install_arch.py` 가 모델을 보고 등록 TU 에 박는다. +enum class arch_kind { raw, detect_yolo }; + +struct arch_task { + arch_kind kind = arch_kind::raw; + + // detect_yolo 전용. 그래프가 내는 것은 **격자 단위 ltrb + 클래스 로짓**이고, + // 격자점 곱·시그모이드·top-k 는 호스트가 한다(g2c 가 그 앞에서 끊는다). + int num_classes = 80; + std::vector strides{8, 16, 32}; + bool nms_free = true; // YOLO26/v10: one2one 이라 NMS 없이 top-k + float score_thr = 0.25f; + float nms_thr = 0.7f; // nms_free 면 안 쓴다 + int max_det = 300; + int input_size = 640; + // 그래프 출력 중 몇 번이 박스/점수인지. 생성 모델마다 다르다(one2many 분기가 앞에 올 수 있다). + int box_out = -1; // -1 = 자동(뒤에서 두 번째 4채널 출력) + int score_out = -1; + std::vector class_names; // 비면 "class N" +}; + +struct arch_entry { + std::string_view name; // gguf `general.architecture` 와 대조 + arch_forward_fn forward; + arch_task task; +}; + +// 이름으로 찾는다. 없으면 nullptr. +arch_entry const* arch_find(std::string_view name); + +// 등록된 전부(도움말·오류 메시지용). **등록 순서는 링크 순서라 보장되지 않는다** — 정렬해 쓸 것. +std::span arch_all(); + +// 전역 객체로 만들면 등록된다. 생성 arch 옆의 `_register.cpp` 가 만든다. +struct arch_registrar { + arch_registrar(std::string_view name, arch_forward_fn forward, arch_task task = {}); +}; + +} // namespace visp diff --git a/src/visp/draw.cpp b/src/visp/draw.cpp new file mode 100644 index 0000000..8a1fee5 --- /dev/null +++ b/src/visp/draw.cpp @@ -0,0 +1,167 @@ +#include "visp/draw.h" + +#include +#include +#include + +namespace visp { + +namespace { + +// 5x7 비트맵 폰트. 라벨에 필요한 글자만 담는다(대문자·숫자·소수점·공백·하이픈). +// 폰트 라이브러리를 끌어오지 않으려고 최소한만 둔다 — 의존성 하나가 빌드를 인질로 잡는다. +struct glyph { char c; uint8_t rows[7]; }; + +constexpr glyph FONT[] = { + {' ', {0,0,0,0,0,0,0}}, {'-', {0,0,0,0x1F,0,0,0}}, + {'.', {0,0,0,0,0,0x0C,0x0C}}, {':', {0,0x0C,0x0C,0,0x0C,0x0C,0}}, + {'%', {0x19,0x1A,0x02,0x04,0x08,0x0B,0x13}}, + {'0', {0x0E,0x11,0x13,0x15,0x19,0x11,0x0E}}, {'1', {0x04,0x0C,0x04,0x04,0x04,0x04,0x0E}}, + {'2', {0x0E,0x11,0x01,0x02,0x04,0x08,0x1F}}, {'3', {0x1F,0x02,0x04,0x02,0x01,0x11,0x0E}}, + {'4', {0x02,0x06,0x0A,0x12,0x1F,0x02,0x02}}, {'5', {0x1F,0x10,0x1E,0x01,0x01,0x11,0x0E}}, + {'6', {0x06,0x08,0x10,0x1E,0x11,0x11,0x0E}}, {'7', {0x1F,0x01,0x02,0x04,0x08,0x08,0x08}}, + {'8', {0x0E,0x11,0x11,0x0E,0x11,0x11,0x0E}}, {'9', {0x0E,0x11,0x11,0x0F,0x01,0x02,0x0C}}, + {'A', {0x0E,0x11,0x11,0x1F,0x11,0x11,0x11}}, {'B', {0x1E,0x11,0x11,0x1E,0x11,0x11,0x1E}}, + {'C', {0x0E,0x11,0x10,0x10,0x10,0x11,0x0E}}, {'D', {0x1E,0x11,0x11,0x11,0x11,0x11,0x1E}}, + {'E', {0x1F,0x10,0x10,0x1E,0x10,0x10,0x1F}}, {'F', {0x1F,0x10,0x10,0x1E,0x10,0x10,0x10}}, + {'G', {0x0E,0x11,0x10,0x17,0x11,0x11,0x0F}}, {'H', {0x11,0x11,0x11,0x1F,0x11,0x11,0x11}}, + {'I', {0x0E,0x04,0x04,0x04,0x04,0x04,0x0E}}, {'J', {0x07,0x02,0x02,0x02,0x02,0x12,0x0C}}, + {'K', {0x11,0x12,0x14,0x18,0x14,0x12,0x11}}, {'L', {0x10,0x10,0x10,0x10,0x10,0x10,0x1F}}, + {'M', {0x11,0x1B,0x15,0x15,0x11,0x11,0x11}}, {'N', {0x11,0x19,0x15,0x13,0x11,0x11,0x11}}, + {'O', {0x0E,0x11,0x11,0x11,0x11,0x11,0x0E}}, {'P', {0x1E,0x11,0x11,0x1E,0x10,0x10,0x10}}, + {'Q', {0x0E,0x11,0x11,0x11,0x15,0x12,0x0D}}, {'R', {0x1E,0x11,0x11,0x1E,0x14,0x12,0x11}}, + {'S', {0x0F,0x10,0x10,0x0E,0x01,0x01,0x1E}}, {'T', {0x1F,0x04,0x04,0x04,0x04,0x04,0x04}}, + {'U', {0x11,0x11,0x11,0x11,0x11,0x11,0x0E}}, {'V', {0x11,0x11,0x11,0x11,0x11,0x0A,0x04}}, + {'W', {0x11,0x11,0x11,0x15,0x15,0x1B,0x11}}, {'X', {0x11,0x11,0x0A,0x04,0x0A,0x11,0x11}}, + {'Y', {0x11,0x11,0x0A,0x04,0x04,0x04,0x04}}, {'Z', {0x1F,0x01,0x02,0x04,0x08,0x10,0x1F}}, +}; + +glyph const* find_glyph(char c) { + if (c >= 'a' && c <= 'z') { + c = char(c - 'a' + 'A'); // 소문자는 대문자로 접는다 + } + for (glyph const& g : FONT) { + if (g.c == c) { + return &g; + } + } + return nullptr; +} + +// 클래스마다 다른 색. HSV 를 돌리는 대신 소수 곱으로 흩어 놓는다 — 인접 클래스가 붙어 있을 때 +// 색이 비슷하면 박스를 구분 못 한다. +void class_color(int label, uint8_t rgb[3]) { + const float h = std::fmod(float(label) * 0.6180339887f, 1.0f) * 6.0f; + const int i = int(h); + const float f = h - float(i); + const uint8_t v = 255, p = 40; + const uint8_t q = uint8_t(255.0f * (1.0f - f * 0.84f)); + const uint8_t t = uint8_t(255.0f * (0.16f + f * 0.84f)); + switch (i % 6) { + case 0: rgb[0] = v; rgb[1] = t; rgb[2] = p; break; + case 1: rgb[0] = q; rgb[1] = v; rgb[2] = p; break; + case 2: rgb[0] = p; rgb[1] = v; rgb[2] = t; break; + case 3: rgb[0] = p; rgb[1] = q; rgb[2] = v; break; + case 4: rgb[0] = t; rgb[1] = p; rgb[2] = v; break; + default: rgb[0] = v; rgb[1] = p; rgb[2] = q; break; + } +} + +struct canvas { + uint8_t* data; + int w, h, ch; + + void px(int x, int y, uint8_t const rgb[3], float a = 1.0f) { + if (x < 0 || y < 0 || x >= w || y >= h) { + return; // 잘라낸다 — 박스가 이미지 밖으로 나가는 건 정상이다 + } + uint8_t* q = data + ((size_t)y * w + x) * ch; + for (int c = 0; c < 3 && c < ch; ++c) { + q[c] = uint8_t(float(q[c]) * (1.0f - a) + float(rgb[c]) * a); + } + if (ch == 4) { + q[3] = 255; + } + } + + void fill(int x0, int y0, int x1, int y1, uint8_t const rgb[3], float a) { + for (int y = y0; y < y1; ++y) { + for (int x = x0; x < x1; ++x) { + px(x, y, rgb, a); + } + } + } + + void rect(int x0, int y0, int x1, int y1, uint8_t const rgb[3], int t) { + for (int k = 0; k < t; ++k) { + for (int x = x0 - k; x <= x1 + k; ++x) { + px(x, y0 - k, rgb); px(x, y1 + k, rgb); + } + for (int y = y0 - k; y <= y1 + k; ++y) { + px(x0 - k, y, rgb); px(x1 + k, y, rgb); + } + } + } + + void text(int x, int y, std::string const& s, uint8_t const rgb[3], int scale) { + int cx = x; + for (char c : s) { + glyph const* g = find_glyph(c); + if (g) { + for (int row = 0; row < 7; ++row) { + for (int col = 0; col < 5; ++col) { + if (g->rows[row] & (1 << (4 - col))) { + fill(cx + col * scale, y + row * scale, + cx + (col + 1) * scale, y + (row + 1) * scale, rgb, 1.0f); + } + } + } + } + cx += 6 * scale; + } + } +}; + +} // namespace + +void draw_detections(image_span const& img, std::vector const& dets, + std::vector const& class_names, + float scale_x, float scale_y, draw_style const& style) { + const int ch = n_channels(img.format); + if (is_float(img.format) || ch < 3) { + // f32 캔버스나 회색조에 그리는 경로는 아직 없다. **조용히 건너뛰지 않고** 말한다 — + // 아무 일도 안 일어나면 "박스가 하나도 안 나왔다" 로 오해한다. + fprintf(stderr, "draw_detections: u8 RGB/RGBA 만 지원한다 (format=%d, ch=%d)\n", + (int)img.format, ch); + return; + } + canvas cv{static_cast(img.data), img.extent[0], img.extent[1], ch}; + + for (detection const& d : dets) { + uint8_t rgb[3]; + class_color(d.label, rgb); + const int x0 = int(d.x1 * scale_x), y0 = int(d.y1 * scale_y); + const int x1 = int(d.x2 * scale_x), y1 = int(d.y2 * scale_y); + cv.rect(x0, y0, x1, y1, rgb, style.thickness); + + if (!style.labels) { + continue; + } + std::string name = (d.label >= 0 && d.label < (int)class_names.size()) + ? class_names[d.label] + : ("CLASS " + std::to_string(d.label)); + char pct[8]; + snprintf(pct, sizeof(pct), " %d%%", int(d.score * 100.0f + 0.5f)); + std::string label = name + pct; + + const int tw = int(label.size()) * 6 * style.text_scale; + const int th = 7 * style.text_scale; + // 박스 위에 붙이되, 이미지 위쪽으로 넘치면 박스 **안쪽**으로 내린다. + const int ly = (y0 - th - 2 >= 0) ? (y0 - th - 2) : (y0 + 2); + cv.fill(x0, ly, x0 + tw + 2, ly + th + 2, rgb, 0.75f); + const uint8_t black[3] = {0, 0, 0}; + cv.text(x0 + 1, ly + 1, label, black, style.text_scale); + } +} + +} // namespace visp diff --git a/src/visp/draw.h b/src/visp/draw.h new file mode 100644 index 0000000..9ed50f7 --- /dev/null +++ b/src/visp/draw.h @@ -0,0 +1,28 @@ +// draw.h — 검출 결과를 이미지에 그린다. **순수 CPU** (ggml 무관). +// +// vision.cpp 에 그리기가 없었다 — 기존 CLI 5개(sam·birefnet·depthany·migan·esrgan)가 +// 전부 이미지→이미지(마스크·깊이맵·확대본)라 사각형을 그릴 일이 없었기 때문이다. +#pragma once + +#include "visp/image.h" +#include "visp/postproc.h" + +#include +#include + +namespace visp { + +struct draw_style { + int thickness = 2; + bool labels = true; // 클래스 이름 + 점수를 박스 위에 찍는다 + int text_scale = 2; // 5x7 비트맵 폰트의 배율 +}; + +// 좌표는 **모델 입력 크기 기준**이다(예: 640×640). 원본 이미지에 그리려면 스케일이 필요하다 — +// 호출부가 `scale`(원본/입력)을 준다. letterbox 를 쓰면 오프셋도 여기서 빼야 한다. +void draw_detections(image_span const& img, std::vector const& dets, + std::vector const& class_names, + float scale_x = 1.0f, float scale_y = 1.0f, + draw_style const& style = {}); + +} // namespace visp diff --git a/src/visp/postproc.cpp b/src/visp/postproc.cpp index ef95901..d690953 100755 --- a/src/visp/postproc.cpp +++ b/src/visp/postproc.cpp @@ -542,4 +542,82 @@ std::vector preprocess(uint8_t const* img, int img_h, int img_w, int img_ return out; } + +// ── YOLO dense (v8/v10/v26) ───────────────────────────────────────────────── +std::vector detect_yolo_dense( + float const* box, float const* score, + std::vector> const& feat_hw, yolo_dense_params const& p) { + + // 레벨별 격자점을 이어 붙인다. 순서는 그래프가 concat 한 순서와 **같아야** 한다 + // (레벨 0 = 가장 큰 feature map). 어긋나면 박스가 통째로 엉뚱한 데 찍힌다. + std::vector points; + std::vector pt_stride; + size_t n_total = 0; + for (size_t l = 0; l < feat_hw.size(); ++l) { + const int H = feat_hw[l].first, W = feat_hw[l].second; + const float s = l < p.strides.size() ? p.strides[l] : p.strides.back(); + std::vector pts = gen_points(H, W, s, p.point_offset); + points.insert(points.end(), pts.begin(), pts.end()); + pt_stride.insert(pt_stride.end(), (size_t)H * W, s); + n_total += (size_t)H * W; + } + const int N = (int)n_total; + if (N == 0) { + return {}; + } + + // ltrb 는 **격자 단위**다 — stride 를 곱해야 픽셀이 된다. + // 그래프 덤프는 채널 우선(flat[c*N+a])이고 `distance2bbox` 는 앵커 우선([i*4+k])이라 + // 여기서 전치도 같이 한다. + std::vector dist((size_t)N * 4); + for (int i = 0; i < N; ++i) { + const float s = pt_stride[i]; + for (int k = 0; k < 4; ++k) { + dist[(size_t)i * 4 + k] = box[(size_t)k * N + i] * s; + } + } + std::vector xyxy((size_t)N * 4); + distance2bbox(points.data(), dist.data(), N, xyxy.data(), p.input_w, p.input_h); + + // 클래스 로짓 → 시그모이드. 앵커마다 최고 클래스만 남긴다(YOLO 규약). + std::vector dets; + dets.reserve(256); + for (int i = 0; i < N; ++i) { + int best = 0; + float best_logit = score[(size_t)0 * N + i]; + for (int c = 1; c < p.num_classes; ++c) { + const float v = score[(size_t)c * N + i]; + if (v > best_logit) { + best_logit = v; + best = c; + } + } + // 시그모이드는 **최댓값 하나만** 계산한다 — 단조라 argmax 가 안 바뀐다. + const float sc = 1.0f / (1.0f + std::exp(-best_logit)); + if (sc < p.score_thr) { + continue; + } + dets.push_back(detection{xyxy[(size_t)i * 4 + 0], xyxy[(size_t)i * 4 + 1], + xyxy[(size_t)i * 4 + 2], xyxy[(size_t)i * 4 + 3], sc, best}); + } + + std::sort(dets.begin(), dets.end(), + [](detection const& a, detection const& b) { return a.score > b.score; }); + + // one2one(YOLOv10/26) 은 중복을 모델이 이미 없앴다 — NMS 를 또 걸면 겹친 물체를 지운다. + if (!p.nms_free) { + std::vector keep = nms(dets, p.nms_thr); + std::vector out; + out.reserve(keep.size()); + for (int idx : keep) { + out.push_back(dets[idx]); + } + dets.swap(out); + } + if ((int)dets.size() > p.max_det) { + dets.resize(p.max_det); + } + return dets; +} + } // namespace visp diff --git a/src/visp/postproc.h b/src/visp/postproc.h index 0d264e2..ab25d7e 100755 --- a/src/visp/postproc.h +++ b/src/visp/postproc.h @@ -94,6 +94,25 @@ std::vector detect_yolox( std::vector> const& obj, std::vector> const& feat_hw, yolox_params const& p); +// ── YOLO dense (v8/v10/v26 계열, 격자 ltrb) ───────────────────────────────── +// g2c 생성 그래프는 **디코드 앞에서 끊는다** — 격자 단위 ltrb 와 클래스 **로짓**을 낸다. +// 여기서 stride 곱 · 격자점 더하기 · 시그모이드 · top-k 를 한다(전부 호스트). +struct yolo_dense_params { + std::vector strides{8, 16, 32}; + float point_offset = 0.5f; // ultralytics make_anchors 규약 + int num_classes = 80; + float score_thr = 0.25f; + float nms_thr = 0.7f; + bool nms_free = true; // one2one(YOLOv10/26) 은 NMS 없이 top-k 만 + int max_det = 300; + int input_w = 0, input_h = 0; +}; +// box[4*N] · score[nc*N] 은 **채널 우선**(flat[c*N + a]) — 그래프 덤프 레이아웃 그대로다. +// N = Σ(W_l·H_l). feat_hw 는 레벨별 (H, W). +std::vector detect_yolo_dense( + float const* box, float const* score, + std::vector> const& feat_hw, yolo_dense_params const& p); + // ── DETR (set prediction, NMS 없음) ───────────────────────────────────────── struct detr_params { int num_queries = 100; diff --git a/tools/example_yolo.sh b/tools/example_yolo.sh new file mode 100755 index 0000000..3250571 --- /dev/null +++ b/tools/example_yolo.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# example_yolo.sh — g2c 생성 arch 를 붙여 **끝까지** 돌리는 예제. +# +# ./tools/example_yolo.sh # yolo26m, 기본 테스트 이미지 +# ./tools/example_yolo.sh yolo26s # 다른 크기 +# ./tools/example_yolo.sh yolo26m my.jpg +# +# 생성물을 저장소에 커밋하지 않는 이유: `.pt` 는 ultralytics 가 받아주고 `.cpp`/`.gguf` 는 +# g2c 가 만든다 — 커밋해봐야 g2c 가 바뀌면 조용히 낡는다. 대신 **이 스크립트로 재현**한다. +set -euo pipefail + +MODEL="${1:-yolo26m}" +IMAGE="${2:-}" +SIZE="${SIZE:-640}" + +VCPP="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +G2C="${G2C_ROOT:-$(cd "$VCPP/.." && pwd)}" +BUILD="${VISP_BUILD:-$VCPP/build}" +WORK="${WORK:-/tmp/visp-example-$MODEL}" + +# 클래스명 첫 글자를 대문자로: yolo26m → Yolo26m (g2c --name 규약) +CLS="$(printf '%s' "${MODEL:0:1}" | tr '[:lower:]' '[:upper:]')${MODEL:1}" + +[ -z "$IMAGE" ] && IMAGE="$VCPP/tests/input/cat-and-hat.jpg" + +if [ ! -f "$G2C/shared/compile/pipeline.py" ]; then + echo "g2c 를 못 찾았다: $G2C" >&2 + echo "G2C_ROOT= 로 지정할 것." >&2 + exit 1 +fi + +echo "== 1/4 g2c 컴파일 ($MODEL → $WORK) ==" +# ⚠️ trace 는 thread 폭주로 hang 할 수 있다 — 한 개로 묶는다. +OMP_NUM_THREADS=1 PYTHONPATH="$G2C" \ + uv run --project "$G2C" python -m shared.compile.pipeline \ + --model "ultralytics.YOLO('$MODEL.pt')" --name "$CLS" \ + --output "$WORK" --input-shape "1,3,$SIZE,$SIZE" + +# 성공 판정은 종료코드가 아니라 **파일 유무**로 한다 — g2c 는 실패해도 exit 0 + "완료!" 를 낸다. +[ -f "$WORK/$CLS.gguf" ] || { echo "gguf 가 안 나왔다 — 위 로그를 볼 것" >&2; exit 2; } + +echo "== 2/4 arch 등록 ==" +python3 "$VCPP/tools/install_arch.py" "$WORK" --name "$CLS" --detect-yolo --size "$SIZE" + +echo "== 3/4 빌드 ==" +cmake --build "$BUILD" -j"$(nproc)" > /dev/null + +echo "== 4/4 실행 ==" +OUT="$WORK/detected.jpg" +"$BUILD/bin/vision-cli" "$MODEL" -m "$WORK/$CLS.gguf" -i "$IMAGE" -o "$OUT" + +echo +echo "결과: $OUT" diff --git a/tools/install_arch.py b/tools/install_arch.py new file mode 100644 index 0000000..3fb280d --- /dev/null +++ b/tools/install_arch.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""g2c 산출물을 vision.cpp 라이브러리에 붙인다 (arch 등록). + + python tools/install_arch.py [--name Yolo26m] [--detect-yolo] + +하는 일: + 1. `.cpp` / `.h` → `src/visp/arch/` 복사 + 2. `_register.cpp` 생성 — 전역 객체가 스스로 레지스트리에 들어간다 + 3. 끝. **CMake 는 안 건드린다** — `arch/*.cpp` 를 glob 으로 모은다. + +왜 g2c 가 아니라 여기서 하나: + g2c 는 main 의 source of truth 라 이 경로의 사정을 떠안기지 않는다. 그리고 검출 + 파라미터(클래스 수·stride·NMS-free)는 **gguf 에 없다** — 여기서 채워 넣는다. + +⚠️ 등록은 **공유 라이브러리 전제**다. static 으로 링크하면 아무도 참조하지 않는 전역 + 객체를 링커가 버려 등록이 통째로 사라진다(`--whole-archive` 필요) → `DECISIONS.md`. +""" +import argparse +import os +import re +import shutil +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +VCPP = os.path.dirname(HERE) +ARCH_DIR = os.path.join(VCPP, "src", "visp", "arch") + +COCO80 = [ + "person", "bicycle", "car", "motorcycle", "airplane", "bus", "train", "truck", "boat", + "traffic light", "fire hydrant", "stop sign", "parking meter", "bench", "bird", "cat", + "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe", "backpack", + "umbrella", "handbag", "tie", "suitcase", "frisbee", "skis", "snowboard", "sports ball", + "kite", "baseball bat", "baseball glove", "skateboard", "surfboard", "tennis racket", + "bottle", "wine glass", "cup", "fork", "knife", "spoon", "bowl", "banana", "apple", + "sandwich", "orange", "broccoli", "carrot", "hot dog", "pizza", "donut", "cake", "chair", + "couch", "potted plant", "bed", "dining table", "toilet", "tv", "laptop", "mouse", + "remote", "keyboard", "cell phone", "microwave", "oven", "toaster", "sink", + "refrigerator", "book", "clock", "vase", "scissors", "teddy bear", "hair drier", + "toothbrush", +] + +REGISTER_TU = '''// GENERATED BY tools/install_arch.py — 손으로 고치지 마라(재설치하면 덮어쓴다). +// +// 이 파일은 **아무도 부르지 않는다.** 전역 객체 생성자가 프로그램 시작 시 스스로 +// `arch_registry` 에 등록한다. 그래서 CLI 에 case 를 추가할 필요가 없다. +#include "visp/arch/{header}" +#include "visp/arch_registry.h" + +namespace {{ + +visp::tensor forward(visp::model_ref m, visp::tensor x, visp::model_file const& f) {{ + // `{cls}_params` 는 arch 마다 타입이 달라 레지스트리 시그니처에 못 싣는다 — + // 여기서 만들어 소화한다. + return visp::{cls}_forward(m, x, visp::{cls}_detect_params(f)); +}} + +visp::arch_task make_task() {{ + visp::arch_task t; +{task_body} + return t; +}} + +const visp::arch_registrar reg{{"{arch}", &forward, make_task()}}; + +}} // namespace +''' + + +def find_outputs(out_dir, name): + """생성 폴더에서 (클래스명, .cpp, .h) 를 찾는다.""" + cpps = [f for f in os.listdir(out_dir) if f.endswith(".cpp")] + if name: + cand = [f for f in cpps if os.path.splitext(f)[0] == name] + else: + cand = cpps + if len(cand) != 1: + sys.exit(f"오류: .cpp 를 하나로 못 좁혔다 ({cand}). --name 으로 지정할 것.") + cls = os.path.splitext(cand[0])[0] + h = os.path.join(out_dir, cls + ".h") + if not os.path.exists(h): + sys.exit(f"오류: 헤더가 없다: {h}") + return cls, os.path.join(out_dir, cand[0]), h + + +def gguf_arch(out_dir, cls): + """gguf 의 `general.architecture`. 이게 CLI 명령 이름이 된다. + + gguf 를 파싱하지 않고 **생성 .cpp 가 검증하는 문자열**에서 읽는다 — 어차피 둘이 + 같아야 하고(안 맞으면 로드가 거부된다), 의존성도 안 는다. + """ + src = open(os.path.join(out_dir, cls + ".cpp"), encoding="utf-8").read() + m = re.search(r'arch\s*!=\s*"([^"]+)"', src) + if not m: + sys.exit("오류: 생성 .cpp 에서 general.architecture 를 못 찾았다") + return m.group(1) + + +def detect_shapes(out_dir, cls): + """생성 .cpp 의 출력 등록에서 (클래스 수, 앵커 수) 를 추정한다. + + `compute_graph_output(..., "out_i")` 앞의 텐서 shape 를 직접 읽을 수는 없으므로, + **weights_manifest 의 마지막 conv 출력 채널**로 클래스 수를 잡는다. 실패하면 80. + """ + nc = 80 + wt = os.path.join(out_dir, cls + ".weights.txt") + if os.path.exists(wt): + for line in open(wt, encoding="utf-8"): + m = re.search(r"cv3.*?\[(\d+),", line) + if m: + nc = int(m.group(1)) + return nc + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("out_dir", help="g2c --output 폴더") + ap.add_argument("--name", help="클래스명(예: Yolo26m). 생략하면 .cpp 가 하나일 때 자동") + ap.add_argument("--detect-yolo", action="store_true", + help="YOLO dense 검출기로 등록(디코드+박스 그리기)") + ap.add_argument("--classes", type=int, default=0, help="클래스 수(0=자동)") + ap.add_argument("--strides", default="8,16,32") + ap.add_argument("--size", type=int, default=640) + ap.add_argument("--score-thr", type=float, default=0.25) + ap.add_argument("--nms", action="store_true", + help="NMS 를 건다(기본은 NMS-free — YOLOv10/26 은 one2one 이라 필요 없다)") + a = ap.parse_args() + + cls, cpp, hdr = find_outputs(a.out_dir, a.name) + arch = gguf_arch(a.out_dir, cls) + os.makedirs(ARCH_DIR, exist_ok=True) + + # 생성 .cpp 는 `#include ".h"` 가 아니라 상대 include 를 쓸 수 있다 → 헤더 이름을 맞춘다. + header = cls + ".h" + shutil.copy(cpp, os.path.join(ARCH_DIR, cls + ".cpp")) + shutil.copy(hdr, os.path.join(ARCH_DIR, header)) + + if a.detect_yolo: + nc = a.classes or detect_shapes(a.out_dir, cls) + strides = [s.strip() for s in a.strides.split(",") if s.strip()] + names = COCO80 if nc == 80 else [] + body = [ + " t.kind = visp::arch_kind::detect_yolo;", + f" t.num_classes = {nc};", + " t.strides = {" + ", ".join(f"{s}.0f" for s in strides) + "};", + f" t.nms_free = {'false' if a.nms else 'true'};", + f" t.score_thr = {a.score_thr}f;", + f" t.input_size = {a.size};", + ] + if names: + body.append(" t.class_names = {") + for i in range(0, len(names), 6): + body.append(" " + ", ".join(f'"{n}"' for n in names[i:i + 6]) + ",") + body.append(" };") + else: + body = [" t.kind = visp::arch_kind::raw;"] + + reg_path = os.path.join(ARCH_DIR, arch + "_register.cpp") + with open(reg_path, "w", encoding="utf-8") as f: + f.write(REGISTER_TU.format(header=header, cls=cls, arch=arch, + task_body="\n".join(body))) + + print(f"설치 완료: {cls} → arch '{arch}'") + print(f" {ARCH_DIR}/{cls}.cpp") + print(f" {ARCH_DIR}/{header}") + print(f" {reg_path}") + print() + print("다음:") + print(f" cmake --build {os.path.join(VCPP, 'build')} -j4") + print(f" {os.path.join(VCPP, 'build/bin/vision-cli')} {arch} -m -i -o out.jpg") + + +if __name__ == "__main__": + main() From 1f5a034017c081083d67e07cf1ad050fff33924a Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 12 Aug 2026 17:06:17 +0900 Subject: [PATCH 03/89] =?UTF-8?q?fix(build):=20libvisioncpp=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A5=BC=20`--build`=20=EC=9D=B8=EC=9E=90=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=9B=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지금까지 `VISP_BUILD` env 로만 받았다. env 는 명령줄에 남지 않아서 **어떤 라이브러리로 빌드했는지 로그에서 읽을 수 없다** — 낡은 `build/` 를 참조해 `undefined reference to visp::*` 가 났을 때 그것 때문에 원인을 찾는 데 오래 걸렸다. 인자면 명령 한 줄에 남는다. # 전 VISP_BUILD=/path/to/dir bash tools/build/build_mmdet_cpp.sh output/MMDetBackbone # 후 bash tools/build/build_mmdet_cpp.sh --build /path/to/dir output/MMDetBackbone `VISP_BUILD` 도 계속 읽는다 — 인자가 우선이다. 인자를 안 주면 동작은 그대로다. `--build` 는 위치 인자와 섞이지 않게 먼저 걷어내므로 어디에 두어도 된다. Co-Authored-By: Claude Opus 5 (1M context) --- tools/build/build_mmdet_cpp.sh | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tools/build/build_mmdet_cpp.sh b/tools/build/build_mmdet_cpp.sh index e5958e9..433c41d 100755 --- a/tools/build/build_mmdet_cpp.sh +++ b/tools/build/build_mmdet_cpp.sh @@ -8,22 +8,38 @@ # 사용: build_mmdet_cpp.sh [arch_name] # gen_dir = g2c --output 디렉토리 (예: output/MMDetBackbone) — .cpp/.h/.gguf 있음 # arch_name= 클래스명(생략 시 gen_dir 의 *.cpp 에서 자동) -# env: VISP_BUILD = libvisioncpp 빌드 디렉토리 (기본: /build) +# options: +# --build libvisioncpp 빌드 디렉토리 (기본: /build) +# ⚠️ **어떤 라이브러리로 빌드했는지는 명령줄에 남아야 한다.** env 로만 받으면 +# 낡은 build/ 를 참조해 링크가 깨져도 로그만 봐서는 알 수 없다. +# `VISP_BUILD` 도 계속 읽지만 인자가 우선이다. # set -e SELF="$(cd "$(dirname "$0")" && pwd)" # vision.cpp/tools/build (빌드 스크립트) V="$(cd "$SELF/../.." && pwd)" # vision.cpp (libvisioncpp 소스) DETECT="$V/tools/detect" # 공용 head/decode 부품 (head.cpp/head.h) RUN="$V/tools/verify" # E2E 검증 러너 -GEN="${1:?usage: build_mmdet_cpp.sh [arch_name]}" +# `--build ` 는 어디에 두든 받는다 — 위치 인자와 섞이지 않게 먼저 걷어낸다. +ARG_BUILD="" +POS=() +while [ $# -gt 0 ]; do + case "$1" in + --build) ARG_BUILD="${2:?--build needs a directory}"; shift 2 ;; + --build=*) ARG_BUILD="${1#--build=}"; shift ;; + *) POS+=("$1"); shift ;; + esac +done +set -- "${POS[@]}" + +GEN="${1:?usage: build_mmdet_cpp.sh [--build ] [arch_name]}" GEN="$(cd "$GEN" && pwd)" ARCH="${2:-}" if [ -z "$ARCH" ]; then ARCH="$(basename "$(ls "$GEN"/*.cpp | grep -v run_ | head -1)" .cpp)" fi -BUILD="${VISP_BUILD:-$V/build}" +BUILD="${ARG_BUILD:-${VISP_BUILD:-$V/build}}" LIB="$BUILD/lib" -[ -f "$LIB/libvisioncpp.so" ] || { echo "libvisioncpp.so 없음: $LIB (VISP_BUILD 설정?)"; exit 1; } +[ -f "$LIB/libvisioncpp.so" ] || { echo "libvisioncpp.so 없음: $LIB (--build 로 지정)"; exit 1; } # VISP_FMT_LIB: fmt 라이브러리 사용 플래그. libvisioncpp 가 fmt 로 빌드됐으면 -DVISP_FMT_LIB + # fmt include 필요. 아니면(내장 fallback) 정의하지 않는다. FMT_INC 있으면 자동으로 켠다. From 791aa685c670907e2949e921557dddff3debc6b9 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 13 Aug 2026 12:53:47 +0900 Subject: [PATCH 04/89] =?UTF-8?q?fix(gguf):=20GGML=5FMAX=5FNAME=20override?= =?UTF-8?q?=20=EB=A5=BC=20=EA=B1=B7=EC=96=B4=EB=82=B4=EA=B3=A0=20ggml=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B0=92(64)=EC=9D=84=20=EC=93=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `state_dict` 키가 64자를 넘는 모델(mmdet HourglassNet 등)을 위해 128 로 올려 두었다. 그건 미루기였다: - 더 깊은 모델이면 또 넘는다. 128 도 임의값이다. - **링크되는 모든 소비자가 같은 값을 써야 한다.** `char name[GGML_MAX_NAME]` 이 `ggml_tensor` 의 구조체 레이아웃이라, CMake 를 안 거치는 소비자(손으로 짠 g++)가 `-D` 를 못 받으면 구조체 크기가 갈린다. 실제로 검증 러너가 그 상태였고, 그것 때문에 무관한 크래시를 ABI 탓으로 오진했다. 긴 이름은 컴파일러가 줄인다(g2c `shared/compile/tensor_names.py`) — 모듈 깊이와 무관하게 성립한다. 모듈 prefix 를 접고 접미사(`.weight`·`.running_mean`)는 보존하므로 생성 `.cpp` 의 `m["…"]` 조회와 자동으로 맞는다. backbone.hourglass_modules.0.low2.low2.low1.0.downsample.0.weight (65) → backbone.hm.0.low2.low2.low1.0.downsample.0.weight (49) 실측(mmdet 100계열, 텐서명 4,051개): 축약 216개(5%), 충돌 0, 최장 63자. 되돌린 뒤 전수 재측정 **78/100 · 회귀 0**. ⚠️ g2c 의 이름 축약(Sudo42b/GTX_Compiler)과 **같이 머지돼야 한다.** 이쪽만 가면 64자를 넘는 이름이 다시 로드 거부된다. Co-Authored-By: Claude Opus 5 (1M context) --- CMakeLists.txt | 12 +++++------- include/visp/ml.h | 20 +++++++------------- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 88ce3aa..fbb707f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -136,13 +136,11 @@ if(VISP_CI) endforeach() endif() endif() -# ggml 기본 텐서명 한도 64자는 state_dict 키를 그대로 gguf 텐서명으로 쓰는 모델에 부족하다 -# (mmdet HourglassNet: 68자). 넘으면 로드가 `tensor name is too long: 65 >= 64` 로 거부한다. -# ★ **ggml 자체 컴파일에도 닿아야 한다** — `ggml_tensor.name[GGML_MAX_NAME]` 이 구조체 -# 레이아웃이라 라이브러리와 헤더의 값이 다르면 ABI 가 갈린다. 그래서 여기(서브디렉터리 -# 추가 **전**)와 `visp/ml.h` 양쪽에 둔다. ml.h 쪽은 CMake 를 안 쓰는 소비자(손수 짠 g++)용. -# upstream ggml.h 는 `#ifndef` 가드라 이 정의가 우선한다 — llama 포크 수정 불필요. -add_compile_definitions(GGML_MAX_NAME=128) +# 텐서명 한도는 ggml 기본값(64) 을 그대로 쓴다. 그보다 긴 `state_dict` 키는 컴파일러가 +# 줄인다(g2c `shared/compile/tensor_names.py`). +# 예전엔 여기서 128 로 올렸는데 그건 미루기였다 — 더 깊은 모델이면 또 넘고, **링크되는 +# 모든 소비자가 같은 값을 써야** 해서 손으로 짠 g++ 한 줄만 빠져도 `ggml_tensor` 크기가 +# 갈린다(`char name[GGML_MAX_NAME]` 이 구조체 레이아웃이다). add_subdirectory(depend/llama/ggml) set(BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS_SAVED}) diff --git a/include/visp/ml.h b/include/visp/ml.h index db7c9c8..6ff8d85 100644 --- a/include/visp/ml.h +++ b/include/visp/ml.h @@ -1,19 +1,13 @@ #pragma once -// ggml 기본 텐서명 한도는 64자인데, state_dict 키를 그대로 gguf 텐서명으로 쓰는 모델은 -// 그걸 넘는다(mmdet HourglassNet: 68자 `backbone.hourglass_modules.0.low2.…downsample.0`). -// 넘으면 gguf 로드가 `tensor name is too long: 65 >= 64` 로 거부한다. +// 텐서명 한도는 ggml 기본값(64) 을 그대로 쓴다. `state_dict` 키가 그보다 길면 컴파일러가 +// 줄인다(g2c `shared/compile/tensor_names.py` — 모듈 prefix 를 접고 접미사는 보존한다). // -// ⚠️ **여기와 CMakeLists 양쪽에 둔다. 한쪽만으로는 부족하다.** -// · 헤더만: 라이브러리의 `ggml.c` 는 64 로 컴파일돼 `ggml_tensor.name[]` 레이아웃이 -// 갈린다 → `Failed to load GGUF model`. -// · CMake 만: 손수 짠 g++ 소비자가 `-D` 를 못 받는다. `tensor_name = -// fixed_string` 이 공개 API 시그니처라 맹글링이 갈려 -// `undefined reference to compute_graph_output(…, fixed_string<128>)` 로 깨진다. -// (upstream ggml.h 는 `#ifndef` 가드가 있어 이 정의가 우선한다 — ggml 포크 수정 불필요.) -#ifndef GGML_MAX_NAME -# define GGML_MAX_NAME 128 -#endif +// ⚠️ **여기서 다시 정의하지 마라.** `tensor_name = fixed_string` 이 공개 API +// 시그니처라, 이 헤더와 라이브러리가 다른 값을 보면 맹글링이 갈려 +// `undefined reference to compute_graph_output(…, fixed_string)` 로 깨지거나 +// `ggml_tensor` 크기가 어긋나 런타임에 죽는다. 값을 하나로 두는 가장 확실한 방법은 +// **아무도 안 바꾸는 것**이다. #include "visp/image.h" #include "visp/util.h" From 3a61385b49191a5bcdaeb8cd05d3a78efa04295d Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 13 Aug 2026 13:27:21 +0900 Subject: [PATCH 05/89] =?UTF-8?q?docs(tools):=20draw=5Fboxes.py=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=E2=80=94=20=EA=B0=80=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EA=B0=80=20=EC=B0=B8=EC=A1=B0=ED=95=98=EB=8A=94=EB=8D=B0=20?= =?UTF-8?q?=EB=B9=A0=EC=A0=B8=20=EC=9E=88=EC=97=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mmdet 가이드가 "tools/verify/draw_boxes.py draws such a file afterwards" 라고 안내하는데 저장소에 없었다. 검증 러너가 낸 raw float32 를 이미지에 그려 주는 스크립트다. Co-Authored-By: Claude Opus 5 (1M context) --- tools/verify/draw_boxes.py | 93 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tools/verify/draw_boxes.py diff --git a/tools/verify/draw_boxes.py b/tools/verify/draw_boxes.py new file mode 100644 index 0000000..0bf53a6 --- /dev/null +++ b/tools/verify/draw_boxes.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Draw detections onto the image they came from. + +The runners write detections as raw float32 -- six numbers per box -- because that is what +comparing against a reference implementation needs. This turns such a file into something to +look at. + + python tools/verify/draw_boxes.py image.jpg boxes.bin -o annotated.png + +Boxes are in the coordinate space of the resized square input the detector ran on, so the +script scales them back to the original image. Pass --size if the detector ran at something +other than 512. +""" +from __future__ import annotations + +import argparse +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +# COCO 80. Detectors trained on something else take --labels. +COCO = ( + "person bicycle car motorcycle airplane bus train truck boat traffic_light fire_hydrant " + "stop_sign parking_meter bench bird cat dog horse sheep cow elephant bear zebra giraffe " + "backpack umbrella handbag tie suitcase frisbee skis snowboard sports_ball kite " + "baseball_bat baseball_glove skateboard surfboard tennis_racket bottle wine_glass cup fork " + "knife spoon bowl banana apple sandwich orange broccoli carrot hot_dog pizza donut cake " + "chair couch potted_plant bed dining_table toilet tv laptop mouse remote keyboard " + "cell_phone microwave oven toaster sink refrigerator book clock vase scissors teddy_bear " + "hair_drier toothbrush" +).split() + +# Distinct enough to tell classes apart without a legend. +PALETTE = [ + (230, 60, 60), (60, 160, 230), (70, 190, 110), (240, 160, 40), (170, 100, 220), + (40, 200, 200), (230, 110, 170), (150, 160, 60), (110, 130, 240), (200, 90, 60), +] + + +def load(path: Path) -> np.ndarray: + d = np.fromfile(path, dtype="float32") + if d.size % 6: + raise SystemExit(f"{path}: {d.size} floats is not a multiple of 6") + return d.reshape(-1, 6) + + +def main(argv=None) -> None: + ap = argparse.ArgumentParser(description="Draw raw float32 detections onto an image.") + ap.add_argument("image", type=Path, help="the image the detector was given") + ap.add_argument("boxes", type=Path, help="detections written by the runner (.bin)") + ap.add_argument("-o", "--output", type=Path, default=Path("annotated.png")) + ap.add_argument("-t", "--threshold", type=float, default=0.3, + help="skip detections below this score. Default 0.3") + ap.add_argument("--size", type=int, default=512, + help="square input resolution the detector ran at. Default 512") + ap.add_argument("--labels", type=Path, default=None, + help="class names, one per line. Default: COCO 80") + a = ap.parse_args(argv) + + names = (a.labels.read_text(encoding="utf-8").split() if a.labels else COCO) + img = Image.open(a.image).convert("RGB") + dets = load(a.boxes) + keep = dets[dets[:, 4] >= a.threshold] + + # The detector saw a square of --size; put the boxes back on the original. + sx, sy = img.width / a.size, img.height / a.size + + draw = ImageDraw.Draw(img) + try: + font = ImageFont.load_default(size=max(12, img.height // 45)) + except TypeError: # Pillow < 9.2 has no size argument + font = ImageFont.load_default() + + for x1, y1, x2, y2, score, label in keep: + label = int(label) + colour = PALETTE[label % len(PALETTE)] + box = (x1 * sx, y1 * sy, x2 * sx, y2 * sy) + draw.rectangle(box, outline=colour, width=max(2, img.height // 300)) + + name = names[label] if label < len(names) else str(label) + text = f"{name} {score:.2f}" + tw, th = draw.textbbox((0, 0), text, font=font)[2:] + ty = max(0.0, box[1] - th - 2) + draw.rectangle((box[0], ty, box[0] + tw + 6, ty + th + 4), fill=colour) + draw.text((box[0] + 3, ty + 2), text, fill=(255, 255, 255), font=font) + + img.save(a.output) + print(f" → {a.output} ({len(keep)} of {len(dets)} detections at score >= {a.threshold})") + + +if __name__ == "__main__": + main() From e4ca033e32a1b7fbcc4042ce4a14620f65f9e72c Mon Sep 17 00:00:00 2001 From: eunchae Date: Thu, 13 Aug 2026 13:44:00 +0900 Subject: [PATCH 06/89] =?UTF-8?q?fix(cli):=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=ED=81=AC=EA=B8=B0=EC=99=80=20=EC=A0=84=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94=EB=A5=BC=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A1=9C=20=EC=98=AE=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 값이 코드에 박혀 있어서, 그 가정을 벗어나는 모델이 **크래시 없이 값만 틀렸다.** `input_size` 는 640 고정이었다. 224 로 trace 한 분류기를 붙이면 그나마 시끄럽게 죽는다 (`GGML_ASSERT(ggml_nelements(a) == ne0*ne1)`). `install_arch.py` 가 생성된 `.py` 의 trace 호출에서 실제 크기를 읽어 등록 TU 에 박는다. `--size` 로 덮을 수 있다. 정규화는 더 나쁘다. 0~1 로 고정돼 있었고 YOLO 규약이라 YOLO 에서는 안 드러났다. torchvision 분류기는 ImageNet 통계를 쓰는데, 기본값으로 돌려도 **아무 경고 없이 다른 숫자가 나온다** — ResNet-18 실측 상대 L2 6.3e-02 이고 top-5 는 우연히 순서까지 맞았다. 그래서 "돌아가니까 맞다" 로 넘어가기 딱 좋다. `--mean/--std`(픽셀 0~255 단위)로 등록한다. `float mean[3]` 은 등록 TU 가 `t.mean = {…}` 로 대입하므로 `std::array` 여야 한다. --- src/cli/cli.cpp | 9 ++++---- src/visp/arch_registry.h | 7 ++++++ tools/install_arch.py | 48 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/cli/cli.cpp b/src/cli/cli.cpp index 31ffe28..d9fb78e 100644 --- a/src/cli/cli.cpp +++ b/src/cli/cli.cpp @@ -487,13 +487,12 @@ void run_generated(cli_args const& args) { arch_task const& task = e->task; const int SZ = task.input_size; - // YOLO 전처리: 정사각 리사이즈 + 0..1. **letterbox 가 아니라 단순 리사이즈**다 — - // 종횡비가 바뀌므로 박스를 되돌릴 때 x·y 배율을 따로 쓴다. - const float mean[3] = {0.0f, 0.0f, 0.0f}; - const float stdv[3] = {255.0f, 255.0f, 255.0f}; + // 정사각 리사이즈 + **등록된** mean/std. `install_arch.py --mean/--std` 가 박는다. + // **letterbox 가 아니라 단순 리사이즈**다 — 종횡비가 바뀌므로 박스를 되돌릴 때 + // x·y 배율을 따로 쓴다. const int nch = n_channels(image.format); std::vector input_cwhn = preprocess(image.data.get(), image.extent[1], image.extent[0], - nch, SZ, mean, stdv, /*to_rgb=*/false); + nch, SZ, task.mean.data(), task.stdv.data(), /*to_rgb=*/false); compute_graph graph = compute_graph_init(262144); model_ref m(weights, graph); diff --git a/src/visp/arch_registry.h b/src/visp/arch_registry.h index 63110f4..29e7fd8 100644 --- a/src/visp/arch_registry.h +++ b/src/visp/arch_registry.h @@ -18,6 +18,7 @@ #include "visp/ml.h" +#include #include #include #include @@ -45,6 +46,12 @@ struct arch_task { float nms_thr = 0.7f; // nms_free 면 안 쓴다 int max_det = 300; int input_size = 640; + + // 전처리 정규화 `(v - mean) / std`. 기본값은 0~1 (YOLO 규약). + // ⚠️ **모델마다 다르다.** torchvision 분류기는 ImageNet 통계를 쓰는데, 0~1 로 돌리면 + // 크래시 없이 값만 틀린다(ResNet-18 실측 상대 L1 5.1e-02, argmax 는 우연히 일치). + std::array mean{0.0f, 0.0f, 0.0f}; + std::array stdv{255.0f, 255.0f, 255.0f}; // 그래프 출력 중 몇 번이 박스/점수인지. 생성 모델마다 다르다(one2many 분기가 앞에 올 수 있다). int box_out = -1; // -1 = 자동(뒤에서 두 번째 4채널 출력) int score_out = -1; diff --git a/tools/install_arch.py b/tools/install_arch.py index 3fb280d..88f6b7e 100644 --- a/tools/install_arch.py +++ b/tools/install_arch.py @@ -111,6 +111,27 @@ def detect_shapes(out_dir, cls): return nc +def traced_size(out_dir, cls, fallback): + """생성 `.py` 에서 trace 입력 크기를 읽는다. + + 그래프는 **한 크기로만** 돈다(trace 가 그 크기를 구웠다). 러너가 다른 크기로 돌리면 + `GGML_ASSERT(ggml_nelements(a) == ne0*ne1)` 로 죽는다 — 사람이 `--size` 를 기억하게 + 두지 말고 산출물에서 읽는다. + """ + path = os.path.join(out_dir, cls + ".py") + try: + src = open(path, encoding="utf-8").read() + except OSError: + return fallback + m = re.search(r"np\.random\.randn\(\s*\d+\s*,\s*\d+\s*,\s*(\d+)\s*,\s*(\d+)", src) + if not m: + return fallback + h, w = int(m.group(1)), int(m.group(2)) + if h != w: + print(f" ⚠ 입력이 정사각이 아니다({h}x{w}) — {h} 로 등록한다. 필요하면 --size 로 덮어써라") + return h + + def main(): ap = argparse.ArgumentParser() ap.add_argument("out_dir", help="g2c --output 폴더") @@ -119,7 +140,13 @@ def main(): help="YOLO dense 검출기로 등록(디코드+박스 그리기)") ap.add_argument("--classes", type=int, default=0, help="클래스 수(0=자동)") ap.add_argument("--strides", default="8,16,32") - ap.add_argument("--size", type=int, default=640) + ap.add_argument("--size", type=int, default=0, + help="입력 해상도. 생략하면 생성 .py 에서 읽는다") + ap.add_argument("--mean", default="0,0,0", + help="전처리 평균(픽셀 0~255 기준). torchvision 분류기는 " + "'123.675,116.28,103.53'") + ap.add_argument("--std", default="255,255,255", + help="전처리 표준편차. torchvision 분류기는 '58.395,57.12,57.375'") ap.add_argument("--score-thr", type=float, default=0.25) ap.add_argument("--nms", action="store_true", help="NMS 를 건다(기본은 NMS-free — YOLOv10/26 은 one2one 이라 필요 없다)") @@ -134,6 +161,17 @@ def main(): shutil.copy(cpp, os.path.join(ARCH_DIR, cls + ".cpp")) shutil.copy(hdr, os.path.join(ARCH_DIR, header)) + size = a.size or traced_size(a.out_dir, cls, 640) + + def _triple(spec, what): + v = [x.strip() for x in spec.split(",") if x.strip()] + if len(v) != 3: + sys.exit(f"오류: --{what} 는 값 3개여야 한다: {spec}") + return "{" + ", ".join(f"{float(x)}f" for x in v) + "}" + + norm = [f" t.mean = {_triple(a.mean, 'mean')};", + f" t.stdv = {_triple(a.std, 'std')};"] + if a.detect_yolo: nc = a.classes or detect_shapes(a.out_dir, cls) strides = [s.strip() for s in a.strides.split(",") if s.strip()] @@ -144,15 +182,17 @@ def main(): " t.strides = {" + ", ".join(f"{s}.0f" for s in strides) + "};", f" t.nms_free = {'false' if a.nms else 'true'};", f" t.score_thr = {a.score_thr}f;", - f" t.input_size = {a.size};", - ] + f" t.input_size = {size};", + ] + norm if names: body.append(" t.class_names = {") for i in range(0, len(names), 6): body.append(" " + ", ".join(f'"{n}"' for n in names[i:i + 6]) + ",") body.append(" };") else: - body = [" t.kind = visp::arch_kind::raw;"] + # 검출기가 아니어도 크기는 실어야 한다 — 러너가 그 크기로 입력을 만든다. + body = [" t.kind = visp::arch_kind::raw;", + f" t.input_size = {size};"] + norm reg_path = os.path.join(ARCH_DIR, arch + "_register.cpp") with open(reg_path, "w", encoding="utf-8") as f: From 64357372284f1cc894bbe8c4ee2e121046872712 Mon Sep 17 00:00:00 2001 From: eunchae Date: Thu, 13 Aug 2026 13:59:59 +0900 Subject: [PATCH 07/89] =?UTF-8?q?feat(mmdet):=20dense=20head=20=EB=A5=BC?= =?UTF-8?q?=20C++=20=EB=B6=80=ED=92=88=EC=9C=BC=EB=A1=9C=20=EC=A1=B0?= =?UTF-8?q?=EB=A6=BD=ED=95=9C=EB=8B=A4=20(100=EA=B3=84=EC=97=B4=20?= =?UTF-8?q?=EC=A4=91=2079)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백본·neck 만 g2c 로 컴파일하고, head 는 `tools/detect/head.cpp` 가 GGUF 에서 이름으로 가중치를 찾아 조립한다. 조립기 함수 13개로 dense 41계열 중 38계열이 열렸고, 그중 10계열은 코드를 한 줄도 안 쓰고 MRO 로 가장 가까운 조상의 조립기를 탄다. 검증은 512×512 · **학습 체크포인트** · torch `bbox_head` 출력 대비 **상대 L1/L2**(허용 5e-02). `tools/verify/dense_head/verify_heads.py` 가 계열 이름만 받아 잰다. ⚠️ **cosine 을 쓰지 않는다.** 스케일 불변이라 크기가 통째로 틀려도 1.0 이 나온다 — `rtmdet` 이 cos 0.999450 으로 통과했는데 실제 값은 97% 틀렸다. 자를 바꾸자마자 share_conv · stride 곱 · SiLU 세 군데가 동시에 드러났다. 랜덤 초기화도 같은 이유로 못 쓴다: γ=1·β=0 이 빠진 연산을 덮어 준다. 계열마다 다른 축은 넷이고 **전부 shape 를 안 바꾼다** — 가중치 공유 · 출력 스케일 · 활성화 · 타워 구조. ggml 은 전부 통과시키므로 `type(bh)` 의 속성에서 직접 읽는다. 그래프 밖 가중치는 `append_head_weights.py` 가 덧붙인다. g2c 가 "그래프에 쓰인 가중치만 굽는" 것은 옳다 — 그 등식을 깬 건 head 를 C++ 로 뺀 이 경로다. 컴파일러에 개념을 늘리는 대신 mmdet 을 아는 쪽이 자기 것을 싣는다. `.pt` 는 클래스를 `__module__` 이름으로 절이므로 로더가 `mmdet_wrap` 을 import 할 수 있어야 한다. export 가 래퍼 모듈을 `.pt` 옆에 같이 쓴다 — 환경변수가 필요 없다. (전에는 "어디서든 로드 가능" 이라는 **주석만** 있었고 실제로는 `ModuleNotFoundError: No module named 'mmdet_wrap'` 로 죽었다.) 머지 순서: #14 → #15 → #16 → #17 → 이 PR. head.cpp 가 #14 의 `group_norm`· `argsort_top_k` 를 쓴다. --- tools/README.md | 326 +++-- tools/build/build_mmdet_cpp.sh | 35 +- tools/detect/draw.h | 76 + tools/detect/head.cpp | 1305 +++++++++++++++++- tools/detect/head.h | 189 ++- tools/frontend/mmdet/append_head_weights.py | 122 ++ tools/frontend/mmdet/frcnn_to_pt.py | 23 +- tools/frontend/mmdet/frcnn_wrap.py | 121 +- tools/frontend/mmdet/mmdet_compat.py | 247 ++++ tools/frontend/mmdet/mmdet_to_pt.py | 128 +- tools/frontend/mmdet/mmdet_wrap.py | 516 ++++++- tools/verify/backbone/run_frcnn.cpp | 333 +++++ tools/verify/backbone/run_mmdet.cpp | 330 ++++- tools/verify/dense_head/fetch_checkpoints.py | 80 ++ tools/verify/dense_head/head_support_map.py | 145 ++ tools/verify/dense_head/mmdet_families.py | 90 ++ tools/verify/dense_head/vconfig.py | 94 ++ tools/verify/dense_head/verify.toml | 39 + tools/verify/dense_head/verify_heads.py | 737 ++++++++++ 19 files changed, 4649 insertions(+), 287 deletions(-) create mode 100644 tools/detect/draw.h create mode 100644 tools/frontend/mmdet/append_head_weights.py create mode 100644 tools/frontend/mmdet/mmdet_compat.py create mode 100644 tools/verify/backbone/run_frcnn.cpp create mode 100644 tools/verify/dense_head/fetch_checkpoints.py create mode 100644 tools/verify/dense_head/head_support_map.py create mode 100644 tools/verify/dense_head/mmdet_families.py create mode 100644 tools/verify/dense_head/vconfig.py create mode 100644 tools/verify/dense_head/verify.toml create mode 100644 tools/verify/dense_head/verify_heads.py diff --git a/tools/README.md b/tools/README.md index 46c081c..8aa73a7 100755 --- a/tools/README.md +++ b/tools/README.md @@ -1,184 +1,234 @@ -# vision.cpp / tools — 검출 frontend & 검증 +# tools — detection frontends, components and runners -검출기를 vision.cpp 에서 실행하기 위한 부품을 **기능(function)별로 분리**한다. 프레임워크(mmdet 등) -지식은 `frontend//` 에만 격리하고, 검출 head/decode 부품(`detect/`)과 검증 러너(`verify/`)는 -**프레임워크 중립**으로 둔다 — mmdet 외 프레임워크도 여기 붙일 수 있게. **g2c 코어·libvisioncpp -코어는 건드리지 않는다.** 구조는 **backbone/neck = g2c 생성(그대로) + head = `detect/` C++ 부품** 하이브리드. -검출 head 는 trace 가 안 되므로(NMS·동적 offset 등) ggml 로 억지 변환하지 않고 손코딩 C++ 로 조립한다. +Everything needed to run a detector with vision.cpp that does not belong in the library +itself. [docs/mmdet-detectors.md](../docs/mmdet-detectors.md) explains the pipeline end to end; +this file describes how the directory is arranged. -## 폴더 구조 (기능별) +## Layout ``` tools/ - detect/ # 검출 공통 C++ 부품 (프레임워크 무관) — head/decode - head.h head.cpp # anchor_head_forward · vfnet_head_forward … frontend/ - mmdet/ # mmdet 전용: 검출기 → traceable .pt + postproc.json (torch-side) - mmdet_wrap.py mmdet_to_pt.py frcnn_wrap.py frcnn_to_pt.py - # # (다른 프레임워크는 frontend// 로 추가) - verify/ # E2E 검증 러너 (task별) - dense_head/ run_vfnet_head.cpp - roi/ run_frcnn.cpp run_roi_verify.cpp run_rpn_verify.cpp - seg/ run_maskrcnn.cpp - tracking/ run_bytetrack_verify.cpp - backbone/ run_dump.cpp run_mmdet.cpp - build/ # 빌드 스크립트 build_{mmdet,frcnn,maskrcnn}_cpp.sh + mmdet/ MMDetection-specific. The only place that imports mmdet. + mmdet_wrap.py traceable module + config extraction + mmdet_to_pt.py CLI: config -> backbone.pt + .postproc.h + frcnn_wrap.py two-stage / mask sub-graphs + frcnn_to_pt.py CLI for the above + detect/ Framework-neutral head components, compiled into the runner + head.h head.cpp + verify/ Runners and inspection, grouped by task + backbone/ run_mmdet.cpp run_dump.cpp + dense_head/ run_vfnet_head.cpp verify_heads.py + roi/ run_frcnn.cpp run_roi_verify.cpp run_rpn_verify.cpp + seg/ run_maskrcnn.cpp + tracking/ run_bytetrack_verify.cpp + draw_boxes.py + build/ Build scripts ``` -- **`detect/head.cpp` 는 라이브러리가 아니라 러너와 함께 컴파일**된다(`build/build_mmdet_cpp.sh`). - libvisioncpp 에 넣지 않는다 = 프레임워크 지식이 코어로 새지 않음. -- head 가 쓰는 `conv_2d`/`group_norm`/`conv_2d_deform` 등은 vision.cpp 라이브러리 프리미티브(무수정). +A detector runs as a hybrid: the backbone and neck are a compiled ggml graph, while the head, +decoding and post-processing are C++ assembled from library primitives. Detection heads carry +control flow that depends on the data — suppression counts that are not known in advance, +deformable offsets derived from an earlier prediction, proposal counts that vary per image — and +tracing records only the path one input happened to take. + +`detect/head.cpp` assembles eight MMDetection families with four functions. RetinaNet, ATSS, +PAA, FCOS and GFL share one skeleton — two convolution towers and a few output convolutions — +so they differ only through flags on `anchor_head_cfg`: a third `centerness` branch, per-level +learnable scales, the FCOS bbox transform, and GFL's distribution decode. VFNet, RepPoints and +TOOD have skeletons of their own and get a function each. Reach for the flags before writing a +new function. + +Nothing here holds a table of layer names. The final classification and regression convolutions +are found by output channel count, and convolution padding comes from the kernel size stored in +the weights, so a new family needs no edit to a lookup table. + +`verify/dense_head/verify_heads.py` measures each family against `bbox_head` in PyTorch, one +tensor at a time, before decoding. It needs trained checkpoints: an untrained model leaves +gamma at 1, beta at 0 and scale at 1, and an assembly that skips those terms still scores a +perfect cosine. + +Of the 100 MMDetection families, 41 carry a dense head; the rest are two-stage detectors, +trackers, or panoptic and instance models whose output goes through `roi/` and `seg/` instead. +Twenty-two of the 41 are covered. Ten of those needed no new code at all: they subclass a head +that was already handled and change only the loss, the backbone or the neck — GHM and PVT are +`RetinaHead`, DyHead is `ATSSHead`, NAS-FCOS is `FCOSHead`, LD subclasses `GFLHead`, LAD +subclasses `PAAHead`, BoxInst and CondInst reach `FCOSHead`. The assembler picks its path by +walking the head's MRO, so a family lands on its nearest covered ancestor. + +When a new family arrives, read `bbox_head.type` first; only if it is unrelated to everything +covered does it need a function of its own, and even then the flags usually get most of the way. +A head that lands on an ancestor is a guess until `verify_heads.py` measures it — a subclass +that overrides `_init_layers` or `forward` assembles into something plausible and wrong. + +Families that are not covered stay registered in `verify_heads.py` anyway. Their failures are +the record of what is left: AutoAssign and YOLOF assemble but disagree (an objectness branch +folded into the score), YOLOX, SSD and YOLACT arrange their towers differently, CornerNet and +CenterNet pool corners rather than score a grid, and the DETR family does not take feature maps +alone — its head consumes decoder queries, so it needs a different assembler rather than another +flag. The attention primitives it would build on already exist in `src/visp/nn.h`. + +Two arrangements keep framework knowledge from spreading: + +- `detect/head.cpp` is not part of `libvisioncpp`. It is compiled together with the runner, + so detector-specific structure never enters the core library. +- Decoding lives in the library, not in the frontend. `detect_anchor`, `roi_align` and + `rpn_proposals` in `src/visp/postproc.h` take numbers, not configuration objects, which is why + they are reusable for detectors that never went through MMDetection. + +## Single-stage detectors + +```sh +# 1. Export. Writes backbone.pt and backbone.postproc.h. +python tools/frontend/mmdet/mmdet_to_pt.py \ + --config retinanet_r18_fpn_1x_coco.py --checkpoint retinanet_r18.pth \ + --out backbone.pt --size 512 + +# 2. Compile backbone.pt to a vision.cpp arch module: .cpp, .h, .gguf. +# Compile it at the same resolution --size used above. Tracing records the operations for +# one input shape, and a graph built for another size aborts in ggml_can_repeat at run time. +# The interface the generated code must satisfy is in docs/mmdet-detectors.md. + +# 3. Build the runner: the generated graph, head.cpp and run_mmdet.cpp together. +# The parameters header is the one export wrote next to backbone.pt. +bash tools/build/build_mmdet_cpp.sh output/MMDetBackbone backbone.postproc.h + +# 4. Run. +output/MMDetBackbone/run_mmdet output/MMDetBackbone/MMDetBackbone.gguf image.jpg detected.png 512 +``` -## 흐름 (main 의 run_yolo_cpp 러너 패턴) +Step 3 looks for the library in `build/`. If you configured elsewhere, name it: +```sh +VISP_BUILD=/path/to/that/directory \ + bash tools/build/build_mmdet_cpp.sh output/MMDetBackbone backbone.postproc.h ``` -mmdet config - │ ① python mmdet_to_pt.py (전처리 — mmdet 지식은 여기만 → backbone.pt + postproc.json) - ▼ -backbone.pt + .postproc.json - │ ② g2c --model backbone.pt --name (g2c 정식 CLI, 코어 무수정·mmdet 코드 없음) - ▼ -output//{.cpp, .h, .gguf} (백본 forward + 가중치[backbone+head]) - │ ③ build_mmdet_cpp.sh output/ (run_mmdet + head.cpp + output/.cpp 컴파일, libvisioncpp 링크) - ▼ -output//run_mmdet - │ ④ run_mmdet - ├─ _forward : g2c 백본(output/.cpp) → FPN features out_0..L-1 - ├─ C++ head 부품 : head.cpp (anchor / vfnet …) → raw cls/box - └─ detect_anchor … : decode + NMS → 박스 -``` - -## 파일 (기능별 위치) -| 파일 | 역할 | -|---|---| -| `mmdet_wrap.py` | `MMDetBackbone`(backbone+neck features nn.Module, head 는 가중치 유지 attribute) + `postproc_cfg`(anchor/decode + head-conv 구조 + 전처리 메타 추출). **유일한 mmdet 의존 지점.** | -| `mmdet_to_pt.py` | CLI: mmdet config → `backbone.pt` + `.postproc.json`. 피클 모듈명 = `mmdet_wrap` (self-contained import). | -| `head.h` / `head.cpp` | C++ head 부품. `anchor_head_forward`(RetinaNet/ATSS: 공유 cls/reg conv 타워) · `vfnet_head_forward`(VFNet: star deformable offset 계산 + `conv_2d_deform`). 러너와 함께 컴파일. | -| `run_mmdet.cpp` | 러너(제네릭, `-DARCH`). `_forward`(백본) → head 부품 → `detect_anchor`. | -| `run_vfnet_head.cpp` | VFNet head 격리 검증 harness (torch FPN features → head → cls/box 덤프). | -| `build_mmdet_cpp.sh` | `run_mmdet.cpp` + `head.cpp` + `output/.cpp` 를 libvisioncpp 와 컴파일. | +`backbone.postproc.h` holds a generated `mmdet_params()` — anchor scales, head convolution +layout, normalisation values. Once an architecture is fixed those are constants, so they are +compiled into the runner rather than read at run time, and the deployed set is the executable +and the weights. -decode+NMS·전처리는 vision.cpp 라이브러리(`src/visp/postproc.{h,cpp}` 의 `detect_anchor`/`preprocess`)를 -그대로 쓴다. `conv_2d_deform`(DCN)도 라이브러리 프리미티브(ggml `conv_2d_deform` 커널 래퍼). +`mmdet_wrap.postproc_cfg` is the only code that reads an MMDetection configuration. It also +extracts `img_mean` / `img_std` / `to_rgb` from `data_preprocessor`, so pre-processing is the +library's `preprocess()` driven by extracted values rather than anything hand-written. -## 사용 예 (RetinaNet r18) +## Looking at the output -```bash -PY=; G2C=; V=$G2C/vision.cpp -FE=$V/tools/frontend/mmdet # mmdet frontend (torch-side, mmdet 지식 유일 지점) -BUILD=$V/tools/build # 빌드 스크립트 -CFG=/configs/retinanet/retinanet_r18_fpn_1x_coco.py +The extension of the output path decides what `run_mmdet` writes. -# ① mmdet → backbone.pt + postproc.json (frontend/mmdet = mmdet 지식 유일 지점) -PYTHONPATH=$FE $PY $FE/mmdet_to_pt.py --config $CFG --out /tmp/rn.pt --size 512 +```sh +run_mmdet model.gguf image.jpg detected.png 512 # the image, boxes drawn on it +run_mmdet model.gguf image.jpg boxes.bin 512 # raw float32, six numbers per box +``` -# ② g2c 정식 CLI → output/MMDetBackbone/{cpp,h,gguf} (g2c 코어 무수정, .pt 는 generic torch 모듈) -PYTHONPATH=$G2C:$FE $PY -m shared.compile.pipeline --model /tmp/rn.pt --name MMDetBackbone \ - --input-shape 1,3,512,512 --output output/MMDetBackbone +An image is the default because that is what the rest of `vision-cli` produces and what a person +looking at a result wants. Raw `float32` — `x1 y1 x2 y2 score label` — is what comparing against +a reference implementation needs, so it stays one extension away. -# ③ 러너 컴파일 (output/.cpp + verify/backbone/run_mmdet + detect/head.cpp + libvisioncpp) -VISP_BUILD=$V/build bash $BUILD/build_mmdet_cpp.sh output/MMDetBackbone +Either way the highest-scoring detections are printed: -# ④ 실행 (백본 + C++ head + detect_anchor → 박스). 입력이 이미지면 preprocess() 자동 전처리. -output/MMDetBackbone/run_mmdet output/MMDetBackbone/MMDetBackbone.gguf \ - image.jpg /tmp/rn.postproc.json boxes.bin 512 +``` + # x1 y1 x2 y2 score label + 0 459.3 241.1 512.0 263.3 0.837 63 + 1 306.4 69.4 361.2 84.5 0.835 63 + ... (98 more) ``` -pre(전처리)도 손코딩이 아니라 **범용 부품 + config 추출**: `postproc.cpp` 의 `preprocess()` -(resize+normalize+to_rgb, CPU 스칼라) + mmdet_wrap 이 `data_preprocessor` 에서 `img_mean/img_std/ -to_rgb` 자동 추출 → postproc.json. +The image carries where, the table carries what. No text is drawn into the image; class is +encoded as colour, which keeps a font out of the runner. -## 검증 +| Variable | Effect | +| :--- | :--- | +| `VISP_DRAW_THRESHOLD` | Minimum score to draw. Default `0.3` | +| `VISP_PRINT_DETS` | How many rows to print. `0` turns the table off | -- **RetinaNet r18** (anchor head): C++ head raw cls/box cos 0.999999~1.0, 최종 박스 `predict_by_feat` - 대비 IoU>0.99 매칭 100/100. -- **VFNet r50** (distance + star DCN head): `vfnet_head_forward` (star deformable offset 계산 + - `conv_2d_deform`) 를 torch head 와 비교 → 5레벨 cls/box **cos 0.999998~1.0** (격리 검증 - `run_vfnet_head`). DCN offset 계산이 자동변환 안 되는 부분을 손코딩으로 해결한 사례. +`tools/verify/draw_boxes.py` draws a `.bin` that was written earlier, which is the way to look +at a file kept for comparison. It adds class names and scores as text, which the C++ path does +not. -## Two-stage (Faster R-CNN) +`run_dump` covers any generated graph — it prints each output tensor's shape and writes it as +raw `float32`, which is how to inspect a backbone with no head attached. -RPN proposal·RoIAlign 은 데이터 의존(proposal 개수 가변)이라 단일 그래프에 안 들어감 → g2c 로 -**두 subgraph**(SubA/SubB)만 뽑고, 그 사이는 host C++ op 으로 오케스트레이션. +## Heads -``` -이미지 - │ g2c SubA (backbone+neck+RPN) [frcnn_wrap.FRCNN_SubA → 14 출력] - ▼ P2-P5 + rpn_cls×5 + rpn_bbox×5 - │ rpn_proposals (host) RPN decode + level NMS → 1000 proposals - │ roi_align (host) proposal + P2-P5 → roi_feat (N,256,7,7) - ▼ - │ g2c SubB (bbox_head Shared2FC) [frcnn_wrap.FRCNN_SubB → cls,bbox] - ▼ cls_score(N,81) + bbox_pred(N,320) - │ detect_roi (host) softmax + delta decode + per-class NMS → 박스 - ▼ 최종 박스 -``` +`detect/head.h` declares the components that turn FPN features into raw per-level tensors. -파일: `frcnn_wrap.py`(SubA/SubB + config), `frcnn_to_pt.py`(→ .pt×2 + frcnn.json), -`run_frcnn.cpp`(오케스트레이션 러너), `build_frcnn_cpp.sh`. host op 은 `postproc.cpp` 의 -`rpn_proposals`/`roi_align`/`detect_roi` (라이브러리). 검증 harness: `run_roi_verify.cpp`, -`run_rpn_verify.cpp`. +`anchor_head_forward` +: Shared convolution tower followed by classification and regression convolutions — RetinaNet, + ATSS, GFL and other anchor-based dense heads. The differences between them are values in + `anchor_head_cfg`, not code. -```bash -python tools/frontend/mmdet/frcnn_to_pt.py --config faster-rcnn_r50_fpn_1x_coco.py --checkpoint frcnn.pth --out /tmp/frcnn -g2c --model /tmp/frcnn/FRCNN_SubA.pt --name FRCNN_SubA --input-shape 1,3,800,800 --output output/FRCNN_SubA -g2c --model /tmp/frcnn/FRCNN_SubB.pt --name FRCNN_SubB --input-shape 4,256,7,7 --output output/FRCNN_SubB -bash tools/build/build_frcnn_cpp.sh output/FRCNN_SubA output/FRCNN_SubB -output/FRCNN_SubA/run_frcnn output/FRCNN_SubA/FRCNN_SubA.gguf output/FRCNN_SubB/FRCNN_SubB.gguf \ - /tmp/frcnn/frcnn.json input.bin 800 -``` +`vfnet_head_forward` +: Distances rather than anchor deltas, refined by a star-shaped deformable convolution whose + sampling offsets are computed from the first bbox prediction. Those offsets are values + produced during the forward pass, so the component builds that computation explicitly. -**검증 (Faster R-CNN r50, 800, trained, demo.jpg):** -- RoIAlign : torch `bbox_roi_extractor` 대비 **cos 1.0, max|Δ|=7e-07** (1000 proposals) -- RPN proposals : torch `RPNHead.predict_by_feat` 대비 **1000/1000 IoU>0.99** -- E2E 박스 : torch 풀 two-stage 대비 **score>0.3 20/20 매칭(IoU>0.95), score>0.05 48/49** +Adding a head means a new `_head_forward` here using library primitives (`conv_2d`, +`group_norm`, `conv_2d_deform`), the matching parameters emitted by `mmdet_wrap.postproc_cfg`, +and a decoder in `postproc.h` if the decoding scheme is new. -## Instance segmentation (Mask R-CNN) +## Two-stage detectors -Faster R-CNN + mask 분기. 최종 박스에 **2nd RoIAlign(out=14)** → g2c SubC(mask_head FCN: -4×conv + deconv + conv_logits) → `paste_mask`(host). +RPN proposals and RoIAlign are data-dependent — the number of proposals is not known until the +network has run — so a two-stage detector cannot be a single graph. ``` -Faster R-CNN → 최종 박스 - │ roi_align(out=14, host) 박스 → mask_feat (M,256,14,14) - │ g2c SubC (mask_head) → mask_logits (M,80,28,28) [frcnn_wrap.MaskRCNN_SubC] - │ paste_mask (host) label mask → sigmoid+resize+threshold → 인스턴스 마스크 - ▼ + image + │ SubA (backbone + neck + RPN) 14 outputs: P2-P5, rpn_cls×5, rpn_bbox×5 + ▼ + │ rpn_proposals (host) decode + per-level NMS -> proposals + │ roi_align (host) proposals + P2-P5 -> roi_feat (N,256,7,7) + ▼ + │ SubB (bbox head, Shared2FC) -> cls_score, bbox_pred + ▼ + │ detect_roi (host) softmax + delta decode + per-class NMS + ▼ detections ``` -파일: `frcnn_wrap.MaskRCNN_SubC`, `run_maskrcnn.cpp`, `build_maskrcnn_cpp.sh`. host op 은 -`postproc.cpp` 의 `roi_align`/`paste_mask`. +```sh +python tools/frontend/mmdet/frcnn_to_pt.py \ + --config faster-rcnn_r50_fpn_1x_coco.py --checkpoint frcnn.pth --out /tmp/frcnn +# compile FRCNN_SubA.pt at 1,3,800,800 and FRCNN_SubB.pt at 4,256,7,7 -> ⚠️ **ggml `conv_transpose_2d_p0` 는 batch(N>1) 미지원** → run_maskrcnn 은 SubC 를 **roi 별 -> (batch=1)** 로 실행한다. (배치로 돌리면 첫 roi 만 정확: batch0 cos 1.0, batch1+ 깨짐.) +bash tools/build/build_frcnn_cpp.sh output/FRCNN_SubA output/FRCNN_SubB +output/FRCNN_SubA/run_frcnn output/FRCNN_SubA/FRCNN_SubA.gguf \ + output/FRCNN_SubB/FRCNN_SubB.gguf /tmp/frcnn/frcnn.json input.bin 800 +``` -**검증 (Mask R-CNN r50, 800, trained, demo.jpg):** torch 풀 Mask R-CNN 대비 -- 박스 20/20 매칭, **mask IoU 평균 0.984, IoU>0.9 20/20** (score>0.3); -- score>0.05: mask IoU 평균 0.975, IoU>0.9 42/43. +The host operations are `rpn_proposals`, `roi_align` and `detect_roi` in the library. -## Tracking (MOT — ByteTrack) +## Instance segmentation -검출(그래프)은 그대로 쓰고, 프레임 간 **association**만 host 부품으로 한다. tracking 은 신경망이 -아니라 상태추적 로직 → g2c 변환 대상 없음. 상태 유지 class `ByteTracker`(라이브러리 `tracker.{h,cpp}`). +Mask R-CNN adds a second RoIAlign at output size 14 over the final boxes, a mask sub-graph, and +host-side mask pasting. ``` -프레임별 검출 (위 검출기 그대로) - → ByteTracker.track(dets, frame_id) - ① Kalman 예측 (SORT 8-state cxcyah) - ② IoU 2단계 매칭 (high-score 먼저 → low-score) + tentative/confirmed - ③ track 생성/유지/삭제 (num_frames_retain) - → track ID (프레임 간 유지) + final boxes -> roi_align(out=14) -> SubC (mask head FCN) -> paste_mask -> instance masks ``` -`ByteTracker` 는 검출기 무관(generic). 검증 harness: `run_bytetrack_verify.cpp`. +``` +run_maskrcnn [size=800] +``` + +> ggml's `conv_transpose_2d_p0` does not support batching, so `run_maskrcnn` evaluates the mask +> sub-graph one RoI at a time. Running it batched leaves only the first RoI correct. + +## Tracking + +Tracking is state management, not a network — there is nothing to compile. `ByteTracker` in +`src/visp/tracker.h` holds track state across frames and is detector-agnostic: it consumes +`std::vector` from any of the decoders. -**검증:** 합성 검출 시퀀스(10 프레임, 5 물체: 등속 이동 + 등장/소멸)를 mmdet `ByteTracker` 와 -동일 입력으로 비교 → **track ID 44/44 완전 일치, 충돌 0** (Kalman·2단계 매칭 복제). +## Isolation harnesses -## 확장 (다른 head) +Each takes one stage and compares it against a dump from the reference implementation, which +locates a mismatch to a single stage rather than to the pipeline as a whole. -- **anchor**(RetinaNet/ATSS, DeltaXYWHBBoxCoder): `anchor_head_forward` 그대로(cls/reg conv 이름 자동 탐지). -- **VFNet**(distance + DCN): `vfnet_head_forward`. offset = star_dcn_offset(bbox_pred) - dcn_base → - `conv_2d_deform`. distance decode 는 `postproc.cpp` 의 `detect_fcos` 연결로 박스화(진행 중). -- **FCOS/DETR/two-stage**: `head.cpp` 에 부품 추가 + `postproc` 의 `detect_fcos`/`detect_detr`/`detect_roi` 연결. +| Harness | Stage | +| :--- | :--- | +| `run_vfnet_head` | A dense head in isolation, from FPN features to raw cls/box | +| `run_rpn_verify` | RPN proposal generation | +| `run_roi_verify` | RoIAlign | +| `run_bytetrack_verify` | Frame-to-frame association | +| `run_dump` | Any generated graph — every output tensor as raw `float32` | diff --git a/tools/build/build_mmdet_cpp.sh b/tools/build/build_mmdet_cpp.sh index 433c41d..dd0012e 100755 --- a/tools/build/build_mmdet_cpp.sh +++ b/tools/build/build_mmdet_cpp.sh @@ -5,21 +5,24 @@ # head.cpp 는 라이브러리가 아니라 여기서 러너와 함께 컴파일된다 → g2c output/.cpp 를 직접 # 컴파일(arch/ 복사·cli REG 없음). # -# 사용: build_mmdet_cpp.sh [arch_name] +# usage: build_mmdet_cpp.sh [arch_name] # gen_dir = g2c --output 디렉토리 (예: output/MMDetBackbone) — .cpp/.h/.gguf 있음 +# params.h = .postproc.h written by mmdet_to_pt.py next to its --out. When omitted, +# a *.postproc.h inside gen_dir is used. # arch_name= 클래스명(생략 시 gen_dir 의 *.cpp 에서 자동) +# # options: # --build libvisioncpp 빌드 디렉토리 (기본: /build) -# ⚠️ **어떤 라이브러리로 빌드했는지는 명령줄에 남아야 한다.** env 로만 받으면 -# 낡은 build/ 를 참조해 링크가 깨져도 로그만 봐서는 알 수 없다. -# `VISP_BUILD` 도 계속 읽지만 인자가 우선이다. +# ⚠️ **어떤 라이브러리로 빌드했는지는 명령줄에 남아야 한다.** 예전엔 env +# (`VISP_BUILD`)로만 받았는데, 낡은 build/ 를 참조해 링크가 깨졌을 때 +# 로그만 봐서는 원인을 알 수 없었다. env 도 계속 읽지만 인자가 우선이다. # set -e SELF="$(cd "$(dirname "$0")" && pwd)" # vision.cpp/tools/build (빌드 스크립트) V="$(cd "$SELF/../.." && pwd)" # vision.cpp (libvisioncpp 소스) DETECT="$V/tools/detect" # 공용 head/decode 부품 (head.cpp/head.h) RUN="$V/tools/verify" # E2E 검증 러너 -# `--build ` 는 어디에 두든 받는다 — 위치 인자와 섞이지 않게 먼저 걷어낸다. +# `--build ` 를 어디에 두든 받는다 — 위치 인자와 섞이지 않게 먼저 걷어낸다. ARG_BUILD="" POS=() while [ $# -gt 0 ]; do @@ -31,9 +34,24 @@ while [ $# -gt 0 ]; do done set -- "${POS[@]}" -GEN="${1:?usage: build_mmdet_cpp.sh [--build ] [arch_name]}" +GEN="${1:?usage: build_mmdet_cpp.sh [--build ] [params.h] [arch_name]}" GEN="$(cd "$GEN" && pwd)" -ARCH="${2:-}" +# The second argument is either the parameters header (.h) or an architecture name. +PARAMS="" +ARCH="" +case "${2:-}" in + *.h) PARAMS="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")"; ARCH="${3:-}" ;; + "") ;; + *) ARCH="$2" ;; +esac +if [ -z "$PARAMS" ]; then + PARAMS="$(ls "$GEN"/*.postproc.h 2>/dev/null | head -1)" +fi +if [ ! -f "$PARAMS" ]; then + echo "no parameters header. mmdet_to_pt.py writes .postproc.h next to its --out." + echo " usage: $(basename "$0") $GEN .postproc.h" + exit 1 +fi if [ -z "$ARCH" ]; then ARCH="$(basename "$(ls "$GEN"/*.cpp | grep -v run_ | head -1)" .cpp)" fi @@ -47,7 +65,7 @@ FMT_INC="$BUILD/_deps/fmt-src/include" FMT_FLAGS="" [ -f "$FMT_INC/fmt/format.h" ] && FMT_FLAGS="-DVISP_FMT_LIB -I$FMT_INC" -echo "arch=$ARCH gen=$GEN build=$BUILD fmt=${FMT_FLAGS:-fallback}" +echo "arch=$ARCH gen=$GEN params=$PARAMS build=$BUILD fmt=${FMT_FLAGS:-fallback}" INC="$GEN/inc" mkdir -p "$INC/visp/arch" cp "$GEN/$ARCH.h" "$INC/visp/arch/$ARCH.h" @@ -55,6 +73,7 @@ cp "$GEN/$ARCH.h" "$INC/visp/arch/$ARCH.h" # run_mmdet.cpp + head.cpp(러너와 함께 컴파일, 라이브러리 아님) + g2c 백본 output/.cpp g++ -std=c++20 -O2 $FMT_FLAGS \ -DARCH="$ARCH" -DVISP_ARCH_HEADER="\"visp/arch/$ARCH.h\"" \ + -DMMDET_PARAMS_HEADER="\"$PARAMS\"" \ -I"$DETECT" -I"$INC" -I"$V/include" -I"$V/src" \ -I"$V/depend/llama/ggml/include" -I"$V/depend/llama/vendor" \ "$RUN/backbone/run_mmdet.cpp" "$DETECT/head.cpp" "$GEN/$ARCH.cpp" \ diff --git a/tools/detect/draw.h b/tools/detect/draw.h new file mode 100644 index 0000000..0b5bf46 --- /dev/null +++ b/tools/detect/draw.h @@ -0,0 +1,76 @@ +// Draw detections onto an image -- the minimum needed for the runner's default output. +// +// No text is drawn. A font would grow the runner for nothing, because the runner also prints +// the detections as a table: the image carries where, the table carries what. +#pragma once + +#include "visp/image.h" +#include "visp/postproc.h" + +#include +#include +#include + +namespace visp { + +// Enough separation to tell classes apart. Same class, same colour, no legend needed. +inline std::array detection_colour(int label) { + static constexpr uint8_t table[][3] = { + {230, 60, 60}, {60, 160, 230}, {70, 190, 110}, {240, 160, 40}, {170, 100, 220}, + {40, 200, 200}, {230, 110, 170}, {150, 160, 60}, {110, 130, 240}, {200, 90, 60}, + }; + int n = int(sizeof(table) / sizeof(table[0])); + int i = ((label % n) + n) % n; + return {table[i][0], table[i][1], table[i][2]}; +} + +namespace detail { + +inline void put_pixel(image_span const& img, int x, int y, std::array c) { + if (x < 0 || y < 0 || x >= img.extent[0] || y >= img.extent[1]) { + return; + } + int nc = n_channels(img.format); + auto* p = static_cast(img.data) + size_t(y) * img.stride + size_t(x) * nc; + p[0] = c[0]; + if (nc > 1) p[1] = c[1]; + if (nc > 2) p[2] = c[2]; +} + +} // namespace detail + +// One box outline, drawn inwards to the given thickness in pixels. +inline void draw_box(image_span const& img, float x1, float y1, float x2, float y2, + std::array colour, int thickness = 2) { + int ix1 = int(std::min(x1, x2)), ix2 = int(std::max(x1, x2)); + int iy1 = int(std::min(y1, y2)), iy2 = int(std::max(y1, y2)); + for (int t = 0; t < thickness; ++t) { + for (int x = ix1; x <= ix2; ++x) { + detail::put_pixel(img, x, iy1 + t, colour); + detail::put_pixel(img, x, iy2 - t, colour); + } + for (int y = iy1; y <= iy2; ++y) { + detail::put_pixel(img, ix1 + t, y, colour); + detail::put_pixel(img, ix2 - t, y, colour); + } + } +} + +// Draw a list of detections. Coordinates are in the square input the detector ran on, so +// scale_x / scale_y put them back on the original image. Returns how many were drawn. +inline int draw_detections(image_span const& img, std::vector const& dets, + float scale_x, float scale_y, float threshold = 0.3f) { + int thickness = std::max(2, img.extent[1] / 300); + int drawn = 0; + for (detection const& d : dets) { + if (d.score < threshold) { + continue; + } + draw_box(img, d.x1 * scale_x, d.y1 * scale_y, d.x2 * scale_x, d.y2 * scale_y, + detection_colour(d.label), thickness); + ++drawn; + } + return drawn; +} + +} // namespace visp diff --git a/tools/detect/head.cpp b/tools/detect/head.cpp index 97f8522..13cc3bc 100755 --- a/tools/detect/head.cpp +++ b/tools/detect/head.cpp @@ -1,69 +1,987 @@ #include "head.h" +#include + #include "visp/nn.h" #include +#include #include namespace visp { +// ── 공용 부품 ───────────────────────────────────────────────────────────────── +// dcn_base 를 head 내부 레이아웃(contiguous_2d = whcn)의 채널 축에 맞춘다. +// 러너는 평평한 {18,1,1,1} 로 넘긴다 — 그대로 쓰면 ne0 끼리 맞춰보다 `ggml_can_repeat` 로 +// 죽는다(offset 은 {W,H,18,1} 이라 채널이 ne2 다). broadcast 가 먹도록 축을 세운다. +static tensor dcn_base_whcn(model_ref m, tensor b) { + if (!b) return b; + int64_t n = ggml_nelements(b); + return (m.flags & model_build_flag::cwhn) ? ggml_reshape_4d(m, b, n, 1, 1, 1) + : ggml_reshape_4d(m, b, 1, 1, n, 1); +} + +// GroupNorm + affine (γ/β). 코어 라이브러리(nn.cpp)에 group_norm 이 없어도 self-contained 하도록 +// 여기 로컬로 둔다(교수님 지시: mmdet 은 tools 에서 자족, 코어 무수정). 채널축: whcn=[1,1,C,1], cwhn=ne[0]. +static tensor group_norm_affine(model_ref m, tensor x, int groups, float eps = 1e-5f) { + x = ggml_group_norm(m, x, groups, eps); + bool whcn = !(m.flags & model_build_flag::cwhn); + auto rs = [&](tensor t) { return whcn ? ggml_reshape_4d(m, t, 1, 1, t->ne[0], 1) : t; }; + if (tensor weight = m.find("weight")) x = ggml_mul(m, x, rs(weight)); + if (tensor bias = m.find("bias")) x = ggml_add(m, x, rs(bias)); + return x; +} + +// pad 를 커널 크기에서 정한다. mmdet head 는 3x3(pad 1)과 1x1(pad 0)이 섞여 있고, +// 계열마다 어느 쪽인지 다르다 — cfg 에 pad 를 또 하나 두는 대신 가중치를 보고 정한다. +// **타워 안에서도 섞인다**: NAS-FCOS 는 탐색된 head 라 단마다 커널이 다르다. +// pad 를 1 로 박으면 1×1 단에서 출력이 두 칸 커진다(실측: 64 → 66). +static tensor conv_same(model_ref m, const std::string& p, tensor x, int stride = 1) { + model_ref sub = m[p.c_str()]; + int k = (int)sub.weights("weight")->ne[0]; // whcn 가중치 {KW,KH,Cin,Cout} + return conv_2d(sub, x, stride, (k - 1) / 2); +} + +// ne2(채널) 축 [off, off+n) 슬라이스. +static tensor slice_ch(model_ref m, tensor x, int64_t off, int64_t n) { + return ggml_cont(m, ggml_view_4d(m, x, x->ne[0], x->ne[1], n, x->ne[3], + x->nb[1], x->nb[2], x->nb[3], (size_t)off * x->nb[2])); +} + +// ConvModule 의 conv 단. 보통은 평범한 conv 지만 **DCNv2(`ModulatedDeformConv2dPack`)일 수도** +// 있다 — NAS-FCOS 처럼 head 구조를 탐색한 계열이 타워 중간중간에 섞어 쓴다. +// 판별은 `conv_offset` 가중치의 유무로 한다(있으면 deform). 이름 표를 두지 않는다. +// +// mmcv 의 forward 그대로: conv_offset 이 3·k·k 채널을 내고 앞 2/3 가 offset, 뒤 1/3 이 mask(sigmoid). +static tensor conv_maybe_deform(model_ref m, const std::string& p, tensor x) { + model_ref cm = m[p.c_str()]; + tensor ow = cm["conv_offset"].find("weight"); + if (!ow) return conv_same(m, p, x); + + tensor w = cm.weights("weight"); + const int k = (int)w->ne[0], pad = (k - 1) / 2, kk = k * k; + const int64_t cin = w->ne[2], och = ow->ne[3]; + + // conv_offset 출력 채널로 **버전과 그룹 수를 동시에** 알아낸다. + // DCNv1(`DeformConv2dPack`) dg·2·k² offset 만 + // DCNv2(`ModulatedDeformConv2dPack`) dg·3·k² offset + mask + // dg 를 1 로 박으면 NAS-FCOS(dg=2)가 틀리고, v2 로 박으면 DDOD(v1)에서 mask 를 읽다 + // 텐서 밖으로 나간다(`data_size + view_offs <= nbytes` assert). + // 54 처럼 양쪽에 걸리는 값은 **입력 채널을 나눌 수 있는 쪽**으로 정한다(v2 dg=2 vs v1 dg=3). + bool modulated = (och % (3 * kk) == 0) && (cin % (och / (3 * kk)) == 0); + const int dg = std::max(1, (int)(och / ((modulated ? 3 : 2) * kk))); + + tensor o = conv_2d(cm["conv_offset"], x, 1, pad); + tensor offset = slice_ch(m, o, 0, 2 * dg * kk); + tensor mask = modulated ? ggml_sigmoid(m, slice_ch(m, o, 2 * dg * kk, dg * kk)) : nullptr; + + if (dg == 1) { + x = conv_2d_deform(m, x, w, offset, mask, 1, pad); + } else { + // ggml 커널은 dg=1 전제다(`offset->ne[2] == 2·k²` assert). vendored ggml 은 안 고치므로 + // **정의대로 쪼갠다** — 입력 채널을 dg 등분해 각자의 offset/mask 로 돌리고 **더한다**. + // (deform_groups 는 offset 만 나눈다. 출력 채널은 안 나뉘므로 concat 이 아니라 합이다.) + const int64_t cg = cin / dg; + tensor acc = nullptr; + for (int g = 0; g < dg; ++g) { + tensor xg = slice_ch(m, x, g * cg, cg); + tensor wg = ggml_cont(m, ggml_view_4d(m, w, w->ne[0], w->ne[1], cg, w->ne[3], + w->nb[1], w->nb[2], w->nb[3], + (size_t)(g * cg) * w->nb[2])); + tensor og = slice_ch(m, offset, (int64_t)g * 2 * kk, 2 * kk); + tensor mg = mask ? slice_ch(m, mask, (int64_t)g * kk, kk) : nullptr; + tensor yg = conv_2d_deform(m, xg, wg, og, mg, 1, pad); + acc = acc ? ggml_add(m, acc, yg) : yg; + } + x = acc; + } + if (tensor b = cm.find("bias")) { + x = ggml_add(m, x, (m.flags & model_build_flag::cwhn) + ? b : ggml_reshape_4d(m, b, 1, 1, b->ne[0], 1)); + } + return x; +} + +// ConvModule 의 활성화. 기본은 ReLU, RTMDet 만 SiLU 다. +static tensor head_act(model_ref m, tensor x, anchor_head_cfg const& c) { + if (c.head_leaky > 0.0f) return ggml_leaky_relu(m, x, c.head_leaky, false); + return c.head_silu ? ggml_silu(m, x) : ggml_relu(m, x); +} + +// ConvModule(conv + GroupNorm + ReLU). 커널 크기는 가중치에서 읽는다. +static tensor conv_gn_relu(model_ref m, tensor x, const std::string& p, int gn_groups) { + x = conv_maybe_deform(m, p + ".conv", x); + x = group_norm_affine(m[(p + ".gn").c_str()], x, gn_groups); + return ggml_relu(m, x); +} + +// cls/reg 공유 타워. ConvModule 은 norm 유무로 두 모양이다 — RetinaNet 은 conv+relu, +// ATSS/FCOS/GFL/RepPoints 는 conv+GN+relu. +static tensor conv_tower(model_ref m, tensor x, anchor_head_cfg const& c, + const std::string& prefix, size_t level, int nconv = -1) { + // RetinaSepBNHead 는 레벨마다 타워를 따로 둔다 → 이름이 한 겹 깊다. + const std::string base = c.per_level_towers + ? prefix + "." + std::to_string(level) : prefix; + if (nconv < 0) nconv = c.stacked_convs; + for (int i = 0; i < nconv; ++i) { + std::string p = base + "." + std::to_string(i); + x = conv_maybe_deform(m, p + ".conv", x); + if (c.head_has_norm) x = group_norm_affine(m[(p + ".gn").c_str()], x, c.gn_groups); + x = head_act(m, x, c); + } + return x; +} + +// mmcv `Scale` — 레벨마다 스칼라 하나를 곱한다. 값은 GGUF 에서 이름으로 읽으므로 +// 체크포인트를 바꿔도 파라미터 헤더를 다시 굽지 않아도 된다. 없으면 그대로 통과. +static tensor apply_scale(model_ref m, tensor x, anchor_head_cfg const& c, size_t l) { + if (c.scales_prefix.empty()) return x; + std::string p = c.scales_prefix + "." + std::to_string(l) + ".scale"; + tensor s = m.find(p.c_str()); + return s ? ggml_mul(m, x, s) : x; // {1,1,1,1} broadcast +} + +// GFL 의 DFL(Distribution Focal Loss) 디코드. reg_head 는 방향 4개 × 빈 (reg_max+1) 개의 +// **로짓**을 낸다 — 그 분포의 기댓값이 거리다. +// +// x = softmax(logits over bins); distance = Σ_j j·x_j +// +// mmdet 은 이걸 디코드(`Integral`)에서 하지만 여기서 한다. 그러면 GFL 의 출력이 +// FCOS 와 같은 **4채널 거리**가 되어 디코드 쪽에 계열 분기를 안 만들어도 된다. +// +// 채널 배치: C = i·(reg_max+1) + j (빈 j 가 빠른 축). whcn 이라 C 는 ne2 다. +static tensor dfl_integral(model_ref m, tensor bp, int reg_max) { + const int64_t W = bp->ne[0], H = bp->ne[1]; + const int B = reg_max + 1; + // ne2(C) 를 [빈, 방향] 으로 가른다. j 가 빠른 축이라 ne2=B, ne3=4 가 된다. + tensor t = ggml_reshape_4d(m, ggml_cont(m, bp), W, H, B, 4); + // softmax 는 ne0 에만 걸리므로 빈 축을 ne0 로 데려온다 → [B, W, H, 4] + t = ggml_cont(m, ggml_permute(m, t, 1, 2, 0, 3)); + t = ggml_soft_max(m, t); + t = ggml_mul(m, t, ggml_arange(m, 0.0f, (float)B, 1.0f)); // j 가중 + t = ggml_sum_rows(m, t); // → [1, W, H, 4] + return ggml_cont(m, ggml_permute(m, t, 3, 0, 1, 2)); // → [W, H, 4, 1] +} + +// ── RetinaNet · ATSS · PAA · FCOS · GFL ─────────────────────────────────────── // features 는 인터프리터 출력(cwhn). 인터프리터 내부 conv 는 contiguous_2d 레이아웃에서 도므로 // (graph_interpret: cwhn_to_contiguous_2d → conv... → contiguous_2d_to_cwhn), head 도 동일하게 // cwhn→contiguous_2d 로 되돌려 conv 타워를 태우고, 결과를 다시 cwhn 으로 낸다(detect_anchor 규약). -void anchor_head_forward(model_ref m, std::vector const& feats, - anchor_head_cfg const& c, - std::vector& cls_out, std::vector& box_out) { +// +// 다섯 계열의 뼈대가 같아 한 함수로 둔다. 다른 곳은 cfg 플래그로만 갈린다: +// centerness_head ATSS·PAA·FCOS 에만 있는 세 번째 갈래 +// scales_prefix ATSS·PAA·FCOS·GFL 의 레벨별 learnable scale +// bbox_* FCOS 의 clamp·stride / exp +// reg_max GFL 의 DFL +static void tower_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out) { for (size_t l = 0; l < feats.size(); ++l) { tensor f = cwhn_to_contiguous_2d(m, feats[l]); + // 분기 앞 공유 conv(+ReLU). RPNHead 의 `rpn_conv` — 없으면 그냥 통과한다. + if (!c.pre_conv.empty()) f = head_act(m, conv_same(m, c.pre_conv, f), c); - // cls 타워: stacked_convs × (conv3x3 + relu), 가중치 레벨 공유 - tensor cc = f; - for (int i = 0; i < c.stacked_convs; ++i) { - std::string p = c.cls_convs_prefix + "." + std::to_string(i) + ".conv"; - cc = ggml_relu(m, conv_2d(m[p.c_str()], cc, 1, 1)); - } - tensor cls = conv_2d(m[c.cls_head.c_str()], cc, 1, 1); // → num_base*num_classes 채널 + tensor cc = conv_tower(m, f, c, c.cls_convs_prefix, l); + tensor rr = conv_tower(m, f, c, c.reg_convs_prefix, l); - // reg 타워 - tensor rr = f; - for (int i = 0; i < c.stacked_convs; ++i) { - std::string p = c.reg_convs_prefix + "." + std::to_string(i) + ".conv"; - rr = ggml_relu(m, conv_2d(m[p.c_str()], rr, 1, 1)); - } - tensor box = conv_2d(m[c.reg_head.c_str()], rr, 1, 1); // → num_base*4 채널 + // RTMDetSepBNHead 는 출력 conv 도 레벨마다 따로다. + const std::string lv = c.per_level_heads + ? "." + std::to_string(l) + c.per_level_head_tail : ""; + tensor cls = conv_same(m, c.cls_head + lv, cc); + tensor box = conv_same(m, c.reg_head + lv, rr); + + box = apply_scale(m, box, c, l); + if (c.reg_max > 0) box = dfl_integral(m, box, c.reg_max); + + // FCOS 는 둘 중 하나만 쓴다(norm_on_bbox). GFL 은 거리에 stride 를 곱한다. + float stride = l < c.strides.size() ? c.strides[l] : 1.0f; + if (c.bbox_clamp_stride) box = ggml_scale(m, ggml_relu(m, box), stride); + else if (c.bbox_exp) box = ggml_exp(m, box); + else if (c.reg_max > 0) box = ggml_scale(m, box, stride); + // RTMDet 은 거리를 **stride 단위**로 낸다. 안 곱하면 레벨마다 8·16·32 배씩 작다 + // (실측 배율 0.117 / 0.059 / 0.030 — 정확히 1/8, 1/16, 1/32). + if (c.bbox_mul_stride) box = ggml_scale(m, box, stride); cls = contiguous_2d_to_cwhn(m, cls); box = contiguous_2d_to_cwhn(m, box); ggml_format_name(cls, "cls_%zu", l); ggml_format_name(box, "box_%zu", l); - cls_out.push_back(cls); - box_out.push_back(box); + out.cls.push_back(cls); + out.box.push_back(box); + + if (!c.centerness_head.empty()) { + tensor ctr = conv_same(m, c.centerness_head + lv, c.centerness_on_reg ? rr : cc); + if (c.ctr_tanh) ctr = ggml_tanh(m, ctr); + ctr = contiguous_2d_to_cwhn(m, ctr); + ggml_format_name(ctr, "ctr_%zu", l); + out.ctr.push_back(ctr); + } } } -// ── VFNet ───────────────────────────────────────────────────────────────────── -// runner 는 whcn 네이티브(가중치 {KW,KH,Cin,Cout}). head 내부도 contiguous_2d(=whcn) 에서 돈다 -// (anchor_head_forward 와 동일 규약). 채널축은 ne[2]. offset/deform 도 whcn 로 조립. +// ── DETR ────────────────────────────────────────────────────────────────────── +// 여기만 출력이 **공간 격자가 아니다**. query 100개가 이미지를 조회해 답을 하나씩 낸다. // -// GroupNorm + affine (γ/β). 코어 라이브러리(nn.cpp)에 group_norm 이 없어도 self-contained 하도록 -// 여기 로컬로 둔다(교수님 지시: mmdet 은 tools 에서 자족, 코어 무수정). 채널축: whcn=[1,1,C,1], cwhn=ne[0]. -static tensor group_norm_affine(model_ref m, tensor x, int groups, float eps = 1e-5f) { - x = ggml_group_norm(m, x, groups, eps); - bool whcn = !(m.flags & model_build_flag::cwhn); - auto rs = [&](tensor t) { return whcn ? ggml_reshape_4d(m, t, 1, 1, t->ne[0], 1) : t; }; - if (tensor weight = m.find("weight")) x = ggml_mul(m, x, rs(weight)); - if (tensor bias = m.find("bias")) x = ggml_add(m, x, rs(bias)); +// ⚠️ `split_qkv` 를 쓸 수 없다. 그건 q·k·v 가 **같은 입력**에서 나올 때 쓰는 것인데 +// DETR 은 (a) self-attn 조차 q·k 에만 위치 인코딩을 더하고 v 에는 안 더하며 +// (b) cross-attn 은 q 가 query, k·v 가 이미지다. 그래서 packed in_proj 를 직접 자른다. +// +// `nn.MultiheadAttention` 의 in_proj 는 [Wq; Wk; Wv] 를 세로로 쌓은 것이다 +// (ne = {embed, 3*embed}) → ne1 방향으로 3등분한다. +static tensor mha_proj(model_ref m, const std::string& p, tensor x, int idx, int n_heads) { + tensor w = m.weights((p + ".in_proj_weight").c_str()); + tensor b = m.find((p + ".in_proj_bias").c_str()); + const int64_t E = w->ne[0]; + tensor wi = ggml_cont(m, ggml_view_2d(m, w, E, E, w->nb[1], (size_t)idx * E * w->nb[1])); + tensor y = ggml_mul_mat(m, wi, x); + if (b) y = ggml_add(m, y, ggml_view_1d(m, b, E, (size_t)idx * E * b->nb[0])); + // attention() 규약: [head_dim, n_heads, n_tokens, batch] + return ggml_reshape_4d(m, y, E / n_heads, n_heads, y->ne[1], 1); +} + +// mmcv `MultiheadAttention`: q·k 에만 pos 를 더하고, **residual 은 pos 를 더하기 전 query** 다. +static tensor mha(model_ref m, const std::string& p, tensor q_in, tensor q_pos, + tensor kv_in, tensor kv_pos, anchor_head_cfg const& c, + tensor mask = nullptr) { + const std::string A = p + ".attn"; + tensor qs = q_pos ? ggml_add(m, q_in, q_pos) : q_in; + tensor ks = kv_pos ? ggml_add(m, kv_in, kv_pos) : kv_in; + tensor q = mha_proj(m, A, qs, 0, c.n_heads); + tensor k = mha_proj(m, A, ks, 1, c.n_heads); + tensor v = mha_proj(m, A, kv_in, 2, c.n_heads); // v 에는 pos 를 안 더한다 + const float scale = 1.0f / std::sqrt((float)(c.embed_dims / c.n_heads)); + tensor a = attention(m, q, k, v, mask, scale, m[(A + ".out_proj").c_str()]); + return ggml_add(m, q_in, a); // residual = pos 더하기 **전** +} + +// mmdet `MLP` — Linear 를 n 단 쌓고 **마지막 뺀 전부**에 ReLU 를 건다. +static tensor mlp(model_ref m, const std::string& p, tensor x, int n) { + for (int i = 0; i < n; ++i) { + x = linear(m[(p + ".layers." + std::to_string(i)).c_str()], x); + if (i + 1 < n) x = ggml_relu(m, x); + } return x; } -// ConvModule(conv 3x3 pad1, bias 없음 + GroupNorm + ReLU). -static tensor conv_gn_relu(model_ref m, tensor x, const std::string& p, int gn_groups) { - x = conv_2d(m[(p + ".conv").c_str()], x, 1, 1); - x = group_norm_affine(m[(p + ".gn").c_str()], x, gn_groups); - return ggml_relu(m, x); +// mmcv `FFN`: Linear → ReLU → Linear, residual 포함. +static tensor ffn(model_ref m, const std::string& p, tensor x) { + tensor y = linear(m[(p + ".layers.0.0").c_str()], x); + // ⚠️ 활성화가 ReLU 가 아닌 계열이 있다 — DAB-DETR 은 **PReLU**(기울기가 학습 파라미터). + // `ggml_leaky_relu` 는 기울기를 float 로만 받으므로 정의대로 조립한다: + // PReLU(x) = relu(x) - a * relu(-x) + // 가중치가 있으면 PReLU, 없으면 ReLU — 이름으로 계열을 가르지 않는다. + if (tensor a = m.find((p + ".layers.0.1.weight").c_str())) + y = ggml_sub(m, ggml_relu(m, y), ggml_mul(m, ggml_relu(m, ggml_neg(m, y)), a)); + else + y = ggml_relu(m, y); + y = linear(m[(p + ".layers.1").c_str()], y); + return ggml_add(m, x, y); +} + +// Conditional DETR 의 attention. `nn.MultiheadAttention` 이 아니라 mmdet 자체 +// `ConditionalAttention` 이라 q/k/v 투영이 **따로** 있다. +// self : q = qcontent(x) + qpos(qpos), k = kcontent(x) + kpos(qpos), v = v_proj(x) +// cross : q = [qcontent(x)(+qpos) ; qpos_sine(ref)] , k = [kcontent(mem) ; kpos(pos)] , +// v = v_proj(mem) ← q·k 만 head 축으로 **이어붙여** head_dim 이 2배 +// ⚠️ `qpos_proj` 는 **0번 층에만** 있다(mmdet 이 나머지 층에서 지운다). 그래서 is_first 로 가른다. +static tensor cond_head_split(model_ref m, tensor x, int n_heads) { + return ggml_reshape_4d(m, x, x->ne[0] / n_heads, n_heads, x->ne[1], 1); +} + +static tensor cond_attn(model_ref m, const std::string& p, tensor x, tensor q_pos, + tensor kv, tensor kv_pos, tensor ref_sine, bool is_first, + anchor_head_cfg const& c) { + const int H = c.n_heads; + const bool cross = (kv != x); + tensor qc = linear(m[(p + ".qcontent_proj").c_str()], x); + tensor kc = linear(m[(p + ".kcontent_proj").c_str()], kv); + tensor v = linear(m[(p + ".v_proj").c_str()], kv); + tensor kp = kv_pos ? linear(m[(p + ".kpos_proj").c_str()], kv_pos) : nullptr; + + tensor q, k; + if (!cross) { + tensor qp = linear(m[(p + ".qpos_proj").c_str()], q_pos); + q = cond_head_split(m, ggml_add(m, qc, qp), H); + k = cond_head_split(m, ggml_add(m, kc, kp), H); + } else { + // 0번 층만 질문에 위치를 더한다. 나머지 층은 내용만 쓰고 위치는 sine 쪽으로 간다. + // ⚠️ 0번 층은 q 와 **k 둘 다** 위치를 더한다. 그리고 k_pos 는 더한 뒤 **또 이어붙인다** — + // 한 번만 쓴다고 착각하기 쉽다(실측: k 에 안 더하면 층0 부터 rel_L1 0.105). + tensor qq = is_first ? ggml_add(m, qc, linear(m[(p + ".qpos_proj").c_str()], q_pos)) : qc; + tensor kk = is_first ? ggml_add(m, kc, kp) : kc; + tensor qs = linear(m[(p + ".qpos_sine_proj").c_str()], ref_sine); + // head 축(ne0)으로 이어붙인다 — torch 의 `cat(dim=3)` 과 같은 자리다. + q = ggml_concat(m, cond_head_split(m, qq, H), cond_head_split(m, qs, H), 0); + k = ggml_concat(m, cond_head_split(m, kk, H), cond_head_split(m, kp, H), 0); + } + tensor vv = cond_head_split(m, v, H); + const float scale = 1.0f / std::sqrt((float)(q->ne[0])); + tensor a = attention(m, q, k, vv, nullptr, scale, m[(p + ".out_proj").c_str()]); + return ggml_add(m, x, a); +} + +// encoder 는 세 계열이 같다(표준 MultiheadAttention · post-norm). 여기만 공유한다. +static tensor detr_encode(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, tensor& pos_out) { + if (feats.empty()) { fprintf(stderr, "detr: feature 가 없다\n"); abort(); } + // cwhn {C,W,H,1} → {C, W*H, 1}. mmdet 의 `flatten(2).permute(0,2,1)` 과 같은 순서다 + // (공간 index = h*W + w). pos_embed 도 프론트엔드가 같은 순서로 구워 둔다. + tensor f = feats[0]; + tensor x = ggml_reshape_3d(m, ggml_cont(m, f), f->ne[0], f->ne[1] * f->ne[2], 1); + tensor pos = m.weights("pos_embed"); + for (int i = 0; i < c.enc_layers; ++i) { + const std::string L = "encoder.layers." + std::to_string(i); + x = mha(m, L + ".self_attn", x, pos, x, pos, c); + x = layer_norm(m[(L + ".norms.0").c_str()], x); // post-norm 이다 + x = ffn(m, L + ".ffn", x); + x = layer_norm(m[(L + ".norms.1").c_str()], x); + } + pos_out = pos; + return x; +} + +static void detr_emit(head_outputs& out, int i, tensor cls, tensor box) { + ggml_format_name(cls, "cls_%d", i); + ggml_format_name(box, "box_%d", i); + out.cls.push_back(cls); + out.box.push_back(box); +} + +// 원조 DETR. decoder 도 표준 MultiheadAttention 이고 query 는 0 에서 시작한다. +void detr_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out) { + tensor pos = nullptr; + tensor mem = detr_encode(m, feats, c, pos); + tensor q_pos = m.weights("query_embedding.weight"); + tensor q = ggml_scale(m, q_pos, 0.0f); // zeros_like(query_pos) + + for (int i = 0; i < c.dec_layers; ++i) { + const std::string L = "decoder.layers." + std::to_string(i); + q = mha(m, L + ".self_attn", q, q_pos, q, q_pos, c); + q = layer_norm(m[(L + ".norms.0").c_str()], q); + q = mha(m, L + ".cross_attn", q, q_pos, mem, pos, c); + q = layer_norm(m[(L + ".norms.1").c_str()], q); + q = ffn(m, L + ".ffn", q); + q = layer_norm(m[(L + ".norms.2").c_str()], q); + + // 층마다 post_norm 을 거친 값이 head 로 간다(deep supervision). 마지막만이 아니다. + tensor h = layer_norm(m["decoder.post_norm"], q); + tensor cls = linear(m["bbox_head.fc_cls"], h); + // reg_ffn: Linear → ReLU → Linear. ⚠️ `add_identity=False` 라 residual 을 더하면 안 된다. + tensor r = linear(m["bbox_head.reg_ffn.layers.0.0"], h); + r = linear(m["bbox_head.reg_ffn.layers.1"], ggml_relu(m, r)); + tensor box = ggml_sigmoid(m, linear(m["bbox_head.fc_reg"], ggml_relu(m, r))); + detr_emit(out, i, cls, box); + } +} + +// Conditional DETR. 질문을 내용/위치로 쪼개 조회한다. 참조점은 query_embedding 에서만 +// 나오므로 **상수**다 — sine 인코딩과 inverse_sigmoid 를 프론트엔드가 구워 뒀다. +void conditional_detr_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out) { + tensor pos = nullptr; + tensor mem = detr_encode(m, feats, c, pos); + tensor q_pos = m.weights("query_embedding.weight"); + tensor q = ggml_scale(m, q_pos, 0.0f); + tensor ref_sine0 = m.weights("ref_sine_embed"); + + for (int i = 0; i < c.dec_layers; ++i) { + const std::string L = "decoder.layers." + std::to_string(i); + // 0번 층은 배율 1, 그 뒤로는 query 에서 뽑은 배율을 곱한다(MLP 2단). + tensor rs = ref_sine0; + if (i > 0) rs = ggml_mul(m, ref_sine0, mlp(m, "decoder.query_scale", q, 2)); + // self-attn 의 key 위치도 query_pos 다(`kpos_proj(query_pos)`). 널을 넘기면 죽는다. + q = cond_attn(m, L + ".self_attn", q, q_pos, q, q_pos, nullptr, i == 0, c); + q = layer_norm(m[(L + ".norms.0").c_str()], q); + q = cond_attn(m, L + ".cross_attn", q, q_pos, mem, pos, rs, i == 0, c); + q = layer_norm(m[(L + ".norms.1").c_str()], q); + q = ffn(m, L + ".ffn", q); + q = layer_norm(m[(L + ".norms.2").c_str()], q); + + tensor h = layer_norm(m["decoder.post_norm"], q); + tensor cls = linear(m["bbox_head.fc_cls"], h); + tensor r = linear(m["bbox_head.reg_ffn.layers.0.0"], h); + r = linear(m["bbox_head.reg_ffn.layers.1"], ggml_relu(m, r)); + tensor reg = linear(m["bbox_head.fc_reg"], ggml_relu(m, r)); + // 박스 앞 2채널에 참조점(inverse_sigmoid)을 더한다. 뒤 2채널이 0 인 상수를 구워 뒀다. + tensor box = ggml_sigmoid(m, ggml_add(m, reg, m.weights("ref_inv_pad"))); + detr_emit(out, i, cls, box); + } +} + +// mmdet `inverse_sigmoid`. 입력이 이미 [0,1] 이라 clamp 는 eps 하한만 의미가 있다. +// ⚠️ `ggml_clamp` 은 in-place 라 원본을 덮어쓴다 — 반드시 사본에 건다. +static tensor inv_sigmoid(model_ref m, tensor x, float eps) { + tensor a = ggml_clamp(m, ggml_cont(m, x), eps, 1.0f); + tensor b = ggml_clamp(m, ggml_cont(m, ggml_scale_bias(m, x, -1.0f, 1.0f)), eps, 1.0f); + return ggml_log(m, ggml_div(m, a, b)); +} + +// mmdet `coordinate_to_encoding` — 좌표(cx,cy,w,h)를 sine 인코딩한다. +// DAB 은 참조점이 **층마다 갱신**돼 상수로 못 굽는다. 그래서 여기서 계산한다. +// +// dim_t 는 인접한 두 칸이 같은 값이라, 결과는 짝수 칸 sin · 홀수 칸 cos 이 번갈아 놓인 꼴이다. +// 그 자리 선택을 view 로 하면 ne0 에 step 이 필요해 안 된다 → **0/1 마스크 상수**로 고른다. +// `2π/dim_t`·짝수 마스크·홀수 마스크 셋 다 프론트엔드가 mmdet 공식 그대로 구웠다. +static tensor coord_encode(model_ref m, tensor coord) { + tensor inv = m.weights("dab_inv_dim_t"); // {F} = 2π / dim_t + tensor ev = m.weights("dab_even"); + tensor od = m.weights("dab_odd"); + const int64_t F = inv->ne[0], nq = coord->ne[1]; + const int order[4] = {1, 0, 2, 3}; // mmdet 은 (y, x, w, h) 순으로 잇는다 + tensor outp = nullptr; + for (int i = 0; i < 4; ++i) { + tensor ci = ggml_cont(m, ggml_view_2d(m, coord, 1, nq, coord->nb[1], + (size_t)order[i] * coord->nb[0])); + // ggml 에 축 broadcast 가 없어 **외적**으로 {F, nq} 를 만든다. + tensor p = ggml_mul_mat(m, ggml_reshape_2d(m, inv, 1, F), + ggml_reshape_2d(m, ci, 1, nq)); + tensor e = ggml_add(m, ggml_mul(m, ggml_sin(m, p), ev), + ggml_mul(m, ggml_cos(m, p), od)); + outp = outp ? ggml_concat(m, outp, e, 0) : e; + } + return outp; // {4F, nq} +} + +// DAB-DETR. query 가 **4차원 앵커 박스**이고, 층마다 박스를 고쳐 다음 층 참조점으로 쓴다 +// (iterative refinement). 그래서 박스 분기가 decoder 안에 들어와 있다. +void dab_detr_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out) { + if (feats.empty()) { fprintf(stderr, "dab_detr: feature 가 없다\n"); abort(); } + // ⚠️ DAB 은 **encoder 도 다르다** — 층마다 위치 인코딩에 배율을 건다 + // (`query_pos * query_scale(query)`). 공용 encoder 를 쓰면 조용히 틀린다(실측 L1 3.84). + tensor f0 = feats[0]; + tensor mem = ggml_reshape_3d(m, ggml_cont(m, f0), f0->ne[0], f0->ne[1] * f0->ne[2], 1); + tensor pos = m.weights("pos_embed"); + for (int i = 0; i < c.enc_layers; ++i) { + const std::string L = "encoder.layers." + std::to_string(i); + tensor p = ggml_mul(m, pos, mlp(m, "encoder.query_scale", mem, 2)); + mem = mha(m, L + ".self_attn", mem, p, mem, p, c); + mem = layer_norm(m[(L + ".norms.0").c_str()], mem); + mem = ffn(m, L + ".ffn", mem); + mem = layer_norm(m[(L + ".norms.1").c_str()], mem); + } + const int E = c.embed_dims, H = E / 2; + tensor q = nullptr; + tensor ref = ggml_sigmoid(m, m.weights("query_embedding.weight")); // {4, nq} + const int64_t nq = ref->ne[1]; + + for (int i = 0; i < c.dec_layers; ++i) { + const std::string L = "decoder.layers." + std::to_string(i); + tensor rse = coord_encode(m, ref); // {2E, nq} + tensor q_pos = mlp(m, "decoder.ref_point_head", rse, 2); // 2E → E + if (q == nullptr) q = ggml_scale(m, q_pos, 0.0f); // output 은 0 에서 시작 + tensor rs = ggml_cont(m, ggml_view_2d(m, rse, E, nq, rse->nb[1], 0)); + if (i > 0) rs = ggml_mul(m, rs, mlp(m, "decoder.query_scale", q, 2)); + + // 박스 크기로 attention 을 변조한다. 앞 절반은 h, 뒤 절반은 w 로 나눈다. + tensor hw = ggml_sigmoid(m, mlp(m, "decoder.ref_anchor_head", q, 2)); // {2, nq} + auto row = [&](tensor t, int64_t k) { + return ggml_cont(m, ggml_view_2d(m, t, 1, nq, t->nb[1], (size_t)k * t->nb[0])); + }; + tensor lo = ggml_mul(m, ggml_cont(m, ggml_view_2d(m, rs, H, nq, rs->nb[1], 0)), + ggml_div(m, row(hw, 1), row(ref, 3))); + tensor hi = ggml_mul(m, ggml_cont(m, ggml_view_2d(m, rs, H, nq, rs->nb[1], + (size_t)H * rs->nb[0])), + ggml_div(m, row(hw, 0), row(ref, 2))); + rs = ggml_concat(m, lo, hi, 0); + + q = cond_attn(m, L + ".self_attn", q, q_pos, q, q_pos, nullptr, i == 0, c); + q = layer_norm(m[(L + ".norms.0").c_str()], q); + q = cond_attn(m, L + ".cross_attn", q, q_pos, mem, pos, rs, i == 0, c); + q = layer_norm(m[(L + ".norms.1").c_str()], q); + q = ffn(m, L + ".ffn", q); + q = layer_norm(m[(L + ".norms.2").c_str()], q); + + tensor rinv = inv_sigmoid(m, ref, 1e-3f); + // ⚠️ **다음 층 참조점은 post_norm 이전 출력으로 계산한다.** head 로 나가는 박스는 + // post_norm 을 거친 값으로 계산하고 — mmdet 이 두 값을 따로 쓴다. + // 한쪽으로 통일하면 층 0 은 맞고 층 1 부터 어긋난다(실측 L1 5.15). + ref = ggml_sigmoid(m, ggml_add(m, mlp(m, "bbox_head.fc_reg", q, 3), rinv)); + + tensor h = layer_norm(m["decoder.post_norm"], q); + tensor cls = linear(m["bbox_head.fc_cls"], h); + tensor box = ggml_sigmoid(m, ggml_add(m, mlp(m, "bbox_head.fc_reg", h, 3), rinv)); + detr_emit(out, i, cls, box); + } +} + +static tensor emax(model_ref m, tensor a, tensor b); // CornerNet 절에 정의 + +// ── Deformable attention (Deformable DETR · DINO · DDQ) ─────────────────────── +// query 가 **자기가 볼 지점을 예측**하고 그 **소수점 좌표**에서 값을 읽는다. +// ggml 에 grid_sample 이 없어 정의대로 조립한다 — 그래서 필요한 것이 둘이다: +// ① 실행 중에 만든 F32 좌표 → I32 인덱스 : `ggml_cast`(CPU 백엔드가 f32→i32 를 지원한다) +// ② 그 인덱스로 행 뽑기 : `ggml_get_rows` +// 나머지(floor·clamp·곱·합)는 이미 있는 op 이다. vendor ggml 은 안 고친다. +// +// 좌표 규약은 mmcv 의 CPU 구현(`F.grid_sample(align_corners=False)`)에서 나온다. +// grid = 2·s − 1 → x_pixel = s_x·W − 0.5 (범위 밖은 0 — padding_mode='zeros') + +// 정수 좌표가 [0, n) 안인가 → 1/0. floor 결과라 정수값이므로 clamp 두 번으로 만든다. +static tensor in_range(model_ref m, tensor v, int64_t n) { + tensor lo = ggml_clamp(m, ggml_cont(m, ggml_scale_bias(m, v, 1.0f, 1.0f)), 0.0f, 1.0f); + tensor hi = ggml_clamp(m, ggml_cont(m, ggml_scale_bias(m, v, -1.0f, (float)n)), 0.0f, 1.0f); + return ggml_mul(m, lo, hi); +} + +// {2 또는 2L, X} 에서 성분 하나를 {1,1,1,X} 로 뽑는다(ne1·ne2 는 broadcast 로 늘어난다). +static tensor comp(model_ref m, tensor r, int64_t k) { + const int64_t X = r->ne[1]; + return ggml_cont(m, ggml_view_4d(m, r, 1, 1, 1, X, r->nb[1], r->nb[1], r->nb[1], + (size_t)k * r->nb[0])); +} + +// mmcv `MultiScaleDeformableAttention`. +// query {E,N} · value {E,T} · ref {2,N}(디코더) 또는 {2L,T}(인코더, 레벨별) +static tensor msdeform(model_ref m, const std::string& p, tensor query, tensor q_pos, + tensor value_src, tensor ref, std::vector const& lv, + int n_heads, int n_points) { + const int64_t E = query->ne[0], N = query->ne[1], T = value_src->ne[1]; + const int L = (int)lv.size(), H = n_heads, P = n_points, D = E / H; + tensor q = q_pos ? ggml_add(m, query, q_pos) : query; // identity 는 더하기 **전** query + + // value 를 {D, T*H} 로 눕힌다 — 행 index = t + T*h 라 head 까지 한 번에 gather 한다. + tensor v = linear(m[(p + ".value_proj").c_str()], value_src); + v = ggml_cont(m, ggml_permute(m, ggml_reshape_4d(m, v, D, H, T, 1), 0, 2, 1, 3)); + v = ggml_reshape_2d(m, v, D, T * H); + tensor head_off = ggml_scale(m, ggml_arange(m, 0.0f, (float)H, 1.0f), (float)T); + head_off = ggml_reshape_4d(m, head_off, 1, 1, H, 1); + + // 채널 배치는 torch 의 view 순서 그대로다: ((h·L + l)·P + p)·2 + c + tensor off = linear(m[(p + ".sampling_offsets").c_str()], q); + off = ggml_reshape_3d(m, off, 2 * P * L, H, N); + tensor aw = linear(m[(p + ".attention_weights").c_str()], q); + aw = ggml_soft_max(m, ggml_reshape_3d(m, aw, L * P, H, N)); // (L·P) 축에 softmax + + tensor acc = nullptr; + for (int l = 0; l < L; ++l) { + // 이 레벨의 offset 만 잘라 {2,P,H,N} 으로 편다(ne0 안에서 l 이 바깥이라 연속이다). + tensor ol = ggml_cont(m, ggml_view_3d(m, off, 2 * P, H, N, off->nb[1], off->nb[2], + (size_t)(l * 2 * P) * off->nb[0])); + ol = ggml_reshape_4d(m, ol, 2, P, H, N); + tensor ox = ggml_cont(m, ggml_view_4d(m, ol, 1, P, H, N, ol->nb[1], ol->nb[2], + ol->nb[3], 0)); + tensor oy = ggml_cont(m, ggml_view_4d(m, ol, 1, P, H, N, ol->nb[1], ol->nb[2], + ol->nb[3], ol->nb[0])); + const int64_t W = lv[l].w, Hh = lv[l].h; + // s = ref + offset/(W,H) → 픽셀 좌표 = s·(W,H) − 0.5. 둘을 합치면 offset 은 그대로다. + // ref 가 4채널(cx,cy,w,h)이면 offset 을 **박스 크기에 비례**해 흩뿌린다: + // s = ref_xy + offset/num_points · ref_wh · 0.5 (2채널이면 s = ref + offset/(W,H)) + tensor px, py; + if (ref->ne[0] == 4) { + const float k = 0.5f / (float)P; + // ⚠️ `ggml_add(a,b)` 는 **b 를 a 모양으로** broadcast 한다 — 큰 쪽이 앞이어야 한다. + // 작은 쪽을 앞에 두면 `GGML_ASSERT(ggml_can_repeat(b, a))` 로 죽는다. + px = ggml_add(m, ggml_mul(m, ggml_scale(m, ox, k * (float)W), comp(m, ref, 2)), + ggml_scale(m, comp(m, ref, 0), (float)W)); + py = ggml_add(m, ggml_mul(m, ggml_scale(m, oy, k * (float)Hh), comp(m, ref, 3)), + ggml_scale(m, comp(m, ref, 1), (float)Hh)); + } else { + const int64_t rk = (ref->ne[0] == 2) ? 0 : 2 * l; + px = ggml_add(m, ox, ggml_scale(m, comp(m, ref, rk), (float)W)); + py = ggml_add(m, oy, ggml_scale(m, comp(m, ref, rk + 1), (float)Hh)); + } + px = ggml_scale_bias(m, px, 1.0f, -0.5f); + py = ggml_scale_bias(m, py, 1.0f, -0.5f); + tensor x0 = ggml_floor(m, px), y0 = ggml_floor(m, py); + tensor fx = ggml_sub(m, px, x0), fy = ggml_sub(m, py, y0); + + tensor lvl_acc = nullptr; + for (int corner = 0; corner < 4; ++corner) { + const float dx = (float)(corner & 1), dy = (float)(corner >> 1); + tensor xi = ggml_scale_bias(m, x0, 1.0f, dx); + tensor yi = ggml_scale_bias(m, y0, 1.0f, dy); + // 범위 밖은 0 으로 샘플링된다(zeros padding) → 가중치를 0 으로 만든다. + tensor ok = ggml_mul(m, in_range(m, xi, W), in_range(m, yi, Hh)); + tensor wx = dx > 0 ? fx : ggml_scale_bias(m, fx, -1.0f, 1.0f); + tensor wy = dy > 0 ? fy : ggml_scale_bias(m, fy, -1.0f, 1.0f); + tensor wgt = ggml_mul(m, ggml_mul(m, wx, wy), ok); + + tensor xc = ggml_clamp(m, ggml_cont(m, xi), 0.0f, (float)(W - 1)); + tensor yc = ggml_clamp(m, ggml_cont(m, yi), 0.0f, (float)(Hh - 1)); + tensor idx = ggml_add(m, ggml_add(m, xc, ggml_scale(m, yc, (float)W)), + ggml_scale_bias(m, head_off, 1.0f, (float)lv[l].off)); + idx = ggml_cast(m, ggml_reshape_1d(m, ggml_cont(m, idx), P * H * N), GGML_TYPE_I32); + tensor g = ggml_reshape_4d(m, ggml_get_rows(m, v, idx), D, P, H, N); + g = ggml_mul(m, g, wgt); + lvl_acc = lvl_acc ? ggml_add(m, lvl_acc, g) : g; + } + // 이 레벨의 attention weight 를 곱한다(softmax 는 이미 L·P 전체에 걸려 있다). + tensor al = ggml_cont(m, ggml_view_3d(m, aw, P, H, N, aw->nb[1], aw->nb[2], + (size_t)(l * P) * aw->nb[0])); + lvl_acc = ggml_mul(m, lvl_acc, ggml_reshape_4d(m, al, 1, P, H, N)); + acc = acc ? ggml_add(m, acc, lvl_acc) : lvl_acc; + } + // P 축을 더한다. ne1 을 줄이는 op 이 없어 조각을 더한다(P 는 4 라 싸다). + tensor sum = nullptr; + for (int pp = 0; pp < P; ++pp) { + tensor sl = ggml_cont(m, ggml_view_4d(m, acc, D, 1, H, N, acc->nb[1], acc->nb[2], + acc->nb[3], (size_t)pp * acc->nb[1])); + sum = sum ? ggml_add(m, sum, sl) : sl; + } + tensor o = ggml_reshape_2d(m, ggml_cont(m, sum), E, N); // 채널 = h·D + d + return ggml_add(m, query, linear(m[(p + ".output_proj").c_str()], o)); +} + +// 레벨별 feature 를 {E, HW} 로 펴서 잇고(mmdet `feat_flatten` 순서) deformable encoder 를 돈다. +// 다중 레벨 위치 인코딩(level_embed 포함)과 encoder 참조점은 상수라 구워 뒀다. +void ddq_levels(std::vector const& feats, std::vector& lv) { + int64_t off = 0; + for (tensor f : feats) { + lv.push_back({f->ne[1], f->ne[2], off}); + off += f->ne[1] * f->ne[2]; + } +} + +static tensor msd_encode(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, std::vector& lv) { + tensor mem = nullptr; + for (tensor f : feats) { + tensor t = ggml_reshape_2d(m, ggml_cont(m, f), f->ne[0], f->ne[1] * f->ne[2]); + mem = mem ? ggml_concat(m, mem, t, 1) : t; + } + ddq_levels(feats, lv); + tensor pos = m.weights("pos_embed"); + tensor enc_ref = m.weights("enc_ref"); + for (int i = 0; i < c.enc_layers; ++i) { + const std::string L = "encoder.layers." + std::to_string(i); + mem = msdeform(m, L + ".self_attn", mem, pos, mem, enc_ref, lv, c.n_heads, c.n_points); + mem = layer_norm(m[(L + ".norms.0").c_str()], mem); + mem = ffn(m, L + ".ffn", mem); + mem = layer_norm(m[(L + ".norms.1").c_str()], mem); + } + return mem; +} + +// `nn.Sequential(Linear, ReLU, Linear, ReLU, Linear)` — 인덱스가 0·2·4 다(MLP 와 다르다). +static tensor seq_mlp3(model_ref m, const std::string& p, tensor x) { + x = linear(m[(p + ".0").c_str()], x); + x = linear(m[(p + ".2").c_str()], ggml_relu(m, x)); + return linear(m[(p + ".4").c_str()], ggml_relu(m, x)); } +void deformable_detr_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out) { + std::vector lv; + tensor mem = msd_encode(m, feats, c, lv); + + // query_embedding 은 {2E, nq} 로 (query_pos, query) 가 붙어 있다. torch 의 split(dim=-1) + // 이라 ne0 앞쪽이 query_pos 다. + tensor qe = m.weights("query_embedding.weight"); + const int64_t E = c.embed_dims, nq = qe->ne[1]; + tensor q_pos = ggml_cont(m, ggml_view_2d(m, qe, E, nq, qe->nb[1], 0)); + tensor q = ggml_cont(m, ggml_view_2d(m, qe, E, nq, qe->nb[1], (size_t)E * qe->nb[0])); + tensor ref = ggml_sigmoid(m, linear(m["reference_points_fc"], q_pos)); // {2, nq} + + for (int i = 0; i < c.dec_layers; ++i) { + const std::string L = "decoder.layers." + std::to_string(i); + // self-attn 은 평범한 MultiheadAttention 이다(query 끼리). + q = mha(m, L + ".self_attn", q, q_pos, q, q_pos, c); + q = layer_norm(m[(L + ".norms.0").c_str()], q); + q = msdeform(m, L + ".cross_attn", q, q_pos, mem, ref, lv, c.n_heads, c.n_points); + q = layer_norm(m[(L + ".norms.1").c_str()], q); + q = ffn(m, L + ".ffn", q); + q = layer_norm(m[(L + ".norms.2").c_str()], q); + + // cls/reg branch 는 층마다 따로 있다(공유 config 면 같은 가중치가 복사돼 있다). + const std::string B = "bbox_head." + std::string("cls_branches.") + std::to_string(i); + const std::string R = "bbox_head.reg_branches." + std::to_string(i); + tensor cls = linear(m[B.c_str()], q); + tensor reg = seq_mlp3(m, R, q); + // reference 는 2차원이라 앞 2채널에만 더한다. {2,nq} 를 {4,nq} 자리에 맞춰 0 을 잇는다. + tensor rinv = inv_sigmoid(m, ref, 1e-5f); + rinv = ggml_concat(m, rinv, ggml_scale(m, rinv, 0.0f), 0); // {4, nq} + tensor box = ggml_sigmoid(m, ggml_add(m, reg, rinv)); + detr_emit(out, i, cls, box); + } +} + +void dino_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out) { + std::vector lv; + tensor mem = msd_encode(m, feats, c, lv); + const int64_t E = c.embed_dims; + const std::string HB = "bbox_head."; + const std::string enc_i = std::to_string(c.dec_layers); // 마지막+1 번째 분기가 encoder 용 + + // ── two-stage: encoder 출력에서 proposal 을 만들어 점수 상위 k 개를 고른다 ── + // 유효하지 않은 자리는 memory 를 0 으로 덮는다(`masked_fill`). 마스크는 상수라 구워 뒀다. + tensor om = ggml_mul(m, mem, m.weights("enc_valid")); + om = layer_norm(m["memory_trans_norm"], linear(m["memory_trans_fc"], om)); + tensor ecls = linear(m[(HB + "cls_branches." + enc_i).c_str()], om); // {ncls, T} + tensor ecoord = ggml_add(m, seq_mlp3(m, HB + "reg_branches." + enc_i, om), + m.weights("enc_proposals")); // {4, T} + + // 클래스축 최댓값 → 토큰 점수. ggml 에 max-reduce 가 없어 채널을 하나씩 접는다 + // (`emax` = (a+b+|a-b|)/2). ncls 는 80 이라 싸다. + const int64_t T = ecls->ne[1], ncls = ecls->ne[0]; + auto ch1 = [&](tensor t, int64_t k) { + return ggml_cont(m, ggml_view_2d(m, t, 1, T, t->nb[1], (size_t)k * t->nb[0])); + }; + tensor score = ch1(ecls, 0); + for (int64_t k = 1; k < ncls; ++k) score = emax(m, score, ch1(ecls, k)); + + // ⚠️ `ggml_top_k` 를 쓰면 안 된다 — **순서를 보장하지 않는다.** CPU 커널이 + // "order is not important" 라며 앞 두 개를 **일부러 뒤바꾼다.** + // 여기서는 query 순서가 곧 출력 순서라 정렬이 필요하다 → argsort 기반을 쓴다. + // (실측: 고른 900개 집합은 같은데 순서만 달라 rel_L1 0.489 → 정렬 후 3.3e-04) + tensor idx = ggml_argsort_top_k(m, ggml_reshape_1d(m, score, T), (int)c.num_queries); + tensor ref = ggml_sigmoid(m, ggml_get_rows(m, ecoord, idx)); // {4, nq} + + tensor q = m.weights("query_embedding.weight"); // {E, nq} + for (int i = 0; i < c.dec_layers; ++i) { + const std::string L = "decoder.layers." + std::to_string(i); + // 참조 박스의 sine 인코딩에서 query 위치를 만든다(층마다 다시 — ref 가 갱신되므로). + tensor q_pos = mlp(m, "decoder.ref_point_head", coord_encode(m, ref), 2); + q = mha(m, L + ".self_attn", q, q_pos, q, q_pos, c); + q = layer_norm(m[(L + ".norms.0").c_str()], q); + q = msdeform(m, L + ".cross_attn", q, q_pos, mem, ref, lv, c.n_heads, c.n_points); + q = layer_norm(m[(L + ".norms.1").c_str()], q); + q = ffn(m, L + ".ffn", q); + q = layer_norm(m[(L + ".norms.2").c_str()], q); + + const std::string R = HB + "reg_branches." + std::to_string(i); + tensor rinv = inv_sigmoid(m, ref, 1e-5f); + // ⚠️ DAB 과 같은 함정: **다음 층 참조점은 norm 이전 출력**으로, head 출력은 norm 이후로. + tensor next_ref = ggml_sigmoid(m, ggml_add(m, seq_mlp3(m, R, q), rinv)); + tensor h = layer_norm(m["decoder.norm"], q); + tensor cls = linear(m[(HB + "cls_branches." + std::to_string(i)).c_str()], h); + tensor box = ggml_sigmoid(m, ggml_add(m, seq_mlp3(m, R, h), rinv)); + detr_emit(out, i, cls, box); + ref = next_ref; + } +} + +// ── DDQ ─────────────────────────────────────────────────────────────────────── +// 패스 0: encoder 까지 돌고 proposal 점수/박스를 낸다. 여기서 멈추는 이유는 head.h 참조. +void ddq_encode_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, ddq_stage& s) { + std::vector lv; + s.mem = msd_encode(m, feats, c, lv); + const std::string HB = "bbox_head.", ei = std::to_string(c.dec_layers); + // 유효하지 않은 자리는 memory 를 0 으로 덮는다(mmdet `masked_fill`). 마스크는 상수다. + tensor om = ggml_mul(m, s.mem, m.weights("enc_valid")); + s.om = layer_norm(m["memory_trans_norm"], linear(m["memory_trans_fc"], om)); + s.ecls = linear(m[(HB + "cls_branches." + ei).c_str()], s.om); + s.ecoord = ggml_add(m, seq_mlp3(m, HB + "reg_branches." + ei, s.om), + m.weights("enc_proposals")); + // ⚠️ query 내용은 `output_memory` 가 아니라 **`query_map(memory)`** 에서 뽑는다. + // mmdet 주석: "we fuse the feature map embedding of distinct positions as the + // content part". om 을 쓰면 층 0 부터 어긋난다(실측 rel_L1 0.107). + s.qmap = linear(m["query_map"], s.mem); + for (tensor x : {s.mem, s.om, s.ecls, s.ecoord, s.qmap}) ggml_set_output(x); +} + +// 패스 i: decoder 한 층. query·ref·mask 는 호스트가 채운 그래프 입력이다. +void ddq_layer_forward(model_ref m, std::vector const& lv, + anchor_head_cfg const& c, int layer_index, + tensor mem, tensor query, tensor ref, tensor mask, ddq_stage& s) { + const std::string L = "decoder.layers." + std::to_string(layer_index); + const std::string HB = "bbox_head.", R = HB + "reg_branches." + std::to_string(layer_index); + + tensor q_pos = mlp(m, "decoder.ref_point_head", coord_encode(m, ref), 2); + tensor q = mha(m, L + ".self_attn", query, q_pos, query, q_pos, c, mask); + q = layer_norm(m[(L + ".norms.0").c_str()], q); + q = msdeform(m, L + ".cross_attn", q, q_pos, mem, ref, lv, c.n_heads, c.n_points); + q = layer_norm(m[(L + ".norms.1").c_str()], q); + q = ffn(m, L + ".ffn", q); + q = layer_norm(m[(L + ".norms.2").c_str()], q); + + tensor rinv = inv_sigmoid(m, ref, 1e-3f); + // DINO 와 같은 규약: 참조점 갱신은 norm **이전** 출력으로, head 출력은 norm 이후로. + s.ref = ggml_sigmoid(m, ggml_add(m, seq_mlp3(m, R, q), rinv)); + tensor h = layer_norm(m["decoder.norm"], q); + s.query = q; + s.cls = linear(m[(HB + "cls_branches." + std::to_string(layer_index)).c_str()], h); + s.box = ggml_sigmoid(m, ggml_add(m, seq_mlp3(m, R, h), rinv)); + for (tensor t : {s.query, s.ref, s.cls, s.box}) ggml_set_output(t); +} + +// ── CornerNet ───────────────────────────────────────────────────────────────── +// corner pooling 은 mmcv 에서 `torch.cummax` 다(방향에 따라 flip 을 앞뒤로 건다). +// ggml 에는 cummax 도, **원소별 max 조차** 없다. 둘 다 있는 것으로 만든다. + +// max(a,b) = (a + b + |a-b|) / 2. ggml_abs·add·sub·scale 만 쓴다. +static tensor emax(model_ref m, tensor a, tensor b) { + tensor s = ggml_add(m, a, b); + tensor d = ggml_abs(m, ggml_sub(m, a, b)); + return ggml_scale(m, ggml_add(m, s, d), 0.5f); +} + +// x 를 축 ax(0=W, 1=H)로 k 칸 민다. 밀려 들어오는 자리는 **0** 이다. +// fwd=true → out[i] = x[i-k] (i=N-k 이면 0) +// `ggml_view` 는 ne0 축에 step 을 못 주지만, pad 로 늘린 뒤 앞/뒤를 잘라내는 것은 된다. +static tensor shift_axis(model_ref m, tensor x, int ax, int k, bool fwd) { + const int64_t N = x->ne[ax]; + int lp[2] = {0, 0}, rp[2] = {0, 0}; + (fwd ? lp : rp)[ax] = k; + tensor p = ggml_pad_ext(m, ggml_cont(m, x), lp[0], rp[0], lp[1], rp[1], 0, 0, 0, 0); + int64_t ne[4] = {p->ne[0], p->ne[1], p->ne[2], p->ne[3]}; + ne[ax] = N; + const size_t off = fwd ? 0 : (size_t)k * p->nb[ax]; + return ggml_cont(m, ggml_view_4d(m, p, ne[0], ne[1], ne[2], ne[3], + p->nb[1], p->nb[2], p->nb[3], off)); +} + +// torch.cummax 를 **prefix/suffix max 스캔**으로 낸다 — k = 1,2,4,… 로 log2(N) 단계다 +// (128 이면 7단계). 각 단계는 "자기 자신과 k 칸 민 자기 자신의 max" 다. +// fwd=true → out[i] = max(x[0..i]) (mmcv 의 flip=False) +// fwd=false → out[i] = max(x[i..N-1]) (flip=True — flip 대신 반대로 민다) +// +// ⚠️ **밀려 들어오는 자리를 0 으로 채운다.** 0 이 max 의 항등원이려면 입력이 음수가 아니어야 +// 한다. CornerPool 입력은 `ConvModule(BN+ReLU)` 뒤라 항상 ≥0 이므로 성립한다. +// 음수가 들어오는 자리에 이 함수를 쓰면 **크래시 없이 값만 틀린다.** +static tensor cummax_axis(model_ref m, tensor x, int ax, bool fwd) { + for (int64_t k = 1; k < x->ne[ax]; k <<= 1) + x = emax(m, x, shift_axis(m, x, ax, (int)k, fwd)); + return x; +} + +// mmdet `BiCornerPool`. topleft=true 면 방향이 ['top','left'], false 면 ['bottom','right']. +// mmcv 의 cummax_dim_flip: bottom=(H,prefix) top=(H,suffix) right=(W,prefix) left=(W,suffix). +// whcn 이라 H=ne1, W=ne0 이다. +static tensor bi_corner_pool(model_ref m, tensor x, const std::string& p, bool topleft) { + tensor d1 = ggml_relu(m, conv_same(m, p + ".direction1_conv.conv", x)); + tensor d2 = ggml_relu(m, conv_same(m, p + ".direction2_conv.conv", x)); + tensor f1 = cummax_axis(m, d1, 1, !topleft); // top(suffix) / bottom(prefix) + tensor f2 = cummax_axis(m, d2, 0, !topleft); // left(suffix) / right(prefix) + // aftpool_conv 와 conv1 은 act_cfg=None 이다 — ReLU 를 붙이면 안 된다. + tensor a = conv_same(m, p + ".aftpool_conv.conv", ggml_add(m, f1, f2)); + tensor c1 = conv_same(m, p + ".conv1.conv", x); + tensor r = ggml_relu(m, ggml_add(m, a, c1)); + return ggml_relu(m, conv_same(m, p + ".conv2.conv", r)); +} + +// `_make_layers` = Sequential(ConvModule(3x3, norm 없음, ReLU), ConvModule(1x1, norm·act 없음)). +// **둘 다 ConvModule 이다** — 두 번째도 `.1.conv` 지 `.1` 이 아니다. +static tensor corner_pred(model_ref m, tensor x, const std::string& p) { + return conv_same(m, p + ".1.conv", ggml_relu(m, conv_same(m, p + ".0.conv", x))); +} + +void corner_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out) { + const std::string H = "bbox_head"; + out.extra.push_back({"brof", {}}); + if (c.corner_emb) { out.extra.push_back({"tlemb", {}}); out.extra.push_back({"bremb", {}}); } + if (c.corner_centripetal) { + out.extra.push_back({"tlgs", {}}); out.extra.push_back({"brgs", {}}); + out.extra.push_back({"tlcs", {}}); out.extra.push_back({"brcs", {}}); + } + + for (size_t l = 0; l < feats.size(); ++l) { + const std::string lv = "." + std::to_string(l); + tensor f = cwhn_to_contiguous_2d(m, feats[l]); + tensor tl = bi_corner_pool(m, f, H + ".tl_pool" + lv, true); + tensor br = bi_corner_pool(m, f, H + ".br_pool" + lv, false); + + auto emit = [&](std::vector& dst, const char* tag, tensor t) { + t = contiguous_2d_to_cwhn(m, t); + ggml_format_name(t, "%s_%zu", tag, l); + dst.push_back(t); + }; + emit(out.cls, "cls", corner_pred(m, tl, H + ".tl_heat" + lv)); + emit(out.box, "box", corner_pred(m, br, H + ".br_heat" + lv)); + emit(out.ctr, "ctr", corner_pred(m, tl, H + ".tl_off" + lv)); + emit(out.extra[0].second, "brof", corner_pred(m, br, H + ".br_off" + lv)); + if (c.corner_emb) { + emit(out.extra[1].second, "tlemb", corner_pred(m, tl, H + ".tl_emb" + lv)); + emit(out.extra[2].second, "bremb", corner_pred(m, br, H + ".br_emb" + lv)); + } + if (c.corner_centripetal) { + // guiding shift(2채널) → 1x1 conv(bias 없음) 로 DCN offset 18채널 → **DCNv1** + // (mask 없음) 으로 pool feature 를 재샘플링 → centripetal shift. + // ⚠️ mmcv DeformConv2d 의 offset 은 **기준 격자로부터의 델타**다. vfnet 처럼 + // base 를 빼면 안 된다 — 거기선 절대 위치를 만들어 놓고 뺐던 것이다. + auto branch = [&](tensor pool, const char* side, const char* tag_gs, const char* tag_cs, + std::vector& dgs, std::vector& dcs) { + const std::string P = H + "." + side; + tensor gs = corner_pred(m, pool, P + "_guiding_shift" + lv); + tensor off = conv_same(m, P + "_dcn_offset" + lv + ".conv", gs); + tensor fa = conv_2d_deform(m, pool, + m.find((P + "_feat_adaption" + lv + ".weight").c_str()), + off, nullptr, 1, 1); + emit(dgs, tag_gs, gs); + emit(dcs, tag_cs, corner_pred(m, fa, P + "_centripetal_shift" + lv)); + }; + branch(tl, "tl", "tlgs", "tlcs", out.extra[1].second, out.extra[3].second); + branch(br, "br", "brgs", "brcs", out.extra[2].second, out.extra[4].second); + } + } +} + +// ── YOLOv3 ──────────────────────────────────────────────────────────────────── +// 레벨마다 bridge conv 하나(ConvModule: conv+BN+LeakyReLU, BN 은 프론트엔드가 접었다) 뒤에 +// 1x1 예측 conv 가 붙는다. 타워 반복이 없어 `cls_convs_prefix.<레벨>.conv` 로 한 번만 태운다. +void yolo_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out) { + for (size_t l = 0; l < feats.size(); ++l) { + const std::string lv = "." + std::to_string(l); + tensor x = cwhn_to_contiguous_2d(m, feats[l]); + x = head_act(m, conv_same(m, c.cls_convs_prefix + lv + ".conv", x), c); + tensor pred = contiguous_2d_to_cwhn(m, conv_same(m, c.cls_head + lv, x)); + ggml_format_name(pred, "cls_%zu", l); + out.cls.push_back(pred); + } +} + +// ── YOLOF ───────────────────────────────────────────────────────────────────── +// 다른 계열과 두 군데가 다르다. +// ① cls/reg 타워 깊이가 서로 다르다(num_cls_convs=2, num_reg_convs=4). +// ② objectness 를 따로 예측해 **로그공간에서 cls 에 흡수**시킨다. 그래서 세 번째 출력이 없다. +// +// normalized_cls = cls + obj - log(1 + e^cls + e^obj) +// +// mmdet 은 `view(N, -1, num_classes, H, W)` 로 채널을 [anchor][class] 로 가른 뒤 obj 를 +// 클래스축으로 broadcast 한다. 여기서도 같은 순서로 가른다 — 채널 index = a*nc + c 이므로 +// **nc 를 안쪽 축**에 두어야 한다. +void yolof_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out) { + const int nreg = c.reg_stacked_convs > 0 ? c.reg_stacked_convs : c.stacked_convs; + for (size_t l = 0; l < feats.size(); ++l) { + tensor f = cwhn_to_contiguous_2d(m, feats[l]); + tensor cc = conv_tower(m, f, c, c.cls_convs_prefix, l, c.stacked_convs); + tensor rr = conv_tower(m, f, c, c.reg_convs_prefix, l, nreg); + + tensor cls = conv_same(m, c.cls_head, cc); + tensor box = conv_same(m, c.reg_head, rr); + tensor obj = conv_same(m, c.centerness_head, rr); // object_pred (na 채널) + + const int64_t W = cls->ne[0], H = cls->ne[1]; + const int64_t nc = c.num_classes, na = c.num_base; + tensor c4 = ggml_reshape_4d(m, ggml_cont(m, cls), W, H, nc, na); + tensor o4 = ggml_reshape_4d(m, ggml_cont(m, obj), W, H, 1, na); + // ggml_add 는 src1 을 broadcast 한다(ne2: 1 → nc). clamp(max=INF) 는 항등이라 생략. + tensor sum = ggml_add(m, c4, o4); + tensor exp = ggml_add(m, ggml_exp(m, c4), ggml_exp(m, o4)); + // 1 + e 를 만들 상수 텐서가 없으므로 scale_bias(a*1 + 1) 로 낸다. + tensor nrm = ggml_sub(m, sum, ggml_log(m, ggml_scale_bias(m, exp, 1.0f, 1.0f))); + cls = ggml_reshape_4d(m, ggml_cont(m, nrm), W, H, nc * na, 1); + + cls = contiguous_2d_to_cwhn(m, cls); + box = contiguous_2d_to_cwhn(m, box); + ggml_format_name(cls, "cls_%zu", l); + ggml_format_name(box, "box_%zu", l); + out.cls.push_back(cls); + out.box.push_back(box); + } +} + +// 종전 진입점 — RetinaNet PoC 러너가 쓰던 이름을 유지한다(centerness 없는 계열 전용). +void anchor_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, + std::vector& cls_out, std::vector& box_out) { + head_outputs o; + tower_head_forward(m, feats, c, o); + cls_out = std::move(o.cls); + box_out = std::move(o.box); +} + +// ── VFNet ───────────────────────────────────────────────────────────────────── +// runner 는 whcn 네이티브(가중치 {KW,KH,Cin,Cout}). head 내부도 contiguous_2d(=whcn) 에서 돈다 +// (anchor_head_forward 와 동일 규약). 채널축은 ne[2]. offset/deform 도 whcn 로 조립. +// // star_dcn_offset (mmdet VFNetHead) 의 그래프 버전 (whcn: bbox_pred {W,H,4,1}, 채널=ne[2]). // ch: 0=x1 1=y1 2=x2 3=y2. /stride 후 18ch offset 조립 → base 뺌. // 추론 모드라 gradient_mul 무관(detach==pred). ggml deform 커널은 knl-pad(=base)를 내부에서 @@ -95,6 +1013,7 @@ void vfnet_head_forward(model_ref m, std::vector const& feats, vfnet_head_cfg const& c, tensor dcn_base, std::vector& cls_out, std::vector& box_out) { const std::string H = c.prefix; + tensor base = dcn_base_whcn(m, dcn_base); for (size_t l = 0; l < feats.size(); ++l) { const float stride = c.strides[l]; const float reg_denom = c.reg_denoms[l]; @@ -107,19 +1026,24 @@ void vfnet_head_forward(model_ref m, std::vector const& feats, reg_feat = conv_gn_relu(m, reg_feat, H + ".reg_convs." + std::to_string(i), c.gn_groups); } - // 초기 bbox_pred = exp(vfnet_reg(vfnet_reg_conv(reg_feat))) · reg_denom (scale=1.0) + // 초기 bbox_pred = exp(scale · vfnet_reg(vfnet_reg_conv(reg_feat))) · reg_denom + // ⚠️ `scale` 은 **학습되는 값**이다. 1.0 으로 박으면 랜덤 초기화에서만 맞는다. tensor reg_init = conv_gn_relu(m, reg_feat, H + ".vfnet_reg_conv", c.gn_groups); tensor bbox_pred = conv_2d(m[(H + ".vfnet_reg").c_str()], reg_init, 1, 1); + if (tensor s = m.find((H + ".scales." + std::to_string(l) + ".scale").c_str())) + bbox_pred = ggml_mul(m, bbox_pred, s); bbox_pred = ggml_scale(m, ggml_exp(m, bbox_pred), reg_denom); // star deformable offset - tensor offset = star_dcn_offset(m, bbox_pred, stride, dcn_base); + tensor offset = star_dcn_offset(m, bbox_pred, stride, base); // refine: reg_feat 를 deform conv → exp·bbox_pred tensor w_reg_dcn = m[(H + ".vfnet_reg_refine_dconv").c_str()].weights("weight"); tensor reg_ref = ggml_relu(m, conv_2d_deform(m, reg_feat, w_reg_dcn, offset, nullptr, 1, 1)); tensor box = conv_2d(m[(H + ".vfnet_reg_refine").c_str()], reg_ref, 1, 1); - box = ggml_mul(m, ggml_exp(m, box), bbox_pred); // scale_refine=1.0 + if (tensor s = m.find((H + ".scales_refine." + std::to_string(l) + ".scale").c_str())) + box = ggml_mul(m, box, s); + box = ggml_mul(m, ggml_exp(m, box), bbox_pred); // iou-aware cls: cls_feat 를 같은 offset 으로 deform conv → cls conv tensor w_cls_dcn = m[(H + ".vfnet_cls_dconv").c_str()].weights("weight"); @@ -135,4 +1059,309 @@ void vfnet_head_forward(model_ref m, std::vector const& feats, } } +// ── RepPoints ───────────────────────────────────────────────────────────────── +// ne2(채널) 축 reduce. ggml 의 mean/sum 은 ne0 만 줄이므로 축을 데려왔다 되돌린다. +static tensor reduce_mean_ne2(model_ref m, tensor x) { + tensor t = ggml_cont(m, ggml_permute(m, x, 2, 1, 0, 3)); // ne0 ↔ ne2 + t = ggml_mean(m, t); + return ggml_cont(m, ggml_permute(m, t, 2, 1, 0, 3)); +} + +// 표본표준편차(torch.std 기본 = unbiased). 평균을 빼도 std 는 안 변하므로 원본에 바로 건다. +static tensor reduce_std_ne2(model_ref m, tensor x, int n) { + tensor d = ggml_sub(m, x, reduce_mean_ne2(m, x)); // {W,H,1,1} broadcast + tensor var = reduce_mean_ne2(m, ggml_sqr(m, d)); + return ggml_sqrt(m, ggml_scale(m, var, (float)n / (float)(n - 1))); +} + +// 점 집합 → bbox (transform_method='moment'). +// pts 는 {W,H,2·num_points,1}, 채널이 y_first 로 [y0,x0,y1,x1,…] 이다 — y 와 x 가 **한 칸씩 +// 번갈아** 놓여 있어서 stride 2 짜리 view 로 갈라낸다(연속 복사 없이). +static tensor points2bbox_moment(model_ref m, tensor pts, tensor moment_transfer, + int num_points) { + const int64_t W = pts->ne[0], H = pts->ne[1], N = pts->ne[3]; + auto every2 = [&](int start) { // 채널 start, start+2, … → {W,H,num_points,1} + return ggml_cont(m, ggml_view_4d(m, pts, W, H, num_points, N, + pts->nb[1], 2 * pts->nb[2], pts->nb[3], + (size_t)start * pts->nb[2])); + }; + tensor py = every2(0), px = every2(1); + + tensor y_mean = reduce_mean_ne2(m, py), x_mean = reduce_mean_ne2(m, px); + tensor y_std = reduce_std_ne2(m, py, num_points); + tensor x_std = reduce_std_ne2(m, px, num_points); + + // 추론에서는 moment_mul 항이 상쇄된다(detach 가 값 그대로) → moment_transfer 자체. + auto elem = [&](int i) { + return ggml_view_1d(m, moment_transfer, 1, (size_t)i * moment_transfer->nb[0]); + }; + tensor half_w = ggml_mul(m, x_std, ggml_exp(m, ggml_cont(m, elem(0)))); + tensor half_h = ggml_mul(m, y_std, ggml_exp(m, ggml_cont(m, elem(1)))); + + tensor b = ggml_sub(m, x_mean, half_w); // x1 + b = ggml_concat(m, b, ggml_sub(m, y_mean, half_h), 2); // y1 + b = ggml_concat(m, b, ggml_add(m, x_mean, half_w), 2); // x2 + b = ggml_concat(m, b, ggml_add(m, y_mean, half_h), 2); // y2 + return b; +} + +void reppoints_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, tensor dcn_base, + head_outputs& out) { + const std::string H = "bbox_head"; + // dcn_base 원소 수(18)에서 점 개수를 얻는다 — cfg 에 또 하나 두지 않는다. + const int num_points = (int)ggml_nelements(dcn_base) / 2; + tensor base = dcn_base_whcn(m, dcn_base); + + for (size_t l = 0; l < feats.size(); ++l) { + tensor f = cwhn_to_contiguous_2d(m, feats[l]); + tensor cls_feat = conv_tower(m, f, c, c.cls_convs_prefix, l); + tensor pts_feat = conv_tower(m, f, c, c.reg_convs_prefix, l); + + // ① 점을 놓는다. center_init=True 라 points_init = 0 이므로 더할 게 없다. + tensor pts_init = conv_same(m, H + ".reppoints_pts_init_out", + ggml_relu(m, conv_same(m, H + ".reppoints_pts_init_conv", + pts_feat))); + + // ② 그 점을 offset 삼아 deform conv. 추론에서는 gradient_mul 항이 상쇄된다. + // ggml deform 커널이 기준 격자를 내부에서 더하므로 mmdet 과 같이 base 를 뺀 값을 준다. + tensor dcn_offset = ggml_sub(m, pts_init, base); + + tensor w_cls = m[(H + ".reppoints_cls_conv").c_str()].weights("weight"); + tensor cls = conv_same(m, H + ".reppoints_cls_out", + ggml_relu(m, conv_2d_deform(m, cls_feat, w_cls, dcn_offset, + nullptr, 1, 1))); + + tensor w_ref = m[(H + ".reppoints_pts_refine_conv").c_str()].weights("weight"); + tensor pts_ref = conv_same(m, H + ".reppoints_pts_refine_out", + ggml_relu(m, conv_2d_deform(m, pts_feat, w_ref, dcn_offset, + nullptr, 1, 1))); + pts_ref = ggml_add(m, pts_ref, pts_init); // refine 은 init 에 대한 잔차다 + + tensor box = points2bbox_moment(m, pts_ref, m.weights((H + ".moment_transfer").c_str()), + num_points); + + cls = contiguous_2d_to_cwhn(m, cls); + box = contiguous_2d_to_cwhn(m, box); + ggml_format_name(cls, "cls_%zu", l); + ggml_format_name(box, "box_%zu", l); + out.cls.push_back(cls); + out.box.push_back(box); + } +} + +// ── TOOD ────────────────────────────────────────────────────────────────────── +// ne2(채널) 축을 [off, off+n) 구간만 잘라낸다. +static tensor slice_ne2(model_ref m, tensor x, int64_t off, int64_t n) { + return ggml_view_4d(m, x, x->ne[0], x->ne[1], n, x->ne[3], + x->nb[1], x->nb[2], x->nb[3], (size_t)off * x->nb[2]); +} + +// TaskDecomposition. mmdet 은 layer attention 을 **conv 가중치에 먼저 곱해** 동적 커널을 +// 만들고 bmm 한다(메모리 절약). 여기서는 분배법칙으로 뒤집는다 — +// +// Σ_{s,i} (a_s · W[o, s,i]) · x[s,i] = Σ_{s,i} W[o, s,i] · (a_s · x[s,i]) +// +// 즉 **입력 청크에 먼저 곱하면** 커널이 정적으로 남아 평범한 1×1 conv 가 된다. +// 값은 같고, 동적 가중치를 그래프에 만들 필요가 없다. +static tensor task_decomp(model_ref m, tensor feat, tensor avg_feat, + const std::string& p, int stacked, int chunk_c, int gn_groups) { + // layer_attention: conv1x1 → relu → conv1x1 → sigmoid (→ {1,1,stacked,1}) + tensor a = conv_2d(m[(p + ".layer_attention.0").c_str()], avg_feat, 1, 0); + a = conv_2d(m[(p + ".layer_attention.2").c_str()], ggml_relu(m, a), 1, 0); + a = ggml_sigmoid(m, a); + + tensor scaled = nullptr; + for (int s = 0; s < stacked; ++s) { + tensor as = ggml_view_4d(m, a, 1, 1, 1, 1, a->nb[1], a->nb[2], a->nb[3], + (size_t)s * a->nb[2]); // 스칼라 a_s + tensor xs = ggml_mul(m, ggml_cont(m, slice_ne2(m, feat, (int64_t)s * chunk_c, chunk_c)), + ggml_cont(m, as)); + scaled = scaled ? ggml_concat(m, scaled, xs, 2) : xs; + } + tensor y = conv_2d(m[(p + ".reduction_conv.conv").c_str()], scaled, 1, 0); + y = group_norm_affine(m[(p + ".reduction_conv.gn").c_str()], y, gn_groups); + return ggml_relu(m, y); +} + +// 격자 중심점(stride 로 나눈 좌표계). **오프셋을 0.5 로 박으면 안 된다** — TOOD 는 +// ATSSHead 를 상속해 AnchorGenerator(center_offset=0)를 쓰므로 중심이 정확히 i 다. +// FCOS 계열의 MlvlPointGenerator 만 i+0.5 다. 0.5 를 잘못 넣으면 전 레벨에서 박스가 +// 반 칸씩 밀린다(실측: cpp−ref 가 어느 레벨에서나 +0.47). +static tensor grid_centers(model_ref m, int64_t W, int64_t H, bool along_x, float off) { + tensor v = ggml_arange(m, off, (float)(along_x ? W : H) + off, 1.0f); + if (along_x) return ggml_repeat_4d(m, ggml_reshape_4d(m, v, W, 1, 1, 1), W, H, 1, 1); + return ggml_repeat_4d(m, ggml_reshape_4d(m, v, 1, H, 1, 1), W, H, 1, 1); +} + +void tood_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out) { + const std::string H = "bbox_head"; + const int S = c.stacked_convs, C = c.feat_channels; + + for (size_t l = 0; l < feats.size(); ++l) { + tensor x = cwhn_to_contiguous_2d(m, feats[l]); + const int64_t W = x->ne[0], FH = x->ne[1]; + + // ① inter conv 스택. 각 단 출력을 전부 모아 이어붙인다(= task interactive feature). + tensor feat = nullptr; + for (int i = 0; i < S; ++i) { + x = conv_gn_relu(m, x, c.cls_convs_prefix + "." + std::to_string(i), c.gn_groups); + feat = feat ? ggml_concat(m, feat, x, 2) : x; + } + tensor avg = ggml_pool_2d(m, feat, GGML_OP_POOL_AVG, W, FH, W, FH, 0, 0); + + // ② task decomposition — cls 와 reg 가 같은 feat 에서 서로 다른 관점을 뽑는다. + tensor cls_feat = task_decomp(m, feat, avg, H + ".cls_decomp", S, C, c.gn_groups); + tensor reg_feat = task_decomp(m, feat, avg, H + ".reg_decomp", S, C, c.gn_groups); + + // ③ cls: 분류 로짓과 정렬 확률의 기하평균. sqrt(σ(a)·σ(b)) 로 그대로 편다 + // (mmdet 은 autograd.Function 이지만 그건 역전파용 최적화다). + tensor logits = conv_same(m, H + ".tood_cls", cls_feat); + tensor prob = conv_2d(m[(H + ".cls_prob_module.0").c_str()], feat, 1, 0); + prob = conv_same(m, H + ".cls_prob_module.2", ggml_relu(m, prob)); + tensor cls = ggml_sqrt(m, ggml_mul(m, ggml_sigmoid(m, logits), ggml_sigmoid(m, prob))); + + // ④ reg: 거리 → 격자 좌표계 bbox. distance2bbox 를 그래프로 편다. + tensor dist = ggml_exp(m, conv_same(m, H + ".tood_reg", reg_feat)); + dist = apply_scale(m, dist, c, l); + tensor px = grid_centers(m, W, FH, true, c.center_offset), + py = grid_centers(m, W, FH, false, c.center_offset); + auto d = [&](int i) { return ggml_cont(m, slice_ne2(m, dist, i, 1)); }; + tensor rb = ggml_sub(m, px, d(0)); // x1 = px - l + rb = ggml_concat(m, rb, ggml_sub(m, py, d(1)), 2); // y1 = py - t + rb = ggml_concat(m, rb, ggml_add(m, px, d(2)), 2); // x2 = px + r + rb = ggml_concat(m, rb, ggml_add(m, py, d(3)), 2); // y2 = py + b + + // ⑤ deform sampling — 1×1 ones 커널 · 채널별 offset. 실질은 채널마다 다른 위치에서 + // bbox 를 다시 읽는 bilinear 재샘플이다. groups=4 를 커널이 못 받으므로 + // (vendored ggml 은 안 고친다) **채널 4갈래로 쪼개 groups=1 로 부르고 잇는다.** + tensor off = conv_2d(m[(H + ".reg_offset_module.0").c_str()], feat, 1, 0); + off = conv_same(m, H + ".reg_offset_module.2", ggml_relu(m, off)); + tensor one = ggml_reshape_4d(m, ggml_arange(m, 1.0f, 2.0f, 1.0f), 1, 1, 1, 1); + tensor ones = one; // 1×1 ones 커널 {1,1,1,1} + tensor bp = nullptr; + for (int i = 0; i < 4; ++i) { + tensor xi = ggml_cont(m, slice_ne2(m, rb, i, 1)); + tensor oi = ggml_cont(m, slice_ne2(m, off, 2 * i, 2)); + tensor yi = conv_2d_deform(m, xi, ones, oi, nullptr, 1, 0); + bp = bp ? ggml_concat(m, bp, yi, 2) : yi; + } + + // ⑥ 재샘플이 좌상단/우하단을 뒤집어 놓은 자리는 원래 박스로 되돌린다. + // ggml 에 where/비교가 없어 step 으로 마스크를 만든다. + auto b = [&](int i) { return ggml_cont(m, slice_ne2(m, bp, i, 1)); }; + tensor bad = ggml_add(m, ggml_step(m, ggml_sub(m, b(0), b(2))), + ggml_step(m, ggml_sub(m, b(1), b(3)))); + bad = ggml_clamp(m, bad, 0.0f, 1.0f); // 둘 중 하나라도 참 → 1 + tensor keep = ggml_sub(m, ggml_repeat_4d(m, one, W, FH, 1, 1), bad); // 1 - bad + tensor box = ggml_add(m, ggml_mul(m, bp, keep), ggml_mul(m, rb, bad)); + + // 박스는 **격자 좌표계 그대로** 낸다 — mmdet 의 head 출력과 같은 규약이다. + // stride 곱하기는 디코드(predict_by_feat)의 몫이라 여기서 하면 두 번 곱해진다. + + cls = contiguous_2d_to_cwhn(m, cls); + box = contiguous_2d_to_cwhn(m, box); + ggml_format_name(cls, "cls_%zu", l); + ggml_format_name(box, "box_%zu", l); + out.cls.push_back(cls); + out.box.push_back(box); + } +} + +// ── CenterNet ───────────────────────────────────────────────────────────────── +// 앵커도 타워도 없다. 세 갈래가 각각 `Conv3x3 → ReLU → Conv1x1` 이고, 이름은 +// `heatmap_head.0/.2` 처럼 nn.Sequential 인덱스다(1 번은 ReLU 라 가중치가 없다). +static tensor centernet_branch(model_ref m, tensor x, const std::string& p) { + return conv_same(m, p + ".2", ggml_relu(m, conv_same(m, p + ".0", x))); +} + +void centernet_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out) { + const std::string H = "bbox_head"; + for (size_t l = 0; l < feats.size(); ++l) { + tensor f = cwhn_to_contiguous_2d(m, feats[l]); + // heatmap 만 sigmoid 를 **여기서** 건다 — mmdet 의 forward_single 이 그렇다. + tensor cls = ggml_sigmoid(m, centernet_branch(m, f, H + ".heatmap_head")); + tensor box = centernet_branch(m, f, H + ".wh_head"); + tensor ctr = centernet_branch(m, f, H + ".offset_head"); + + cls = contiguous_2d_to_cwhn(m, cls); + box = contiguous_2d_to_cwhn(m, box); + ctr = contiguous_2d_to_cwhn(m, ctr); + ggml_format_name(cls, "cls_%zu", l); + ggml_format_name(box, "box_%zu", l); + ggml_format_name(ctr, "ctr_%zu", l); + out.cls.push_back(cls); + out.box.push_back(box); + out.ctr.push_back(ctr); + } +} + +// ── 계열 분기 ───────────────────────────────────────────────────────────────── +void mmdet_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, tensor dcn_base, head_outputs& out) { + switch (c.kind) { + case head_kind::anchor: + case head_kind::fcos: + case head_kind::gfl: + tower_head_forward(m, feats, c, out); + break; + case head_kind::yolof: + yolof_head_forward(m, feats, c, out); + break; + case head_kind::yolo: + yolo_head_forward(m, feats, c, out); + break; + case head_kind::centernet: + centernet_head_forward(m, feats, c, out); + break; + case head_kind::cornernet: + corner_head_forward(m, feats, c, out); + break; + case head_kind::detr: + detr_head_forward(m, feats, c, out); + break; + case head_kind::conditional_detr: + conditional_detr_head_forward(m, feats, c, out); + break; + case head_kind::dab_detr: + dab_detr_head_forward(m, feats, c, out); + break; + case head_kind::deformable_detr: + deformable_detr_head_forward(m, feats, c, out); + break; + case head_kind::dino: + dino_head_forward(m, feats, c, out); + break; + case head_kind::reppoints: + reppoints_head_forward(m, feats, c, dcn_base, out); + break; + case head_kind::tood: + tood_head_forward(m, feats, c, out); + break; + case head_kind::vfnet: { + // vfnet 은 자기 cfg 를 따로 갖는다 — 공통 필드에서 채워 넘긴다. + vfnet_head_cfg v; + v.stacked_convs = c.stacked_convs; + v.feat_channels = c.feat_channels; + v.num_classes = c.num_classes; + v.gn_groups = c.gn_groups; + v.strides = c.strides; + // ⚠️ reg_denom 은 stride 에서 못 얻는다. mmdet 은 regress_ranges 상한을 쓰고 + // **마지막 레벨만 그 두 배**다([64,128,256,512,1024]). 프론트엔드가 준 값을 쓴다. + v.reg_denoms = c.reg_denoms; + vfnet_head_forward(m, feats, v, dcn_base, out.cls, out.box); + break; + } + // ⚠️ switch 에 case 를 안 넣으면 **아무 일도 안 하고 빈 출력**이 나간다 — centernet 이 + // 그랬고, 러너가 그 빈 벡터를 인덱싱해 SIGSEGV 로 죽었다. 원인 지점에서 말하게 한다. + if (out.cls.empty()) { + fprintf(stderr, "mmdet_head_forward: head_kind %d 에 조립기가 없다 (head.cpp switch 확인)\n", + (int)c.kind); + abort(); + } + } +} + } // namespace visp diff --git a/tools/detect/head.h b/tools/detect/head.h index 82f6426..84bda4f 100755 --- a/tools/detect/head.h +++ b/tools/detect/head.h @@ -5,15 +5,44 @@ #pragma once #include "visp/ml.h" +#include "visp/postproc.h" // det_params #include #include namespace visp { -// anchor head(RetinaNet/ATSS 등) 의 head-conv 구조. 값은 .postproc.json 에서 읽는다. +// 어느 조립기를 쓸지. 계열이 늘어도 러너는 안 바뀐다 — mmdet_head_forward 가 갈라준다. +enum class head_kind { + anchor, // RetinaNet · ATSS · PAA — cls/reg(+centerness) 타워, Delta 디코드 + fcos, // + bbox 에 scale·clamp·stride (anchor-free 거리 디코드) + gfl, // + DFL(분포 → 거리 기댓값). cls 가 품질까지 겸한다 + vfnet, // star deformable refine (전용 함수) + reppoints, // 점 집합 → bbox (전용 함수) + tood, // task decomposition + deform sampling (전용 함수) + centernet, // heatmap / wh / offset 세 갈래 (앵커 없음, 단일 레벨) + yolof, // 단일 레벨 · 암묵 objectness (cls 와 obj 를 로그공간에서 합친다) + yolo, // YOLOv3 — 레벨당 bridge conv 하나 + 1x1 예측 conv, 출력이 **한 갈래**다 + cornernet, // 좌상/우하 코너 — corner pooling(=cummax) + 레벨당 6갈래 출력 + detr, // transformer encoder+decoder. 출력이 픽셀이 아니라 **query** 다 + conditional_detr, // + 질문을 내용/위치로 쪼개 조회 (참조점은 상수) + dab_detr, // + query 가 4D 앵커, 층마다 박스를 고쳐 참조점을 갱신 + deformable_detr, // multi-scale deformable attention (예측한 소수점 좌표에서 샘플링) + dino, // + two-stage: encoder 출력에서 상위 k 개를 골라 query 로 쓴다 + ddq, // + 층마다 NMS 로 중복 query 를 가린다 → **한 그래프로 못 돈다** +}; + +// head-conv 구조. 값은 mmdet_to_pt.py 가 .postproc.h 에 굽는다(런타임 파일 없음). +// +// 계열별로 함수를 따로 쓰지 않고 **플래그로 가른다** — RetinaNet/ATSS/PAA/FCOS/GFL 은 +// "타워 2개 + 출력 conv 몇 개"라는 뼈대가 같고 곁가지만 다르기 때문이다. +// 뼈대가 아예 다른 계열(vfnet/reppoints/tood)만 전용 함수를 갖는다. struct anchor_head_cfg { + head_kind kind = head_kind::anchor; int stacked_convs = 4; // 공유 cls/reg conv 타워 깊이 + // reg 타워만 깊이가 다른 계열이 있다(YOLOF: num_cls_convs=2, num_reg_convs=4). + // 0 이면 stacked_convs 와 같다고 본다. + int reg_stacked_convs = 0; int feat_channels = 256; int num_base = 9; // location 당 anchor 수 int num_classes = 80; // cls_out_channels @@ -21,7 +50,82 @@ struct anchor_head_cfg { std::string reg_convs_prefix = "bbox_head.reg_convs"; std::string cls_head = "bbox_head.retina_cls"; // 최종 cls conv std::string reg_head = "bbox_head.retina_reg"; // 최종 reg conv - bool head_has_norm = false; // 타워에 norm(GN 등) — 이번 PoC(RetinaNet)=false + // 분기 conv 앞에 **하나만** 있는 공유 conv(+ReLU). RPNHead 의 `rpn_conv` 가 그렇다. + // 컨테이너(ModuleList/Sequential)가 아니라 맨 Conv2d 라 타워 탐지에 안 걸린다. + // 빼먹으면 256→256 이라 shape 이 안 변해 **조용히 틀린다**(rpn 실측 L1 5.56). + std::string pre_conv; + bool head_has_norm = false; // 타워가 ConvModule+GN (ATSS·FCOS·GFL) 인가 + int gn_groups = 32; // 그때 GroupNorm 그룹 수 + // 타워 가중치를 레벨끼리 공유하지 않는 계열(RetinaSepBNHead — efficientnet·nas_fpn). + // 이름이 `cls_convs.<레벨>.<단>` 으로 한 겹 깊어진다. + bool per_level_towers = false; + // 출력 conv 까지 레벨별인 계열(RTMDetSepBNHead). `rtm_cls.<레벨>` 처럼 한 겹 깊다. + bool per_level_heads = false; + // 그 레벨 안에서 또 한 겹 들어가는 계열(SSD: `cls_convs.<레벨>.<단>` 이고 타워가 없다). + std::string per_level_head_tail; + + // 세 번째 출력 갈래(centerness). 비어 있으면 안 만든다 — RetinaNet 이 그렇다. + std::string centerness_head; // 예: "bbox_head.atss_centerness" + bool centerness_on_reg = true; // reg_feat 에서 뽑나(false 면 cls_feat) + // YOLACT 의 mask coefficient 갈래는 tanh 를 거친다(품질 점수가 아니라 계수라서). + bool ctr_tanh = false; + // CornerHead 의 embedding 갈래(그룹핑용). config 로 끌 수 있어 플래그로 받는다. + bool corner_emb = false; + // CentripetalNet: emb 대신 guiding/centripetal shift 갈래 + DCNv1 feature adaption. + bool corner_centripetal = false; + + // ── DETR ────────────────────────────────────────────────────────────── + // 출력이 공간 격자가 아니다 — decoder 층마다 (query, ch) 하나씩 나온다. + int embed_dims = 256; + int n_heads = 8; + int enc_layers = 6; + int dec_layers = 6; + int n_points = 4; // deformable: query 하나가 레벨당 볼 지점 수 + int num_queries = 300; // DETR 계열의 query 개수(two-stage 는 topk 개수이기도 하다) + float ddq_iou_thr = 0.8f; // DDQ 의 distinct query selection NMS 임계 + + // 레벨별 learnable scale (mmcv Scale). 비면 안 곱한다. + // **값은 GGUF 에서 이름으로 읽는다** — 체크포인트를 바꿔도 헤더를 다시 굽지 않아도 된다. + std::string scales_prefix; // 예: "bbox_head.scales" + + // bbox_pred 후처리. FCOS 만 쓴다(norm_on_bbox 에 따라 둘 중 하나). + bool bbox_exp = false; // norm_on_bbox=false → exp(pred) + bool bbox_clamp_stride = false; // norm_on_bbox=true → clamp(pred,0)*stride + bool bbox_mul_stride = false; // RTMDet: 거리 예측이 stride 단위라 그대로 곱한다 + + // 타워의 활성화. mmdet ConvModule 의 기본은 ReLU 지만 RTMDet 은 SiLU 다 + // (`act_cfg=dict(type='SiLU')`). 값이 조용히 작아질 뿐 shape 는 같아서 안 드러난다. + bool head_silu = false; + // LeakyReLU 를 쓰는 계열(YOLOv3, slope 0.1). >0 이면 이 기울기를 쓴다(silu 보다 우선). + float head_leaky = 0.0f; + + // GFL 의 DFL. >0 이면 reg_head 출력이 4*(reg_max+1) 채널이고, + // 조립기가 여기서 기댓값을 내 **4채널 거리**로 바꾼다(디코드 쪽을 단순하게 두려고). + int reg_max = 0; + + std::vector strides; // 레벨별 stride (bbox_clamp_stride / gfl 에 필요) + std::vector reg_denoms; // VFNet 의 레벨별 정규화 범위(regress_range 상한) + float center_offset = 0.0f; // 격자 중심 위치. anchor 계열 0, point 계열 0.5 +}; + +// 조립 결과. ctr 은 centerness 갈래가 없는 계열이면 빈 벡터다. +struct head_outputs { + std::vector cls, box, ctr; + // 갈래가 셋을 넘는 계열용(CornerHead: tl/br × heat·emb·off = 6). 이름은 덤프 파일명이 된다. + // ⚠️ 셋에 억지로 우겨넣으면 안 잰 갈래가 생기고 "통과"가 거짓말이 된다. + std::vector>> extra; +}; + +// Everything the runner needs to run one detector. +// Once an architecture is fixed these are constants, so mmdet_to_pt.py emits them as +// mmdet_params() in .postproc.h and they are compiled into the runner. Nothing is read +// at run time. +struct mmdet_cfg { + anchor_head_cfg head; + det_params det; + float img_mean[3] = {0, 0, 0}; + float img_std[3] = {1, 1, 1}; + bool to_rgb = false; }; // FPN features(레벨별, cwhn) → 레벨별 raw cls_score / bbox_pred(cwhn). @@ -53,4 +157,85 @@ void vfnet_head_forward(model_ref m, std::vector const& feats, vfnet_head_cfg const& c, tensor dcn_base, std::vector& cls_out, std::vector& box_out); +// RepPoints. 점 9개를 예측하고 그 집합을 bbox 로 바꾼다(transform_method='moment'). +// · dcn_base : {18,1,1,1} — dcn_base_offset(고정 3x3 그리드). 런타임이 값을 채운다. +// 두 단계다: init 로 점을 놓고, 그 점을 offset 삼아 deform conv 로 refine 한다. +void reppoints_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, tensor dcn_base, + head_outputs& out); + +// TOOD. 하나의 inter conv 스택에서 cls/reg 를 갈라내고(task decomposition), +// reg 는 예측한 offset 으로 자기 자신을 다시 샘플링한다(deform sampling). +void tood_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out); + +// DETR. encoder 6층(이미지끼리) + decoder 6층(query 100개) + cls/reg 분기. +// 출력은 **decoder 층마다 하나씩** 이라 out.cls/out.box 의 index 는 FPN 레벨이 아니라 층이다. +// · cls[i] : ne={num_classes+1, num_queries, 1, 1} +// · box[i] : ne={4, num_queries, 1, 1} (sigmoid 까지) +void detr_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out); +void conditional_detr_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out); +void dab_detr_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out); +// Deformable DETR. feature 를 레벨별로 펴서 이어붙이고, encoder·decoder 모두 +// deformable attention 을 쓴다. 출력은 decoder 층마다 (cls, box). +void deformable_detr_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out); +// DINO. encoder 출력에서 proposal 을 만들어 **점수 상위 num_queries 개**를 query 의 초기 +// 참조 박스로 쓴다(two-stage). 참조점이 4채널이라 deformable 샘플링이 박스 크기에 비례한다. +void dino_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out); + +// ── DDQ ─────────────────────────────────────────────────────────────────────── +// **이 계열만 한 그래프로 못 돈다.** decoder 층마다 `batched_nms` 로 중복 query 를 골라내 +// 다음 층의 self-attention 마스크를 만드는데(추론 경로에서도), NMS 는 반복 횟수와 분기가 +// 실행 중 값에 따라 정해지는 탐욕 알고리즘이라 정적 그래프로 표현할 수 없다. +// → 조립기를 **토막**으로 나누고 러너가 패스 사이에서 호스트 NMS 를 돌린다. +// 패스 0 : ddq_encode_forward → 호스트 NMS → query 900개 +// 패스 i : ddq_layer_forward → 호스트 NMS → 다음 층 마스크 +struct ddq_stage { + tensor mem = nullptr, om = nullptr, ecls = nullptr, ecoord = nullptr; // 패스 0 + tensor qmap = nullptr; // query 내용의 원천 — `query_map(memory)` 다(om 아님) + tensor query = nullptr, ref = nullptr, cls = nullptr, box = nullptr; // 패스 i +}; +void ddq_encode_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, ddq_stage& s); +// mask 는 **가산 마스크**(가릴 자리 −inf, 나머지 0)다 — 호스트가 NMS 결과로 채운다. +// 패스 1~6 에는 백본 그래프가 없으므로 feature 대신 **레벨 치수**만 받는다. +struct msd_level { int64_t w, h, off; }; +void ddq_levels(std::vector const& feats, std::vector& lv); +void ddq_layer_forward(model_ref m, std::vector const& lv, + anchor_head_cfg const& c, int layer_index, + tensor mem, tensor query, tensor ref, tensor mask, ddq_stage& s); + +// CornerNet. 코너 풀링(= `torch.cummax`)을 쓴다. ggml 에 cummax 도 원소별 max 도 없어 +// **항등식과 shift 로 만든다** — 자세한 건 head.cpp 의 emax/cummax_axis 주석. +// 출력이 레벨당 6갈래라 out.extra 를 쓴다(cls=tl_heat, box=br_heat, ctr=tl_off, extra=나머지). +void corner_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out); + +// YOLOv3. 레벨마다 `bridge conv(3x3, BN+LeakyReLU) → 1x1 예측 conv` 하나뿐이고, +// 출력이 **한 갈래**다 — 채널이 `na*(5+num_classes)` 로 box/obj/cls 를 다 담는다. +// 그래서 out.box 는 비어 있고 out.cls 에 pred_map 이 그대로 들어간다. +void yolo_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out); + +// YOLOF. 단일 레벨(dilated encoder)이고, objectness 를 따로 예측해 cls 와 **로그공간에서** +// 합친다 — `cls + obj - log(1 + e^cls + e^obj)`. 그래서 obj 는 별도 출력으로 안 나가고 +// cls 안으로 흡수된다(out.ctr 이 비어 있다). +void yolof_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out); + +// CenterNet. 앵커도 타워도 없다 — 단일 레벨에 `Conv3x3 → ReLU → Conv1x1` 세 갈래다. +// · out.cls = heatmap(num_classes, **sigmoid 까지**) · out.box = wh(2) · out.ctr = offset(2) +void centernet_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, head_outputs& out); + +// 계열 무관 진입점. `c.kind` 로 갈라 위 조립기 중 하나를 부른다. +// dcn_base 는 vfnet/reppoints 만 쓴다 — 다른 계열이면 nullptr 로 둔다. +void mmdet_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, tensor dcn_base, head_outputs& out); + } // namespace visp diff --git a/tools/frontend/mmdet/append_head_weights.py b/tools/frontend/mmdet/append_head_weights.py new file mode 100644 index 0000000..cb88acd --- /dev/null +++ b/tools/frontend/mmdet/append_head_weights.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""append_head_weights.py — g2c 가 구운 GGUF 에 **그래프 밖 가중치**를 덧붙인다. + +왜 g2c 가 아니라 여기인가 +------------------------ +g2c 는 `torch.jit.trace` 가 만든 그래프의 노드에서만 가중치를 모은다. 그건 옳다 — +"그래프에 쓰인 것 = 실행에 필요한 것" 이 보통은 성립하기 때문이다. + +그 등식이 **이 경로에서만** 깨진다. head 를 C++ 부품으로 조립하기로 했으므로 +`MMDetBackbone.forward` 는 backbone+neck 까지만 돈다. 그래서 `bbox_head`(그리고 DETR +계열이면 `encoder`/`decoder`/`query_embedding`)가 그래프에 안 들어가고, GGUF 에도 안 실린다. +C++ 부품이 이름으로 찾다가 `tensor not found: bbox_head...` 로 죽는다. + +이걸 g2c 안에서 처리하면 컴파일러에 "그래프 밖 가중치" 라는 개념이 하나 늘어난다. +**mmdet 을 아는 쪽이 자기 것을 덧붙이는 편이 맞다** — g2c 는 원본 그대로 두고, 산출물은 +여전히 파일 하나다(러너도 안 바뀐다). + +무엇을 싣나 +---------- +`state_dict` 를 통째로 붓지 않는다. fold 로 흡수된 BN weight/bias 까지 되살아나는데, +`.cpp` 가 안 쓰는 데다 **fold 이전 값**이라 conv 와 어긋난 채 파일에 남는다. +`MMDetBackbone.head_weight_prefixes` 가 선언한 prefix 아래만, 그리고 GGUF 에 아직 없는 +것만 싣는다. + +사용: + python append_head_weights.py bb.pt out/Fam.gguf +""" +import os +import sys + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +try: + # 검증 하네스가 두는 import 우회(mmpretrain 의 transformers 충돌). 없으면 그냥 넘어간다. + import _stub # noqa: F401 +except Exception: + pass + +# BN 의 running 통계는 학습 파라미터가 아니다. conv 로 접혔거나 추론에 안 쓴다. +_SKIP_SUFFIX = ("num_batches_tracked", "running_mean", "running_var") + + +def _gguf(): + """vendor 된 gguf-py 를 import. vision.cpp 안에서만 경로를 찾는다.""" + here = os.path.dirname(os.path.abspath(__file__)) + v = os.path.abspath(os.path.join(here, "..", "..", "..")) # …/vision.cpp + sys.path.insert(0, os.path.join(v, "depend", "llama", "gguf-py")) + import gguf + return gguf + + +# g2c 와 **같은 축약 규칙**을 쓴다 — 따로 구현하면 언젠가 갈린다. +# 이 파일은 `/vision.cpp/tools/frontend/mmdet/` 에 있으므로 네 단계 위가 g2c 루트다. +_G2C_ROOT = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', '..', '..')) +if _G2C_ROOT not in sys.path: + sys.path.insert(0, _G2C_ROOT) +from shared.compile.tensor_names import short_name # noqa: E402 + + +def append(pt_path: str, gguf_path: str, prefixes=None) -> int: + """`gguf_path` 를 제자리에서 다시 써서 선언된 가중치를 덧붙인다. 추가된 개수를 돌려준다.""" + import torch + + gguf = _gguf() + model = torch.load(pt_path, weights_only=False) + if prefixes is None: + prefixes = tuple(getattr(model, "head_weight_prefixes", ()) or ()) + if not prefixes: + return 0 + + reader = gguf.GGUFReader(gguf_path) + arch = "fam" + fld = reader.fields.get("general.architecture") + if fld is not None: + arch = bytes(fld.parts[-1]).decode() + # ⚠️ reader 의 `.data` 는 **torch 순서**(shape 가 ne 의 역순)로 나온다 — + # writer 가 다시 뒤집으므로 그대로 넘기면 왕복이 맞는다. + existing = [(t.name, np.asarray(t.data)) for t in reader.tensors] + have = {n for n, _ in existing} + + extra = [] + for key, tensor in model.state_dict().items(): + if key in have or not key.startswith(prefixes) or key.endswith(_SKIP_SUFFIX): + continue + arr = tensor.detach().cpu().numpy() + # 정수 버퍼를 fp16 으로 구우면 값이 뭉개진다 — 부동소수만 내린다. + if arr.dtype.kind == "f": + arr = arr.astype(np.float16) + # ⚠️ **여기도 이름을 줄여야 한다.** g2c 가 굽는 쪽(`generate_gguf`)만 줄이면 + # 나중에 덧붙는 head 가중치가 64자를 넘어 로드가 거부된다 + # (`mask_head.mask_feature_head.convs_all_levels.…` 실측 64자). + extra.append((short_name(key), arr)) + if not extra: + return 0 + + tmp = gguf_path + ".tmp" + writer = gguf.GGUFWriter(tmp, arch=arch) + writer.add_architecture() + for name, arr in existing + extra: + writer.add_tensor(name, arr) + writer.write_header_to_file() + writer.write_kv_data_to_file() + writer.write_tensors_to_file() + writer.close() + os.replace(tmp, gguf_path) + return len(extra) + + +def main(argv=None): + a = (argv if argv is not None else sys.argv[1:]) + if len(a) < 2: + print(__doc__) + return 2 + n = append(a[0], a[1]) + print(f" · 그래프 밖 가중치 {n} 개 추가 → {a[1]}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/frontend/mmdet/frcnn_to_pt.py b/tools/frontend/mmdet/frcnn_to_pt.py index 046f151..0aa8ae2 100755 --- a/tools/frontend/mmdet/frcnn_to_pt.py +++ b/tools/frontend/mmdet/frcnn_to_pt.py @@ -19,6 +19,7 @@ import torch sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import mmdet_compat # noqa: E402 from frcnn_wrap import FRCNN_SubA, FRCNN_SubB, MaskRCNN_SubC, frcnn_cfg # noqa: E402,F401 (피클: frcnn_wrap) @@ -30,17 +31,33 @@ def main(argv=None): ap.add_argument("--size", type=int, default=800) a = ap.parse_args(argv) from mmdet.apis import init_detector - det = init_detector(a.config, a.checkpoint, device="cpu").eval() + # `.eval()` 체이닝 금지 — train() 을 오버라이드한 계열에서 None 이 돌아온다. + det = init_detector(a.config, a.checkpoint, device="cpu") + det.eval() os.makedirs(a.out, exist_ok=True) + # 클래스가 `frcnn_wrap` 이름으로 절여진다 → 로더가 import 할 수 있게 같이 둔다. + for f in mmdet_compat.install_loader_modules(a.out, "frcnn_wrap"): + print(f" → loader module: {f}") torch.save(FRCNN_SubA(det).eval(), f"{a.out}/FRCNN_SubA.pt") # 이미지 → 14 출력 - torch.save(FRCNN_SubB(det).eval(), f"{a.out}/FRCNN_SubB.pt") # roi_feat → cls/bbox + from frcnn_wrap import num_bbox_stages + ns = num_bbox_stages(det) + if ns == 1: + torch.save(FRCNN_SubB(det).eval(), f"{a.out}/FRCNN_SubB.pt") # roi_feat → cls/bbox + else: + # 캐스케이드: 단계마다 따로 낸다. 러너가 사이에 호스트 RoIAlign 을 끼워 돈다. + for i in range(ns): + torch.save(FRCNN_SubB(det, i).eval(), f"{a.out}/FRCNN_SubB{i}.pt") cfg = frcnn_cfg(det, a.size) if cfg.get("has_mask"): torch.save(MaskRCNN_SubC(det).eval(), f"{a.out}/MaskRCNN_SubC.pt") # mask_feat → mask_logits # feat_hw (P2-P6) — 러너 CWHN flat 해석용. dummy forward 로 크기 취득. + # ⚠️ neck 이 **없는** 계열이 있다(C4 계열 TridentNet). `FRCNN_SubA` 와 같은 규약으로 + # 건너뛴다 — 여기만 빼먹으면 `frcnn.json` 이 안 나와 "two-stage 아님" 으로 오분류된다. with torch.no_grad(): - feats = det.neck(det.backbone(torch.zeros(1, 3, a.size, a.size))) + feats = det.backbone(torch.zeros(1, 3, a.size, a.size)) + if getattr(det, "neck", None) is not None: + feats = det.neck(feats) cfg["feat_hw"] = [[int(f.shape[2]), int(f.shape[3])] for f in feats] json.dump(cfg, open(f"{a.out}/frcnn.json", "w"), indent=2) print(f" → {a.out}/FRCNN_SubA.pt, FRCNN_SubB.pt, frcnn.json (feat_hw={cfg['feat_hw']})") diff --git a/tools/frontend/mmdet/frcnn_wrap.py b/tools/frontend/mmdet/frcnn_wrap.py index b7afa51..98504ce 100755 --- a/tools/frontend/mmdet/frcnn_wrap.py +++ b/tools/frontend/mmdet/frcnn_wrap.py @@ -7,34 +7,96 @@ roi_align/detect_roi). 두 wrapper 모두 flat tuple 반환 → g2c 가 out_0.. 다출력으로 컴파일. 피클 모듈명 = frcnn_wrap (self-contained). g2c 코어 무관 — mmdet 만 import. """ +import mmdet_compat # noqa: F401 (import 만으로 호환 패치가 걸린다) import torch import torch.nn as nn +def _n_roi_levels(det): + """RoIAlign 이 실제로 쓰는 레벨 수. **4 로 박으면 안 된다.** + + FPN 계열은 P2..P6 중 앞 4개를 쓰지만, C4 계열(TridentNet 등)은 neck 없이 + 백본 마지막 단계 **하나**만 쓴다. extractor 의 `featmap_strides` 가 정답이다. + """ + ext = getattr(getattr(det, "roi_head", None), "bbox_roi_extractor", None) + if isinstance(ext, nn.ModuleList): + ext = ext[0] + strides = getattr(ext, "featmap_strides", None) + return len(strides) if strides else 4 + + class FRCNN_SubA(nn.Module): - """이미지 → (P2,P3,P4,P5, rpn_cls×5, rpn_bbox×5) = 14 출력. P2-P5=RoIAlign 입력, rpn=5레벨(P2-P6).""" + """이미지 → (RoI 레벨 feats, rpn_cls×L, rpn_bbox×L). + + FPN 계열은 (P2..P5, rpn×5, rpn×5) = 14 출력. C4 계열(TridentNet)은 neck 이 없어 + (C4, rpn_cls, rpn_bbox) = 3 출력이다. + """ def __init__(self, det): super().__init__() self.backbone = det.backbone - self.neck = det.neck + # ⚠️ **neck 이 항상 있다고 보면 안 된다.** C4 계열(TridentFasterRCNN)은 FPN 없이 + # 백본 C4 를 그대로 쓰고 `shared_head`(ResLayer)가 C5 역할을 한다. + self.neck = getattr(det, "neck", None) self.rpn_head = det.rpn_head + self.n_roi = _n_roi_levels(det) def forward(self, x): - feats = self.neck(self.backbone(x)) # tuple len 5: P2..P6 - rpn_cls, rpn_bbox = self.rpn_head(feats) # (list5, list5) - return tuple(feats[:4]) + tuple(rpn_cls) + tuple(rpn_bbox) + feats = self.backbone(x) + if getattr(self, "neck", None) is not None: + feats = self.neck(feats) # tuple len 5: P2..P6 + rpn_cls, rpn_bbox = self.rpn_head(feats) # (listL, listL) + return tuple(feats[:getattr(self, "n_roi", 4)]) + tuple(rpn_cls) + tuple(rpn_bbox) class FRCNN_SubB(nn.Module): - """RoIAlign feat (N,256,7,7) → (cls_score (N,81), bbox_pred (N,320)).""" - def __init__(self, det): + """RoIAlign feat (N,256,7,7) → (cls_score (N,81), bbox_pred (N,320)). + + 캐스케이드 계열(`CascadeRoIHead`·HTC·SCNet)은 `bbox_head` 가 **ModuleList** 다 — + 단계마다 박스를 정제하고 다음 단계에서 그 박스로 RoIAlign 을 다시 한다. + 그래서 단계 하나만 담고, 루프는 러너가 돈다(호스트 RoIAlign 이 사이에 끼므로 + 한 그래프로 못 돈다 — two-stage 와 같은 이유). + """ + def __init__(self, det, stage=None): super().__init__() - self.bbox_head = det.roi_head.bbox_head + bh = det.roi_head.bbox_head + self.bbox_head = bh if stage is None else bh[stage] + # ⚠️ C4 계열은 RoIAlign 과 bbox_head 사이에 **`shared_head`(ResLayer=C5)** 가 있다. + # 빼먹으면 채널이 안 맞아 `mat1 and mat2 shapes cannot be multiplied + # (N×1024 and 2048×81)` 로 죽는다(tridentnet 실측). mmdet `_bbox_forward` 와 + # 같은 순서다: extractor → shared_head → bbox_head. + self.shared_head = (det.roi_head.shared_head + if getattr(det.roi_head, "with_shared_head", False) else None) + # ⚠️ Double-Head R-CNN 은 head 가 **입력 두 개**를 받는다(`forward(x_cls, x_reg)`). + # 같은 proposal 로 RoIAlign 을 두 번 하는데, 회귀용은 상자를 `reg_roi_scale_factor` + # 배(1.3) 키워 넓은 맥락을 본다. g2c 그래프는 입력이 하나뿐이라 + # **배치로 이어붙여** 받고 여기서 가른다(앞 절반=cls, 뒤 절반=reg). + self.two_in = getattr(det.roi_head, "reg_roi_scale_factor", None) is not None + # ⚠️ 가르는 지점을 **export 시점 상수**로 박는다. `roi_feat.shape[0] // 2` 로 두면 + # trace 가 실행 중 값으로 봐서 렌더러가 시작 인덱스를 모르고 **offset 0 으로 + # 폴백**한다 — 두 슬라이스가 같은 자리를 읽어 확대판 RoI 가 무시된다(L1 0.740). + # proposal 개수는 `test_cfg.rpn.max_per_img` 로 이미 정해져 있다. + self.n_half = int(det.test_cfg.rpn.max_per_img) if self.two_in else 0 def forward(self, roi_feat): + # ⚠️ **`self.<새속성>` 을 그냥 읽지 마라.** 이 클래스는 `torch.save` 로 통째 절여지는데, + # 저장은 **오래 사는 워커 프로세스**(스윕 시작 때 import 한 옛 코드)가 하고 + # 불러오기는 **새 서브프로세스**(새 코드)가 한다. 코드를 고치는 순간 그 사이가 + # 갈라져 `AttributeError: no attribute 'two_in'` 이 난다(스윕 도중 실측). + # 새 속성은 항상 `getattr(..., 기본값)` 으로 읽는다. + if getattr(self, "shared_head", None) is not None: + roi_feat = self.shared_head(roi_feat) + if getattr(self, "two_in", False): + m = getattr(self, "n_half", 0) or roi_feat.shape[0] // 2 + return self.bbox_head(roi_feat[:m], roi_feat[m:]) return self.bbox_head(roi_feat) +def num_bbox_stages(det): + """RoI bbox head 단계 수. 1 이면 평범한 two-stage.""" + bh = getattr(getattr(det, "roi_head", None), "bbox_head", None) + return len(bh) if isinstance(bh, nn.ModuleList) else 1 + + class MaskRCNN_SubC(nn.Module): """mask RoIAlign feat (M,256,14,14) → mask_logits (M, num_classes, 28, 28). (Mask R-CNN).""" def __init__(self, det): @@ -48,24 +110,60 @@ def forward(self, mask_feat): def frcnn_cfg(det, size=800): """host 부품(rpn_proposals/roi_align/detect_roi)용 config 추출 → .frcnn.json.""" rh = det.rpn_head + # ⚠️ RPN 이 표준 `AnchorGenerator` 를 안 쓰는 계열이 있다. 호스트 `rpn_proposals` 는 + # (strides, scale, ratios) 로 앵커를 깔아 디코드하는 구조라 그대로는 못 쓴다. + # **`AttributeError` 로 흘려보내지 말고** 왜 안 되는지 말한다 — 그래야 "미지원" 과 + # "우리 버그" 가 구분된다. + if not hasattr(rh, "prior_generator"): + raise NotImplementedError( + f"{type(rh).__name__}: 표준 앵커 RPN 이 아니다. " + "CascadeRPNHead=다단계 정제(stages), GARPNHead=앵커 모양을 예측, " + "EmbeddingRPNHead=학습된 proposal — 각각 호스트 proposal 생성이 달라진다") pg = rh.prior_generator bc = rh.bbox_coder ext = det.roi_head.bbox_roi_extractor + # 캐스케이드는 extractor 도 ModuleList 다(단계마다 하나). 설정은 전부 같으므로 0번을 쓴다. + if isinstance(ext, nn.ModuleList): + ext = ext[0] + # ⚠️ bbox extractor 가 `GenericRoIExtractor` 면 **레벨을 고르지 않고 전 레벨을 합친다** + # (게다가 groie 는 레벨마다 5x5 conv + GeneralizedAttention 을 건다). 호스트 + # RoIAlign 은 그걸 표현 못 한다 — 조용히 레벨 선택으로 떨어뜨리면 값만 틀린다. + if type(ext).__name__ == "GenericRoIExtractor": + raise NotImplementedError( + "GenericRoIExtractor(bbox): 전 레벨 집계 + pre/post 모듈이라 호스트 RoIAlign 으로 " + "표현 못 한다. 레벨별 conv 가 그래프에 들어가야 한다") bh = det.roi_head.bbox_head + # 캐스케이드는 bbox_head 도 ModuleList 다. 클래스 수·coder 종류는 단계 공통이라 + # **마지막 단계**를 쓴다(최종 박스를 내는 단계라 디코드 규약이 거기 맞춰져 있다). + if isinstance(bh, nn.ModuleList): + bh = bh[-1] rpn_c, rcnn_c = det.test_cfg.rpn, det.test_cfg.rcnn strides = [s[0] if isinstance(s, (tuple, list)) else int(s) for s in pg.strides] scales = pg.scales.tolist() if hasattr(pg.scales, "tolist") else list(pg.scales) mask = {} if getattr(det.roi_head, "with_mask", False): mext = det.roi_head.mask_roi_extractor + if isinstance(mext, nn.ModuleList): + mext = mext[0] mask = { "has_mask": True, "mask_roi_out": int(mext.roi_layers[0].output_size[0]), # 14 "mask_strides": [int(s) for s in mext.featmap_strides], - "mask_finest_scale": int(mext.finest_scale), + # ⚠️ `GenericRoIExtractor`(PointRend 의 마스크 경로)에는 `finest_scale` 이 없다. + # 전 레벨을 합치므로 레벨 선택 개념 자체가 없다. **bbox 검증에는 안 쓰이는 + # 값이라** 여기서 죽으면 안 된다 — 없으면 기본값으로 둔다. + "mask_finest_scale": int(getattr(mext, "finest_scale", 56)), "mask_thr_binary": float(rcnn_c.mask_thr_binary), } + ns = num_bbox_stages(det) + heads = det.roi_head.bbox_head + heads = list(heads) if ns > 1 else [heads] return {**mask, + # 캐스케이드: 단계 수와 **단계별 bbox 정규화 상수**. 단계마다 다르다 + # (예: [0.1,0.1,0.2,0.2] → [0.05,0.05,0.1,0.1] → [0.033,0.033,0.067,0.067]). + "num_bbox_stages": ns, + "stage_stds": [list(map(float, h.bbox_coder.stds)) for h in heads], + "stage_means": [list(map(float, h.bbox_coder.means)) for h in heads], "img_size": int(size), # RPN "rpn_strides": [float(s) for s in strides], @@ -75,6 +173,11 @@ def frcnn_cfg(det, size=800): "rpn_nms_pre": int(rpn_c.nms_pre), "rpn_nms_thr": float(rpn_c.nms.iou_threshold), "rpn_max": int(rpn_c.max_per_img), # RoIAlign + # ⚠️ **RoI feature 채널을 256 으로 박으면 안 된다.** FPN 계열은 256 이지만 + # C4 계열(TridentNet)은 neck 이 없어 백본 C4 채널(1024)이 그대로 온다. + # Double-Head: 회귀용 RoI 는 상자를 이만큼 키워 다시 자른다(0 이면 안 씀). + "reg_roi_scale_factor": float(getattr(det.roi_head, "reg_roi_scale_factor", 0.0) or 0.0), + "roi_channels": int(ext.out_channels), "roi_out": int(ext.roi_layers[0].output_size[0]), "roi_strides": [int(s) for s in ext.featmap_strides], "roi_finest_scale": int(ext.finest_scale), diff --git a/tools/frontend/mmdet/mmdet_compat.py b/tools/frontend/mmdet/mmdet_compat.py new file mode 100644 index 0000000..38dabae --- /dev/null +++ b/tools/frontend/mmdet/mmdet_compat.py @@ -0,0 +1,247 @@ +"""mmdet_compat.py — mmdet/mmpretrain 을 **import 가능하게** 만드는 호환 패치. + +import 하는 것만으로 걸린다. `mmdet_wrap` · `frcnn_wrap` 둘 다 여기를 거친다 — +우회를 진입점마다 따로 두면 한쪽에서 조용히 실패한다(실제로 겪었다: 검증 하네스에만 +`_stub.py` 를 두었더니 `mmdet_to_pt.py` 직접 실행에서 mmdet import 가 죽었고, +그 탓에 CARAFE 패치가 안 걸려 한참 뒤 "CPU 커널 없음" 으로 나타났다). + +**패키지를 설치하지 않는다.** mm 계열은 레지스트리 자동 임포트라 설치 자체가 부작용이고, +전 계열을 깨뜨린 전례가 두 번 있다 → 위키 `openmim-설치가-setuptools를-되돌려-전부-깨뜨린다`. +""" +import sys +import types + +import torch.nn as nn + + +def _relax_torch_load(): + """torch 2.6 부터 `torch.load` 기본값이 `weights_only=True` 다. + + mmengine 이 체크포인트를 읽을 때 ConfigDict·numpy 스칼라 같은 비-텐서 객체에서 + `_pickle.UnpicklingError` 로 죽는다(crowddet·maskformer 실측). 호출부가 mmengine + 안이라 인자를 넘길 수 없으므로 기본값을 되돌린다. + + ⚠️ 신뢰하는 파일 전용이다 — 이 하네스는 mmdet 공식 metafile URL 로 받은 것만 읽는다. + """ + import torch + + if getattr(torch.load, "_visp_relaxed", False): + return + _orig = torch.load + + def load(*a, **kw): + kw.setdefault("weights_only", False) + return _orig(*a, **kw) + + load._visp_relaxed = True + torch.load = load + + +def apply(): + """`mmpretrain` 의 BLIP 모듈을 빈 껍데기로 막는다 — **mmdet import 를 살리려고**. + + `mmdet.models` → `reid_data_preprocessor` → `mmpretrain.models` 로 딸려 들어가는데, + 그 안 BLIP 이 설치된 transformers 버전과 안 맞아 import 시점에 죽는다: + + TypeError: NoneType takes no arguments (BertPreTrainedModel 정의 중) + + 우회가 **호출자마다 따로** 있으면(검증 하네스만 `_stub.py` 를 두는 식) 다른 진입점에서 + 조용히 실패한다 — `trace_friendly_ops` 가 mmdet import 에 실패해 CARAFE 패치를 못 걸었고, + 한참 뒤 "CPU 커널 없음" 이라는 엉뚱한 에러로 나타났다. mmdet 으로 들어가는 문은 + 여기 하나이므로 여기서 막는다. 이미 import 된 상태면 건드리지 않는다. + """ + _relax_torch_load() + # ⚠️ **패키지를 통째로 막지 마라.** 처음엔 `...blip` 자체를 빈 모듈로 바꿨는데, + # 그러면 형제 모듈(`blip_retrieval` 등)을 못 찾아 오히려 더 넓게 깨진다. + # 죽는 건 `language_model.py` 하나뿐이다(거기서 `PreTrainedModel` 이 None 이 된다). + # 그 파일만 **아무 이름이든 내주는** 더미로 바꾼다 — `from ... import A, B, C` 가 통과한다. + # ① 먼저 **설치 없이** 고칠 수 있는지 본다. transformers 5.x 는 몇 심볼을 + # `modeling_utils` → `pytorch_utils` 로 옮겼는데 mmpretrain 은 옛 위치를 import 한다. + # 별칭만 이어주면 되므로 **패키지를 건드리지 않는다**(설치는 레지스트리 자동 임포트 + # 때문에 전 계열을 깨뜨린 전례가 두 번 있다 → 위키). + # 옮겨간 심볼을 **하나씩 쫓지 않는다** — 없는 이름을 물어오면 형제 모듈에서 찾아 준다. + # (5.x 에서 apply_chunking_to_forward → pytorch_utils, GenerationMixin → generation …) + try: + import importlib as _il + _mu = _il.import_module("transformers.modeling_utils") + _srcs = ["transformers.pytorch_utils", "transformers.generation", + "transformers.modeling_layers", "transformers"] + + class _Fallback(type(_mu)): + def __getattr__(self, name): + if name.startswith("__") and name.endswith("__"): + raise AttributeError(name) + for s in _srcs: + try: + v = getattr(_il.import_module(s), name) + except Exception: + continue + setattr(_mu, name, v) # 한 번 찾으면 캐시 + return v + # 5.x 에서 **삭제된** 이름은 어디에도 없다. import 만 통과시키고 + # 실제로 부르면 터뜨린다 — 조용히 잘못된 값을 내는 것보다 낫다. + # (head pruning 유틸이라 추론 경로에서는 안 불린다) + if name in ("find_pruneable_heads_and_indices",): + def _removed(*a, _n=name, **k): + raise NotImplementedError( + f"transformers 5.x 에서 삭제된 함수다: {_n}") + setattr(_mu, name, _removed) + return _removed + raise AttributeError(name) + + _mu.__class__ = _Fallback + except Exception: + pass # transformers 가 없으면 아래 더미로 간다 + + # ② 데이터셋 전용 의존성은 더미로 막는다. 우리는 **모델 구조만** 쓰므로 데이터 로더가 + # 없어도 된다. 이것들은 레지스트리 프레임워크가 아니라 잎 패키지라 부작용이 없다. + for dep in ("mat4py",): + if dep not in sys.modules: + try: + __import__(dep) + except Exception: + sys.modules[dep] = types.ModuleType(dep) + + # ③ 토크나이저의 삭제된 메서드. `batch_encode_plus` 는 5.x 에서 빠졌고 `__call__` 이 같은 일을 한다. + # (GroundingDINO·GLIP 이 프롬프트를 토큰화할 때 부른다) + try: + from transformers.tokenization_utils_base import PreTrainedTokenizerBase as _T + if not hasattr(_T, "batch_encode_plus"): + _T.batch_encode_plus = lambda self, *a, **k: self(*a, **k) + except Exception: + pass + + n = "mmpretrain.models.multimodal.blip.language_model" + if n in sys.modules: + return + + class _AnyNames(types.ModuleType): + def __getattr__(self, name): + # ⚠️ 던더는 가로채면 안 된다 — import 기계가 `__file__`·`__path__` 를 물어보는데 + # 빈 클래스를 돌려주면 엉뚱한 곳에서 터진다 + # (`type object '__file__' has no attribute 'endswith'`). + if name.startswith("__") and name.endswith("__"): + raise AttributeError(name) + return type(name, (), {}) # 요청한 이름의 빈 클래스를 만들어 준다 + + sys.modules[n] = _AnyNames(n) + + + + +def patch_ops(): + """trace 가 통째로 삼키는 mmdet 커스텀 op 을 **등가 수식**으로 바꾼다. + + `torch.autograd.Function` 은 forward/backward 를 직접 정의한 불투명 단위라 + `torch.jit.trace` 가 내부를 안 편다 — 그래프에 원자 노드 하나로 남고, 컴파일러는 + `unhandled op '<클래스명>'` 을 낸다. 렌더러를 새로 쓸 일이 아니라 **여기서 풀 일**이다. + 이 클래스들은 학습(역전파 수치안정) 때문에 존재하고 순전파는 등가이기 때문이다. + + 호출은 멱등. mmdet 이 없거나 구조가 바뀌었으면 조용히 넘어간다. + """ + try: + import mmdet.models.dense_heads.tood_head as _tood + except Exception as e: + # ⚠️ **조용히 넘어가지 않는다.** 여기서 return 하면 아래 패치가 전부 안 걸리고, + # 한참 뒤 "CPU 커널 없음" 같은 엉뚱한 에러로 나타난다(실제로 겪었다). + print(f" ⚠️ trace_friendly_ops: mmdet import 실패 — 패치를 못 걸었다: " + f"{type(e).__name__}: {e}") + return + # TOOD. mmdet docstring 이 직접 밝힌다 — "substitutes the autograd function of + # (x.sigmoid() * y.sigmoid()).sqrt()". 학습용 해석적 gradient 라 추론 값은 같다. + if getattr(_tood.sigmoid_geometric_mean, "__module__", "") != __name__: + def sigmoid_geometric_mean(x, y): + return (x.sigmoid() * y.sigmoid()).sqrt() + _tood.sigmoid_geometric_mean = sigmoid_geometric_mean + + _patch_carafe() + + +def _patch_carafe(): + """CARAFE 를 torch 로 대체한다 — mmcv 커널이 **CUDA 전용**이라 CPU 에서 죽는다. + + RuntimeError: carafe_forward_impl: implementation for device cpu not found. + + "CPU 커널이 없다" 는 검증 불가가 아니라 재구현 과제다. 커널 소스 + (`carafe_naive_cuda_kernel.cuh`)의 인덱싱을 그대로 옮긴다: + + out[n,c,ph,pw] = Σ_{my,mx} feat[n,c,iy,ix] · mask[n,(g·k+my)·k+mx, ph, pw] + g = c // (C/group), (iy,ix) 는 (ph//s, pw//s) 주변 k×k, 경계 밖은 0 + + 벡터화하면 unfold + nearest 확대 + 가중합이다. + + ⚠️ **`import mmcv.ops.carafe as m` 은 모듈이 아닐 수 있다** — `mmcv/ops/__init__.py` + 가 같은 이름의 함수를 re-export 해 서브모듈을 가린다. `importlib` 로 꺼낸다. + ⚠️ **패치 실패를 조용히 삼키지 마라.** 안 걸린 패치는 원래 버그보다 찾기 어렵다. + """ + import importlib + import torch.nn.functional as F + try: + mod = importlib.import_module("mmcv.ops.carafe") + except Exception: + return # CARAFE 를 안 쓰는 환경 — 조용히 넘어간다 + if getattr(mod, "_visp_patched", False): + return + + def carafe(feats, masks, kernel_size, group_size, scale_factor): + n, c, h, w = feats.shape + ho, wo = h * scale_factor, w * scale_factor + k, g = kernel_size, group_size + u = F.unfold(feats, k, padding=(k - 1) // 2).view(n, c * k * k, h, w) + u = F.interpolate(u, size=(ho, wo), mode="nearest") + u = u.view(n, g, c // g, k * k, ho, wo) + m = masks.view(n, g, k * k, ho, wo).unsqueeze(2) + return (u * m).sum(3).view(n, c, ho, wo) + + class _CARAFE(nn.Module): + def __init__(self, kernel_size, group_size, scale_factor): + super().__init__() + self.kernel_size, self.group_size = kernel_size, group_size + self.scale_factor = scale_factor + + def forward(self, feats, masks): + return carafe(feats, masks, self.kernel_size, self.group_size, self.scale_factor) + + mod.carafe = carafe + mod.CARAFE = _CARAFE + mod._visp_patched = True + # `mmcv.ops` 가 re-export 한 이름도 같이 갈아야 한다(그쪽을 import 한 코드가 있다). + ops = importlib.import_module("mmcv.ops") + ops.carafe = carafe + ops.CARAFE = _CARAFE + try: + import mmcv.ops.carafe as _chk + assert getattr(_chk, "_visp_patched", False) or _chk is carafe + except Exception as e: # 실패는 **반드시 말한다** + print(f" ⚠️ CARAFE 패치 확인 실패: {type(e).__name__}: {e}") + + + +def install_loader_modules(out_path, *names): + """`out_path`(.pt 또는 그 디렉토리) 옆에 래퍼 모듈을 복사한다. + + `torch.save` 는 클래스를 `__module__` 이름으로 절이므로, 로드하는 쪽이 그 이름을 + import 할 수 있어야 한다. 컴파일러는 `.pt` 의 디렉토리를 `sys.path` 에 넣으므로 + 거기에 모듈이 **있기만** 하면 환경변수 없이 열린다. + + 자기 자신(`mmdet_compat`)도 같이 나른다 — 래퍼가 import 한다. + """ + import os + import shutil + + here = os.path.dirname(os.path.abspath(__file__)) + dst_dir = out_path if os.path.isdir(out_path) else os.path.dirname(os.path.abspath(out_path)) + os.makedirs(dst_dir, exist_ok=True) + copied = [] + for name in (*names, "mmdet_compat"): + src = os.path.join(here, name + ".py") + dst = os.path.join(dst_dir, name + ".py") + if os.path.abspath(src) == os.path.abspath(dst): + continue + shutil.copyfile(src, dst) + copied.append(name + ".py") + return copied + + +apply() +patch_ops() diff --git a/tools/frontend/mmdet/mmdet_to_pt.py b/tools/frontend/mmdet/mmdet_to_pt.py index ffcecd4..ca53a66 100755 --- a/tools/frontend/mmdet/mmdet_to_pt.py +++ b/tools/frontend/mmdet/mmdet_to_pt.py @@ -12,7 +12,7 @@ PYTHONPATH=:<이 폴더> g2c --model retinanet_bb.pt --name Retina --output output/retina """ import argparse -import json +import math import os import sys import torch @@ -20,9 +20,117 @@ # self-contained import: 이 파일 폴더(tools/frontend/mmdet)를 경로에 넣고 top-level 모듈로 import # → torch.save 피클 모듈경로 = 'mmdet_wrap' (mmdet 라이브러리와 이름 충돌 없음). sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import mmdet_compat # noqa: E402 from mmdet_wrap import MMDetBackbone, build # noqa: E402,F401 (MMDetBackbone: 피클 등록) +def _f(v): + """A C++ float literal. repr keeps the significant digits. + + Infinities reach here from configs that bound a regression range with one -- FCOS writes + regress_ranges=((-1, 64), ..., (512, INF)) -- and `inff` is not something C++ accepts. + """ + v = float(v) + if math.isinf(v): + return "-INFINITY" if v < 0 else "INFINITY" + if math.isnan(v): + return "NAN" + return f"{v!r}f" + + +def _arr(name, values): + return f" c.det.{name} = {{{', '.join(_f(v) for v in values)}}};\n" + + +def emit_params(cfg, config_name): + """Decoding and anchor configuration as a C++ header holding mmdet_params(). + + Replaces the JSON sidecar. The values end up inside the executable, which removes a + deployed file and makes it impossible to pair a .gguf with a configuration from a + different export. + """ + h = cfg.get("head_type", "raw") + out = [ + "// Generated by mmdet_to_pt.py — do not edit.\n", + f"// source: {config_name}\n", + f"// head_type: {h}\n", + "#pragma once\n\n", + "#include \n", + '#include "head.h"\n\n', + "namespace visp {\n\n", + "inline mmdet_cfg mmdet_params() {\n", + " mmdet_cfg c;\n", + ] + if h == "raw": + out += [ + " // The head in this config was not recognised, so only the backbone is\n", + " // exported and decoding is left to the caller.\n", + " return c;\n}\n\n} // namespace visp\n", + ] + return "".join(out) + + out.append(f" c.head.kind = head_kind::{h};\n") + for k in ("stacked_convs", "reg_stacked_convs", "feat_channels", "num_base", + "num_classes", "gn_groups", "reg_max", + "embed_dims", "n_heads", "enc_layers", "dec_layers", "n_points", "num_queries"): + if k in cfg: + out.append(f" c.head.{k} = {int(cfg[k])};\n") + for k in ("cls_convs_prefix", "reg_convs_prefix", "cls_head", "reg_head", + "centerness_head", "scales_prefix", "per_level_head_tail", "pre_conv"): + if k in cfg: + out.append(f' c.head.{k} = "{cfg[k]}";\n') + for k in ("head_has_norm", "centerness_on_reg", "bbox_exp", "bbox_clamp_stride", "bbox_mul_stride", + "per_level_towers", "per_level_heads", "head_silu", "ctr_tanh", "corner_emb", "corner_centripetal"): + out.append(f" c.head.{k} = {str(bool(cfg.get(k, False))).lower()};\n") + # 조립기도 stride 를 쓴다(FCOS 의 곱, GFL 의 거리, TOOD 의 격자→픽셀). + out.append(" c.head.strides = {" + ", ".join(_f(v) for v in cfg["strides"]) + "};\n") + if cfg.get("reg_denoms"): + out.append(" c.head.reg_denoms = {" + ", ".join(_f(v) for v in cfg["reg_denoms"]) + "};\n") + # 격자 중심. anchor 계열(AnchorGenerator)은 0, point 계열은 0.5 — 조립기가 TOOD 에 쓴다. + out.append(f" c.head.head_leaky = {_f(cfg.get('head_leaky', 0.0))};\n") + out.append(f" c.head.ddq_iou_thr = {_f(cfg.get('ddq_iou_thr', 0.8))};\n") + out.append(f" c.head.center_offset = {_f(cfg.get('center_offset', 0.0))};\n\n") + + # anchor(Delta) 디코드가 되는 계열만 c.det 를 채운다. 조립은 되는데 코더가 + # 다른 계열(FSAF 의 TBLRBBoxCoder 등)은 head 원시 출력까지만 낸다. + if h != "anchor" or not cfg.get("can_decode", True): + out += [ + " // Decoding for this family is the caller's: the head above emits raw\n", + " // per-level tensors, and only the anchor(Delta) path fills c.det here.\n", + ] + out.append(" c.det.strides = {" + ", ".join(_f(v) for v in cfg["strides"]) + "};\n") + out.append(f" c.det.num_classes = {int(cfg.get('num_classes', 80))};\n") + out.append(f" c.det.use_sigmoid = {str(bool(cfg.get('use_sigmoid', True))).lower()};\n") + for i, v in enumerate(cfg.get("img_mean", [0.0] * 3)): + out.append(f" c.img_mean[{i}] = {_f(v)};\n") + for i, v in enumerate(cfg.get("img_std", [1.0] * 3)): + out.append(f" c.img_std[{i}] = {_f(v)};\n") + out.append(f" c.to_rgb = {str(bool(cfg.get('to_rgb', False))).lower()};\n") + out.append(" return c;\n}\n\n} // namespace visp\n") + return "".join(out) + + out.append(_arr("strides", cfg["strides"])) + out.append(_arr("octave_scales", cfg["octave_scales"])) + out.append(_arr("ratios", cfg["ratios"])) + out.append(f" c.det.octave_base_scale = {_f(cfg.get('octave_base_scale', 4.0))};\n") + out.append(f" c.det.center_offset = {_f(cfg.get('center_offset', 0.0))};\n") + for i, v in enumerate(cfg.get("means", [0.0] * 4)): + out.append(f" c.det.means[{i}] = {_f(v)};\n") + for i, v in enumerate(cfg.get("stds", [1.0] * 4)): + out.append(f" c.det.stds[{i}] = {_f(v)};\n") + out.append(f" c.det.num_classes = {int(cfg.get('num_classes', 80))};\n") + out.append(f" c.det.use_sigmoid = {str(bool(cfg.get('use_sigmoid', True))).lower()};\n\n") + + for i, v in enumerate(cfg.get("img_mean", [0.0] * 3)): + out.append(f" c.img_mean[{i}] = {_f(v)};\n") + for i, v in enumerate(cfg.get("img_std", [1.0] * 3)): + out.append(f" c.img_std[{i}] = {_f(v)};\n") + out.append(f" c.to_rgb = {str(bool(cfg.get('to_rgb', False))).lower()};\n") + + out.append(" return c;\n}\n\n} // namespace visp\n") + return "".join(out) + + def main(argv=None): ap = argparse.ArgumentParser(prog="mmdet_to_pt") ap.add_argument("--config", required=True, help="mmdet config .py") @@ -33,15 +141,21 @@ def main(argv=None): m, shapes, cfg = build(a.config, a.checkpoint, a.size) print(f" backbone/neck features: {shapes}") - torch.save(m, a.out) # MMDetBackbone.__module__ == 'mmdet_wrap' → 어디서든 로드 가능 + torch.save(m, a.out) n_head = sum(1 for k in m.state_dict() if k.startswith("bbox_head")) print(f" → saved: {a.out} (state_dict {len(m.state_dict())} tensors, head {n_head} 포함)") - # decode/anchor + head-conv config 사이드카 (vision.cpp mmdet 부품이 읽음) - sidecar = os.path.splitext(a.out)[0] + ".postproc.json" - with open(sidecar, "w") as f: - json.dump(cfg, f, indent=2) - print(f" → sidecar: {sidecar} (head_type={cfg.get('head_type')})") + # 클래스는 `__module__`(= 'mmdet_wrap') 로 절여진다 → 로더가 그 이름을 import 할 수 + # 있어야 한다. `.pt` 옆에 같이 두면 환경변수 없이 열린다. + for f in mmdet_compat.install_loader_modules(a.out, "mmdet_wrap"): + print(f" → loader module: {f}") + + # Decoding, anchor and head-conv configuration, emitted as a C++ function. Once the + # architecture is fixed these are constants, so they are compiled into the runner. + header = os.path.splitext(a.out)[0] + ".postproc.h" + with open(header, "w") as f: + f.write(emit_params(cfg, os.path.basename(a.config))) + print(f" → params: {header} (head_type={cfg.get('head_type')})") if __name__ == "__main__": diff --git a/tools/frontend/mmdet/mmdet_wrap.py b/tools/frontend/mmdet/mmdet_wrap.py index 6cdfc23..232b809 100755 --- a/tools/frontend/mmdet/mmdet_wrap.py +++ b/tools/frontend/mmdet/mmdet_wrap.py @@ -7,17 +7,165 @@ g2c 무관 — mmdet 만 import. forward = backbone→neck FPN features 까지(head decode/NMS 제외). bbox_head 는 attribute 로 유지 → state_dict(→gguf)에 head 가중치가 실려 vision.cpp head 부품이 사용. """ +import sys +import types + import torch import torch.nn as nn +import mmdet_compat # noqa: F401 (import 만으로 호환 패치가 걸린다) + + +# op 패치는 **공유 모듈**에 있다 — 진입점이 둘(`mmdet_wrap`·`frcnn_wrap`)이라 +# 한쪽에만 두면 다른 쪽에서 조용히 안 걸린다(실제로 CARAFE 가 그래서 두 번 막혔다). +trace_friendly_ops = mmdet_compat.patch_ops + + +def allow_mmengine_checkpoint_globals(): + """mmengine 이 체크포인트에 같이 넣는 클래스를 torch 의 안전 목록에 올린다. + + PyTorch 2.6 부터 `torch.load` 의 `weights_only` 기본값이 True 다. mmdet v3 체크포인트는 + 학습 메타(`HistoryBuffer` — 손실 이력)를 함께 담고 있어 그대로는 로드가 거부된다. + + **`weights_only=False` 로 되돌리지 않는다.** 그건 파일 전체에 임의 코드 실행을 허용하는 + 것이라 검증 도구가 열어줄 문이 아니다. 필요한 클래스만 이름으로 허용한다. + """ + import numpy as np + allow = [] + try: + from mmengine.logging.history_buffer import HistoryBuffer + allow.append(HistoryBuffer) # 손실 이력 (mmdet v3 체크포인트) + except Exception: + pass + # 그 이력이 numpy 배열을 담고 있어 배열 재구성 함수도 함께 필요하다. + # **데이터 생성자만** 올린다 — 임의 호출 가능한 것을 올리는 게 아니다. + # + # ⚠️ **피클에 적힌 이름으로** 등록해야 한다. numpy 2 는 내부를 `numpy._core` 로 옮겼는데 + # 오래된 체크포인트는 `numpy.core.multiarray._reconstruct` 로 적혀 있다. 객체만 넘기면 + # torch 가 `obj.__module__` 로 이름을 만들어 새 경로로 등록하고, 대조가 안 돼 계속 막힌다. + # `(객체, "전체이름")` 튜플로 옛 이름을 같이 준다. + allow += [ + (np.core.multiarray._reconstruct, "numpy.core.multiarray._reconstruct"), + (np.core.multiarray.scalar, "numpy.core.multiarray.scalar"), + (np.ndarray, "numpy.ndarray"), + (np.dtype, "numpy.dtype"), + ] + for name in dir(getattr(np, "dtypes", None)): + t = getattr(np.dtypes, name, None) + if isinstance(t, type) and name.endswith("DType"): + allow.append((t, f"numpy.dtypes.{name}")) + # ⚠️ `builtins.getattr` — DDQ DETR 체크포인트가 이걸 피클에 담고 있어 그대로는 못 연다. + # 이건 **임의 속성 접근 수단**이라 허용하면 `weights_only=True` 의 방어가 크게 약해진다. + # (신뢰할 수 없는 .pth 에 대해서는 사실상 `weights_only=False` 에 가깝다.) + # 그래도 넣은 이유: 이 도구는 **mmdetection 공식 model zoo 에서 받은 체크포인트만** + # 검증에 쓰고, 그 출처는 config 와 함께 저장소에 적혀 있다. 사용자 판단으로 진행했다. + # → 출처가 불분명한 체크포인트에는 이 경로를 쓰지 마라. `DECISIONS.md` + allow.append(getattr) + try: + import torch.serialization as ts + ts.add_safe_globals(allow) + except Exception: + pass # 없는 버전이면 그냥 넘어간다 — 로드가 되면 그만이다 + + +def fold_head_bn(bh): + """head 의 `ConvModule` 안 BatchNorm 을 conv 에 흡수시키고 Identity 로 바꾼다. + + GGUF 에는 **학습된 파라미터만** 실린다 — `running_mean`/`running_var` 는 안 간다. + 백본은 컴파일러가 fold 해서 이 문제가 없는데, head 는 그래프 밖이라 그 pass 를 안 탄다. + 그래서 여기서 접는다(수학적으로 정확한 변환이라 값이 안 바뀐다). + + `RetinaSepBNHead`(efficientnet·nas_fpn)가 유일한 사례다 — mmdet head 는 대개 GN 을 쓴다. + """ + import copy + import torch + folded, seen = 0, set() + for mod in bh.modules(): + bn = getattr(mod, "bn", None) + conv = getattr(mod, "conv", None) + if not isinstance(bn, nn.modules.batchnorm._BatchNorm) or conv is None: + continue + # ⚠️ **conv 를 레벨끼리 공유하는 head 가 있다**(RTMDetSepBNHead: share_conv=True 라 + # conv 는 하나인데 BN 만 레벨별로 따로다). 그대로 접으면 같은 conv 에 BN 을 + # 여러 번 겹쳐 접어 **전 레벨이 다 틀린다**(실측 cls 배율 0.89). + # 두 번째부터는 그 ConvModule 전용 사본을 만들어 접는다. + if id(conv) in seen: + conv = copy.deepcopy(conv) + mod.conv = conv + seen.add(id(conv)) + with torch.no_grad(): + std = (bn.running_var + bn.eps).sqrt() + scale = bn.weight / std + w = conv.weight * scale.reshape(-1, 1, 1, 1) + b = bn.bias - bn.running_mean * scale + if conv.bias is not None: + b = b + conv.bias * scale + conv.weight.copy_(w) + if conv.bias is None: + conv.bias = nn.Parameter(b) + else: + conv.bias.copy_(b) + mod.bn = nn.Identity() # ConvModule.forward 의 norm 단이 통과만 한다 + folded += 1 + return folded + + +def _unwrap_checkpoint_forward(det): + """인스턴스에 덮인 `forward` 를 걷어낸다 — **`torch.save` 를 살리려고**. + + mmengine 이 activation checkpointing 을 걸면 모듈 인스턴스에 + `self.forward = functools.partial(...)` 를 심는데, 그 partial 이 모듈 자신에 대한 + **weakref** 를 물고 있어 피클이 안 된다: + + TypeError: cannot pickle 'weakref.ReferenceType' object + + (grounding_dino·mm_grounding_dino 의 `encoder.layers[i]`·`fusion_layers[i]` 가 그렇다. + BERT 가 범인일 거라 추측했는데 아니었다 — 하나씩 좁혀서 찾았다.) + + checkpointing 은 **학습 때 메모리를 아끼는 래퍼**일 뿐이라, 지우면 클래스의 원래 + `forward` 가 다시 보이고 추론 값은 같다. + """ + n = 0 + for mod in det.modules(): + if "forward" in vars(mod): + del mod.forward + n += 1 + if n: + print(f" · checkpointing forward 래퍼 {n}개 제거 (피클 가능하게)") + + class MMDetBackbone(nn.Module): """backbone(+neck) features 만 노출. head 는 가중치 유지용 attribute(forward 미사용).""" def __init__(self, det): super().__init__() self.backbone = det.backbone self.neck = det.neck if getattr(det, "with_neck", False) else None - self.bbox_head = getattr(det, "bbox_head", None) # 가중치 보존 (state_dict→gguf) + self.bbox_head = getattr(det, "bbox_head", None) + # head 는 forward 에 참여하지 않는다 — 그 계산은 tools/detect 의 C++ 부품이 한다. + # 그래도 가중치는 GGUF 에 있어야 그 부품이 이름으로 찾을 수 있는데, trace 에는 안 + # 잡힌다. **g2c 를 고치지 않고** `append_head_weights.py` 가 g2c 산출물에 덧붙인다 — + # mmdet 지식은 mmdet 폴더에 둔다. 여기서는 무엇을 실을지 이름만 선언한다. + declared = ["bbox_head"] + + # ⚠️ **DETR 계열은 encoder/decoder 가 head 가 아니라 detector 에 달려 있다** + # (mmdet v3 `DetectionTransformer`). `bbox_head` 는 cls/reg 분기 둘뿐이고 + # `forward(hidden_states)` 로 decoder 출력을 받는다. 그래서 "bbox_head 만 선언" + # 으로는 transformer 가중치가 통째로 빠진다. + # 이름을 나열하지 않고 **detector 의 자식 중 우리가 이미 아는 것 말고 전부**를 + # 붙인다 — 계열이 늘어도 여기를 안 고친다. 이름은 mmdet 원본 그대로 유지해야 + # C++ 조립기가 같은 키로 찾는다. + known = {"backbone", "neck", "bbox_head", "data_preprocessor"} + for name, child in det.named_children(): + if name in known or child is None: + continue + setattr(self, name, child) + declared.append(name) + # level_embed 처럼 **Module 이 아니라 Parameter** 로 달린 것도 있다(Deformable DETR). + for name, prm in det.named_parameters(recurse=False): + setattr(self, name, prm) + declared.append(name) + self.head_weight_prefixes = tuple(declared) def forward(self, x): f = self.backbone(x) @@ -45,28 +193,229 @@ def postproc_cfg(det): return {"head_type": "raw"} pg = getattr(bh, "prior_generator", None) bc = getattr(bh, "bbox_coder", None) - if pg is None or bc is None or "Delta" not in type(bc).__name__: - return {"head_type": "raw"} # anchor(Delta) 만 이번 PoC 지원 + + # 어느 조립기를 쓸지. **MRO 를 순서대로 훑는다** — 계열 이름이 아니라 head 클래스가 + # 기준이고, 상속한 계열은 부모의 조립기를 그대로 탄다(GHM·PVT 는 RetinaHead 자체, + # FSAF·FreeAnchor 는 RetinaHead 상속, LD 는 GFLHead 상속 …). 손실·라벨할당·증류는 + # 학습 시점 얘기라 추론 그래프가 같기 때문이다. + # MRO 순서가 곧 우선순위다: VFNetHead 는 ATSSHead 를 상속하지만 자기 항목이 먼저 걸린다. + # + # ⚠️ 부모로 떨어지는 건 **추정**이다. `_init_layers`/`forward` 를 오버라이드한 계열이면 + # 조립이 조용히 틀린다 — verify_heads.py 로 재기 전에는 지원한다고 말하지 마라. + HEADS = { + "VFNetHead": "vfnet", "RepPointsHead": "reppoints", "TOODHead": "tood", + "GFLHead": "gfl", "FCOSHead": "fcos", + "ATSSHead": "anchor", "PAAHead": "anchor", "RetinaHead": "anchor", + "AnchorHead": "anchor", "AnchorFreeHead": "fcos", + "CenterNetHead": "centernet", "YOLOFHead": "yolof", + "YOLOXHead": "anchor", "YOLOV3Head": "yolo", + "CornerHead": "cornernet", "CentripetalHead": "cornernet", + "DeformableDETRHead": "deformable_detr", "DABDETRHead": "dab_detr", "ConditionalDETRHead": "conditional_detr", + "DETRHead": "detr", + } + kind = next((HEADS[c.__name__] for c in type(bh).__mro__ if c.__name__ in HEADS), None) + if kind is None: + return {"head_type": "raw"} # 모르는 계열 — 백본만 내보낸다 + # ⚠️ prior_generator 는 **앵커 계열에만** 있다. CenterNet 은 heatmap 최대점, CornerNet 은 + # 코너 짝짓기를 쓰므로 없다. 이걸 먼저 검사하면 조립 가능한 계열까지 raw 로 떨어진다. + # 없다. 이걸 먼저 검사하면 조립 가능한 계열까지 raw 로 떨어진다. + # deformable 계열은 MRO 로 `DETRHead` 에 떨어지지만 attention 자체가 다르다 — + # 여기서 kind 를 바로잡는다. two-stage(DINO·DDQ)는 아직 조립기가 없으므로 raw 로 떨군다. + # **지원 안 하는 것은 크래시가 아니라 "인식 못 함" 으로 말한다.** + enc = getattr(det, "encoder", None) + dec = getattr(det, "decoder", None) + deformable = (dec is not None + and type(dec.layers[0].cross_attn).__name__.startswith("MultiScaleDeform")) + if deformable: + # two-stage 여부는 `memory_trans_fc` 로 가른다 — DINO 도 query_embedding 은 갖고 있어서 + # 그걸로 가르면 두 계열이 같은 조립기를 타고 조용히 틀린다. + two_stage = getattr(det, "memory_trans_fc", None) is not None + # DDQ 는 층마다 NMS 로 query 를 골라낸다 — `dqs_cfg` 유무가 그 표시다. + if getattr(det, "dqs_cfg", None) is not None: + kind = "ddq" + else: + kind = "dino" if two_stage else "deformable_detr" + + if pg is None and kind not in ("centernet", "cornernet", "detr", "conditional_detr", + "dab_detr", "deformable_detr", "dino", "ddq"): + return {"head_type": "raw"} + + # 디코드 지원은 **별개 판단**이다. head 조립은 되는데 박스 코더가 다른 계열이 있다 + # (FSAF 는 RetinaHead 인데 TBLRBBoxCoder 를 쓴다). 하나로 묶으면 조립까지 같이 막힌다. + can_decode = bc is not None and "Delta" in type(bc).__name__ + # (레벨별 anchor 수가 다르면 뒤에서 취소한다 — num_base 하나로는 못 푼다) ncls = int(getattr(bh, "cls_out_channels", getattr(bh, "num_classes", 80))) - strides = [s[0] if isinstance(s, (tuple, list)) else int(s) for s in pg.strides] - obs = float(getattr(pg, "octave_base_scale", 1.0) or 1.0) - scales = [float(x) for x in _tolist(getattr(pg, "scales", [obs]))] - ratios = [float(x) for x in _tolist(getattr(pg, "ratios", [1.0]))] - num_base = int(_tolist(getattr(pg, "num_base_priors", [len(scales) * len(ratios)]))[0]) - - # head-conv 구조 (C++ anchor_head_forward 가 조립) — 최종 cls/reg conv 이름 자동 탐지. - stacked = len(bh.cls_convs) if hasattr(bh, "cls_convs") else 0 + # stride 는 prior_generator 가 없으면 `build()` 가 실제 feature 크기에서 채운다. + strides = ([s[0] if isinstance(s, (tuple, list)) else int(s) for s in pg.strides] + if pg is not None else []) + obs = float(getattr(pg, "octave_base_scale", 1.0) or 1.0) if pg is not None else 1.0 + + def _floats(v, dflt): + # ⚠️ SSDAnchorGenerator 는 scales/ratios 를 **레벨별 리스트**로 둔다. 원소가 스칼라가 + # 아니라 `float(x)` 가 터진다("only one element tensors …"). 그때는 디코드를 + # 포기하고(호출자 몫) head 조립만 한다 — 여기서 죽으면 계열이 통째로 막힌다. + try: + return [float(x) for x in _tolist(v)] + except (TypeError, ValueError): + return dflt + scales = _floats(getattr(pg, "scales", [obs]), []) if pg is not None else [1.0] + ratios = _floats(getattr(pg, "ratios", [1.0]), []) if pg is not None else [1.0] + nbp = _tolist(getattr(pg, "num_base_priors", [len(scales) * len(ratios)])) if pg is not None else [1] + num_base = int(nbp[0]) + # 레벨마다 anchor 수가 다르면(SSD: 4·6·6·6·4·4) 하나의 num_base 로 디코드할 수 없다. + uniform_priors = len(set(int(x) for x in nbp)) <= 1 + + # head-conv 구조 (C++ 조립기가 소비) — 최종 cls/reg conv 이름 **자동 탐지**. + # 계열마다 이름이 다르다(retina_cls / atss_cls / gfl_cls / conv_cls …). 이름 표를 두는 대신 + # **출력 채널 수로 알아본다** — 그러면 새 계열이 와도 표를 안 고쳐도 된다. + # 타워 ModuleList 이름이 계열마다 다르다. TOOD 는 cls/reg 를 따로 두지 않고 + # **inter_convs 하나**를 쓴 뒤 task decomposition 으로 가른다. + # YOLOF 는 `cls_subnet`/`bbox_subnet` 이고 **깊이도 서로 다르다**(2 / 4). + # 후보를 순서대로 보고 처음 있는 것을 쓴다 — 새 이름이 와도 여기 한 줄만 는다. + def _tower(*names): + return next((n for n in names if getattr(bh, n, None) is not None), names[-1]) + cls_tower = _tower("inter_convs", "head_convs", "cls_convs", "cls_subnet", "multi_level_cls_convs") + reg_tower = _tower("inter_convs", "head_convs", "reg_convs", "bbox_subnet", "multi_level_reg_convs") + towers = getattr(bh, cls_tower, []) + reg_towers = getattr(bh, reg_tower, []) + # RetinaSepBNHead 는 레벨마다 타워를 따로 둔다 → ModuleList 안에 또 ModuleList 다. + # 그때 `len(towers)` 는 단 수가 아니라 **레벨 수**이므로 한 겹 들어가야 한다. + # 레벨별 타워인지 판별. 컨테이너 종류로 보면 안 된다 — RetinaSepBNHead 는 ModuleList, + # YOLOX 는 nn.Sequential 이다. **원소가 ConvModule 인가**(=`.conv` 를 갖는가)로 가른다. + per_level = bool(towers) and getattr(towers[0], "conv", None) is None + stacked = len(towers[0]) if per_level else len(towers) + # reg 타워 깊이가 다르면 따로 싣는다. 같으면 0(= cls 와 같다)으로 둔다. + reg_stacked = 0 if reg_tower == cls_tower else len(reg_towers) + if reg_stacked == stacked: + reg_stacked = 0 feat_ch = int(getattr(bh, "feat_channels", 256)) - cls_head = reg_head = None - for name, mod in bh.named_children(): # retina_cls/retina_reg, atss_cls/atss_reg 등 + reg_max = int(getattr(bh, "reg_max", 0) or 0) + reg_ch = num_base * 4 * (reg_max + 1) # GFL 은 방향당 (reg_max+1) 개 빈 + # 출력 conv 가 **레벨별 ModuleList** 인 계열이 있다(RTMDetSepBNHead 의 rtm_cls/rtm_reg). + # 대표로 0번을 보고 채널을 재고, 조립기에 `.<레벨>` 을 붙이라고 알린다. + cls_head = reg_head = ctr_head = None + ctr_tanh = False + pre_conv = "" + # CornerHead 는 embedding 갈래를 config 로 끌 수 있다(CentripetalNet 은 끈다). + corner_emb = bool(getattr(bh, "with_corner_emb", False)) + corner_centripetal = getattr(bh, "tl_feat_adaption", None) is not None + # DETR: encoder/decoder 는 detector 에 있다. 층수·헤드수를 **모듈에서 읽는다**(config 아님). + enc = getattr(det, "encoder", None) + dec = getattr(det, "decoder", None) + # ⚠️ deformable attention 계열(Deformable DETR · DINO · DDQ)은 **조립기가 없다.** + # MRO 로는 DETRHead 로 떨어져 detr 조립기를 타는데, 가중치 이름부터 달라 크래시한다. + # "지원한다고 말하지 않는" 쪽이 맞다 — raw 로 내보내고 러너가 조용히 넘어가게 한다. + # (없는 것: 네트워크가 예측한 **소수점 좌표에서 읽는** bilinear 샘플링.) + detr_dims = {} + if dec is not None: + sa = dec.layers[0].self_attn + detr_dims = {"embed_dims": int(sa.embed_dims), "n_heads": int(sa.num_heads), + "enc_layers": len(enc.layers) if enc is not None else 0, + "dec_layers": len(dec.layers), + "n_points": int(getattr(dec.layers[0].cross_attn, "num_points", 4)), + "num_queries": int(getattr(det, "num_queries", 300)), + "ddq_iou_thr": float((getattr(det, "dqs_cfg", None) or {}) + .get("iou_threshold", 0.8))} + per_level_heads = False + head_tail = "" + # SSD 는 **타워가 없다** — `cls_convs[l]` 자체가 예측 conv(Sequential)다. 그대로 두면 + # 같은 conv 를 타워로 한 번, head 로 또 한 번 태운다. 구조로 알아본다: + # 타워 후보의 원소가 Sequential 이고 그 **마지막 Conv2d 출력 채널이 cls 채널 수**면 head 다. + def _last_conv(seq): + idx = [i for i, m in enumerate(seq) if isinstance(m, nn.Conv2d)] + return (idx[-1], seq[idx[-1]]) if idx else (None, None) + if getattr(bh, "convs_bridge", None) is not None: + # YOLOv3. 타워 반복이 없고 출력이 한 갈래다 — 채널이 na*(5+nc) 라 채널수 탐지에 안 걸린다. + cls_tower = reg_tower = "convs_bridge" + cls_head = reg_head = "convs_pred" + stacked, per_level_heads = 1, True + towers = bh.convs_bridge # 아래 활성화 탐지가 이 타워를 보게 한다 + elif towers and isinstance(towers[0], nn.Sequential): + i_last, last = _last_conv(towers[0]) + if last is not None and last.out_channels == num_base * ncls: + stacked, per_level_heads, head_tail = 0, True, f".{i_last}" + cls_head, reg_head = cls_tower, reg_tower + # 최종 conv 를 **출력 채널 수**로 알아본다 — 이름 표를 두면 계열이 늘 때마다 고쳐야 한다 + # (retina_cls / atss_cls / gfl_cls / conv_cls …). + # + # ⚠️ **채널 수가 겹치는 경우가 있다.** MOT 용 YOLOX 는 클래스가 "사람" 하나라 + # cls(=num_base·ncls=1) 와 objectness(=num_base=1) 가 **둘 다 1채널**이다. + # 한 번의 순회로 먼저 만난 것을 집으면 obj 가 cls 자리에 들어간다(실측: bytetrack). + # → **두 번 훑는다.** 이름이 확실한 세 번째 갈래를 먼저 걷어내고, 남은 것만 채널로 가른다. + # (같은 부류: DCN v1/v2 도 54채널이 양쪽에 걸려 다른 불변량으로 갈랐다 → 위키) + cands = [] + for name, mod in (() if cls_head else bh.named_children()): + if isinstance(mod, nn.ModuleList) and len(mod) and isinstance(mod[0], nn.Conv2d): + mod, per_level_heads = mod[0], True if isinstance(mod, nn.Conv2d): - if mod.out_channels == num_base * ncls: - cls_head = name - elif mod.out_channels == num_base * 4: - reg_head = name - # head 타워에 norm(GN 등) 있으면 conv 이름이 .conv, 없으면도 .conv (ConvModule) — 기록만. - has_norm = bool(getattr(bh, "norm_cfg", None)) + cands.append((name, mod)) + + # ① 이름이 분명한 갈래부터 확정한다(품질 점수 · mask 계수). + for name, mod in cands: + if "coeff" in name: + # YOLACT 의 mask coefficient. 채널이 na*num_protos 라 채널수로는 못 알아본다. + ctr_head, ctr_tanh = name, True + elif ctr_head is None and any(s in name for s in ("centerness", "iou", "obj")): + # 세 번째 갈래. **이름이 계열마다 다르다** — atss_centerness / conv_centerness / + # atss_iou(DDOD) / object_pred(YOLOF) / conv_obj(YOLOX). 하는 일은 같다. + ctr_head = name + + # ② 남은 것만 채널 수로 가른다. + for name, mod in cands: + if name in (ctr_head,): + continue + if cls_head is None and mod.out_channels == num_base * ncls: + cls_head = name + elif reg_head is None and mod.out_channels == reg_ch: + reg_head = name + + # ③ 그러고도 남는 Conv2d 가 있으면 **분기 앞 공유 conv** 다(RPNHead 의 `rpn_conv`). + # 컨테이너가 아니라 맨 Conv2d 라 타워 탐지에 안 걸린다. 채널이 안 변해 + # 빼먹어도 크래시가 없고 값만 틀린다(rpn 실측 L1 5.56). + for name, mod in cands: + if name not in (cls_head, reg_head, ctr_head) and mod.out_channels == feat_ch: + pre_conv = "bbox_head." + name + break + + # ConvModule 의 norm. 있으면 타워가 conv+GN+relu, 없으면 conv+relu 다. + # BatchNorm 은 conv 로 접는다(GGUF 가 running stats 를 안 실으므로) → 접은 뒤엔 norm 이 없다. + n_folded = fold_head_bn(bh) + # 활성화도 계열마다 다르다 — mmdet ConvModule 의 기본은 ReLU 인데 RTMDet 은 SiLU 다. + # **실제 모듈에서 읽는다**(config 의 act_cfg 는 head 가 덮어쓰는 경우가 있다). + # 활성화가 틀리면 shape 는 그대로고 값만 조금씩 작아진다 → 조용히 틀린다. + # ⚠️ **이름이 하나가 아니다.** SiLU 와 Swish 는 같은 함수인데 mmcv 는 `Swish` 라는 + # 자체 클래스로 등록한다(YOLOX). 이름 하나만 보면 조용히 ReLU 로 떨어진다 — 실측 L1 0.213. + _tw = towers[0][0] if per_level else (towers[0] if towers else None) + _act = type(getattr(_tw, "activate", None)).__name__ + head_leaky = float(getattr(getattr(_tw, "activate", None), "negative_slope", 0.0) or 0.0) + head_silu = _act in ("SiLU", "Swish", "MemoryEfficientSwish") + if _tw is not None and _act not in ("SiLU", "Swish", "MemoryEfficientSwish", + "ReLU", "LeakyReLU", "NoneType"): + # 모르는 활성화를 ReLU 로 떨어뜨리면 shape 는 맞고 값만 틀린다 → 조용히 통과한다. + print(f" ⚠️ 모르는 head 활성화 '{_act}' — ReLU 로 조립한다. 값이 틀릴 수 있다.") + norm_cfg = getattr(bh, "norm_cfg", None) or {} + has_norm = bool(norm_cfg) and not n_folded + gn_groups = int(norm_cfg.get("num_groups", 32)) if isinstance(norm_cfg, dict) else 32 + + # FCOS 만 bbox 에 후처리가 붙는다. norm_on_bbox 가 둘 중 어느 쪽인지 가른다. + # ⚠️ `kind` 가 아니라 **실제 클래스**로 판단한다. FoveaHead 도 anchor-free 라 kind 는 + # fcos 로 떨어지지만, bbox_pred 에 exp 를 안 건다(exp 는 feature_adaption 에만 쓴다). + is_fcos = any(c.__name__ == "FCOSHead" for c in type(bh).__mro__) + norm_on_bbox = bool(getattr(bh, "norm_on_bbox", False)) + bbox_clamp_stride = is_fcos and norm_on_bbox + # RTMDet 계열은 거리를 **stride 단위**로 낸다(`rtm_reg(...)[.exp()] * stride[0]`). + # `exp_on_reg` 속성의 유무가 그 계열이라는 표시다 — 이름으로 가르지 않는다. + is_rtmdet = hasattr(bh, "exp_on_reg") + bbox_exp = (is_fcos and not norm_on_bbox) or bool(getattr(bh, "exp_on_reg", False)) + bbox_mul_stride = is_rtmdet + # AutoAssign 은 `forward_single` 을 갈아끼워 **norm_on_bbox 와 무관하게** clamp·stride 를 + # 건다(`bbox_pred.clamp(min=0) * stride`). 그리고 centerness 를 항상 reg_feat 에서 뽑는데, + # `centerness_on_reg` 속성은 상속받은 기본값(False)이라 **속성만 보면 틀린다**. + # 구조적 표시는 `center_prior` — 이 계열에만 있는 서브모듈이다. + is_autoassign = hasattr(bh, "center_prior") + if is_autoassign: + bbox_exp, bbox_clamp_stride = False, True # ── 전처리(pre) 메타: mmdet data_preprocessor(모델 안 서브모듈)에서 추출 ── # normalize mean/std(픽셀스케일 0-255) + 채널변환. vision.cpp preprocess() 가 소비. @@ -82,7 +431,7 @@ def postproc_cfg(det): to_rgb = not bool(getattr(dp, "_channel_conversion", False)) return { - "head_type": "anchor", + "head_type": kind, # ── 전처리(pre) — 이미지→텐서 (vision.cpp preprocess) ── "img_mean": img_mean, "img_std": img_std, @@ -93,28 +442,145 @@ def postproc_cfg(det): "octave_base_scale": obs, "octave_scales": [s / obs for s in scales], # =2^(i/n) "ratios": ratios, - "center_offset": float(getattr(pg, "center_offset", 0.0) or 0.0), + "center_offset": float(getattr(pg, "center_offset", 0.0) or 0.0) if pg is not None else 0.0, "means": [float(v) for v in getattr(bc, "means", [0.0] * 4)], "stds": [float(v) for v in getattr(bc, "stds", [1.0] * 4)], + "can_decode": can_decode and uniform_priors, # ── C++ head 부품(anchor_head_forward)용 구조 ── "num_base": num_base, "stacked_convs": stacked, + "reg_stacked_convs": reg_stacked, "feat_channels": feat_ch, - "cls_convs_prefix": "bbox_head.cls_convs", - "reg_convs_prefix": "bbox_head.reg_convs", + "cls_convs_prefix": "bbox_head." + cls_tower, + "reg_convs_prefix": "bbox_head." + reg_tower, "cls_head": "bbox_head." + (cls_head or "retina_cls"), "reg_head": "bbox_head." + (reg_head or "retina_reg"), "head_has_norm": has_norm, + "gn_groups": gn_groups, + "per_level_towers": per_level, + "per_level_heads": per_level_heads, + "per_level_head_tail": head_tail, + # 곁가지 — 없으면 조립기가 그 단계를 건너뛴다. + "centerness_head": ("bbox_head." + ctr_head) if ctr_head else "", + "head_leaky": head_leaky, + "pre_conv": pre_conv, + "ctr_tanh": ctr_tanh, + "corner_emb": corner_emb, + "corner_centripetal": corner_centripetal, + **detr_dims, + "centerness_on_reg": is_autoassign or bool(getattr(bh, "centerness_on_reg", True)), + "scales_prefix": "bbox_head.scales" if hasattr(bh, "scales") else "", + "bbox_exp": bbox_exp, + "bbox_clamp_stride": bbox_clamp_stride, + "bbox_mul_stride": bbox_mul_stride, + "head_silu": head_silu, + "reg_max": reg_max, + # VFNet 의 레벨별 정규화 범위. stride 에서 유도하면 안 된다 — 마지막 레벨만 두 배다. + "reg_denoms": [float(v) for v in getattr(bh, "reg_denoms", []) or []], } def build(config, checkpoint=None, size=512): """mmdet config(.py) → (MMDetBackbone(eval), feature shapes, postproc cfg).""" from mmdet.apis import init_detector # mmdet 만 import (g2c 무관) - det = init_detector(config, checkpoint, device="cpu").eval() - m = MMDetBackbone(det).eval() + trace_friendly_ops() # trace 가 삼키는 커스텀 op 을 등가 수식으로 + allow_mmengine_checkpoint_globals() # v3 체크포인트의 학습 메타 허용 + # ⚠️ `.eval()` 을 체이닝하지 마라. `nn.Module.eval()` 은 `self.train(False)` 의 반환값을 + # 그대로 돌려주는데, 증류 계열(`KnowledgeDistillationSingleStageDetector` — ld·lad)이 + # `train()` 을 오버라이드하며 `return self` 를 빠뜨렸다. 체이닝하면 det 이 None 이 되고, + # 한참 뒤 속성 접근에서 터져 원인 지점을 잃는다. + det = init_detector(config, checkpoint, device="cpu") + det.eval() + _unwrap_checkpoint_forward(det) + m = MMDetBackbone(det) + m.eval() cfg = postproc_cfg(det) cfg["img_size"] = int(size) # 정방 resize 크기 (pre) with torch.no_grad(): outs = m(torch.randn(1, 3, size, size)) + # CenterNet 은 prior_generator 가 없다(heatmap 최대점을 쓰므로 anchor 가 필요 없다). + # 그래도 러너는 레벨 수·stride 를 알아야 한다 — **실제 feature 크기에서 역산**한다. + if not cfg.get("strides"): + cfg["strides"] = [float(size) / float(o.shape[-1]) for o in outs] + + # DETR 계열의 sine positional encoding 은 **입력 크기가 정해지면 상수**다. C++ 에서 + # 공식을 다시 구현하면 틀릴 여지가 생기므로 **mmdet 자신의 모듈로 계산해** GGUF 에 굽는다. + # 레이아웃도 여기서 맞춘다: mmdet 은 (bs, C, H, W) → flatten(2).permute → (bs, H*W, C). + # (H*W, C) 로 저장하면 ggml ne = {C, H*W} 라 조립기가 그대로 쓴다. + pe = getattr(det, "positional_encoding", None) + if pe is not None and outs: + with torch.no_grad(): + o = outs[0] + mask = torch.zeros((1, o.shape[2], o.shape[3]), dtype=torch.bool) + pos = pe(mask) # (1, C, H, W) + pos = pos.flatten(2).permute(0, 2, 1)[0].contiguous() + m.register_buffer("pos_embed", pos) + m.head_weight_prefixes = tuple(m.head_weight_prefixes) + ("pos_embed",) + + # Conditional DETR 계열의 **참조점**도 상수다. `reference = sigmoid(ref_point_head(query_pos))` + # 이고 query_pos 는 학습된 embedding(입력과 무관)이라, 거기서 나오는 sine 인코딩도 상수다. + # C++ 에서 sine 공식을 다시 짜지 않고 **mmdet 자신의 함수로** 계산해 굽는다. + dec = getattr(det, "decoder", None) + rph = getattr(dec, "ref_point_head", None) + # ⚠️ DAB-DETR 도 `ref_point_head` 를 갖지만 **입력이 다르다** — query_embedding 이 4D 앵커라 + # 그 sine 인코딩(2*dim)을 받는다. 입력 폭이 query_pos 와 같을 때만 이 경로다. + qemb = getattr(det, "query_embedding", None) # two-stage 계열(DDQ·DINO)은 아예 없다 + if (rph is not None and qemb is not None + and int(rph.layers[0].weight.shape[1]) == int(qemb.weight.shape[1])): + from mmdet.models.layers.transformer.utils import coordinate_to_encoding + from mmdet.models.layers.transformer.utils import inverse_sigmoid + with torch.no_grad(): + qp = det.query_embedding.weight[None] # (1, nq, dim) + ref = dec.ref_point_head(qp).sigmoid() # (1, nq, 2 또는 4) + sine = coordinate_to_encoding(ref[..., :2])[0].contiguous() # (nq, dim) + inv = inverse_sigmoid(ref[0, :, :2]) + # head 는 박스 앞 2채널에만 이 값을 더한다 → (nq, 4) 로 만들어 그냥 더하게 한다. + pad = torch.zeros((inv.shape[0], 4)) + pad[:, :2] = inv + m.register_buffer("ref_sine_embed", sine) + m.register_buffer("ref_inv_pad", pad.contiguous()) + m.head_weight_prefixes = tuple(m.head_weight_prefixes) + ("ref_sine_embed", "ref_inv_pad") + + # Deformable 계열(Deformable DETR · DINO · DDQ): 다중 레벨 위치 인코딩(level_embed 포함)과 + # **encoder 참조점**은 입력 크기에만 의존하는 상수다. mmdet 자신의 `pre_transformer` 로 + # 계산해 굽는다 — 레벨 오프셋·정규화 규약을 C++ 에서 다시 짜면 틀릴 여지가 생긴다. + if getattr(det, "level_embed", None) is not None: + from mmdet.structures import DetDataSample + ds = DetDataSample() + ds.set_metainfo({"batch_input_shape": (size, size), "img_shape": (size, size)}) + with torch.no_grad(): + ei = det.pre_transformer(tuple(outs), [ds])[0] + lvl_pos = ei["feat_pos"][0].contiguous() # (sum_HW, dim) + ss = ei["spatial_shapes"] + vr = ei["valid_ratios"] + enc_ref = det.encoder.get_encoder_reference_points(ss, vr, device="cpu") + enc_ref = enc_ref[0].reshape(enc_ref.shape[1], -1).contiguous() # (sum_HW, 2L) + m.register_buffer("pos_embed", lvl_pos) + m.register_buffer("enc_ref", enc_ref) + m.head_weight_prefixes = tuple(m.head_weight_prefixes) + ("pos_embed", "enc_ref") + + # two-stage(DINO·DDQ): encoder 출력에서 proposal 을 만들어 상위 k 개를 query 로 쓴다. + # proposal 격자와 유효 마스크는 **입력 크기에만 의존하는 상수**다 — 구워서 넘긴다. + if getattr(det, "memory_trans_fc", None) is not None: + with torch.no_grad(): + mem0 = torch.zeros(1, int(lvl_pos.shape[0]), int(lvl_pos.shape[1])) + _, proposals = det.gen_encoder_output_proposals(mem0, None, ss) + valid = torch.isfinite(proposals[0]).all(-1, keepdim=True).float() + m.register_buffer("enc_proposals", proposals[0].contiguous()) + m.register_buffer("enc_valid", valid.contiguous()) + m.head_weight_prefixes = tuple(m.head_weight_prefixes) + ("enc_proposals", "enc_valid") + + # DAB-DETR: 참조점이 층마다 갱신돼 상수로 못 굽는다. 대신 `coordinate_to_encoding` 이 + # 쓰는 **고정 계수**만 굽는다 — 2π/dim_t 와 짝/홀 자리 마스크. + # (인접한 두 dim_t 가 같은 값이라 결과는 sin·cos 이 번갈아 놓인 꼴이 된다.) + if dec is not None and getattr(dec, "ref_point_head", None) is not None: + import math + nf = int(dec.embed_dims) // 2 + dim_t = 10000.0 ** (2 * (torch.arange(nf, dtype=torch.float32) // 2) / nf) + m.register_buffer("dab_inv_dim_t", (2 * math.pi / dim_t).contiguous()) + idx = torch.arange(nf) + m.register_buffer("dab_even", (idx % 2 == 0).float().contiguous()) + m.register_buffer("dab_odd", (idx % 2 == 1).float().contiguous()) + m.head_weight_prefixes = tuple(m.head_weight_prefixes) + ( + "dab_inv_dim_t", "dab_even", "dab_odd") return m, [tuple(o.shape) for o in outs], cfg diff --git a/tools/verify/backbone/run_frcnn.cpp b/tools/verify/backbone/run_frcnn.cpp new file mode 100644 index 0000000..4541c37 --- /dev/null +++ b/tools/verify/backbone/run_frcnn.cpp @@ -0,0 +1,333 @@ +// run_frcnn.cpp — two-stage 검출기(Faster/Mask R-CNN 계열)를 **2패스**로 돌린다. +// +// dense head 는 한 그래프로 끝나지만 two-stage 는 안 된다. RPN 이 낸 후보에서 NMS 로 +// proposal 을 고르고(개수·좌표가 실행 중에 정해진다), 그 좌표에서 feature 를 잘라내 +// (RoIAlign) 두 번째 head 에 넣는다. 둘 다 **값에 따라 달라지는 동작**이라 정적 그래프로 +// 표현할 수 없다 — `ddq` 와 같은 이유다. +// +// 패스 0: SubA(백본+FPN+RPN) → 호스트 rpn_proposals() → roi_align() +// 패스 1: SubB(RoI head) → 호스트 detect_roi() +// +// 호스트 부품 셋(`rpn_proposals`·`roi_align`·`detect_roi`)은 `postproc.h` 에 이미 있다. +// 이 파일은 그것들을 잇는 배선이다. +// +// 컴파일: -DARCH_A= -DARCH_B= +// -DVISP_ARCH_HEADER_A='"..."' -DVISP_ARCH_HEADER_B='"..."' +#include VISP_ARCH_HEADER_A +#include VISP_ARCH_HEADER_B + +#include "visp/ml.h" +#include "visp/postproc.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace visp; + +#define CAT_(a, b) a##b +#define CAT(a, b) CAT_(a, b) +#define FWD_A CAT(ARCH_A, _forward) +#define PRM_A CAT(ARCH_A, _detect_params) +#define FWD_B CAT(ARCH_B, _forward) +#define PRM_B CAT(ARCH_B, _detect_params) + +static std::vector load_bin(const char* path, size_t n) { + std::vector v(n); + std::ifstream f(path, std::ios::binary); + f.read(reinterpret_cast(v.data()), n * sizeof(float)); + return v; +} + +static void dump_bin(std::string const& path, std::vector const& v) { + if (FILE* f = fopen(path.c_str(), "wb")) { + fwrite(v.data(), sizeof(float), v.size(), f); + fclose(f); + } +} + +// 아주 작은 JSON 스칼라/배열 리더. 프론트엔드가 낸 `frcnn.json` 만 읽으면 되므로 +// 완전한 파서가 필요 없다 — 키를 찾아 그 뒤 숫자를 읽는다. +struct tiny_json { + std::string s; + explicit tiny_json(const char* path) { + std::ifstream f(path); + s.assign(std::istreambuf_iterator(f), std::istreambuf_iterator()); + } + size_t find_key(const std::string& k) const { + size_t p = s.find("\"" + k + "\""); + return p == std::string::npos ? p : s.find(':', p) + 1; + } + float num(const std::string& k, float dflt) const { + size_t p = find_key(k); + if (p == std::string::npos) return dflt; + if (s.compare(p, 5, "true") == 0 || s.compare(p + 1, 4, "true") == 0) return 1.0f; + if (s.find("false", p) == p || s.find("false", p + 1) == p + 1) return 0.0f; + return strtof(s.c_str() + p, nullptr); + } + std::vector arr(const std::string& k) const { + std::vector out; + size_t p = find_key(k); + if (p == std::string::npos) return out; + size_t l = s.find('[', p), r = s.find(']', l); + const char* c = s.c_str() + l + 1; + while (c < s.c_str() + r) { + char* e = nullptr; + float v = strtof(c, &e); + if (e == c) { ++c; continue; } + out.push_back(v); + c = e; + } + return out; + } +}; + +// 그래프 출력 out_0..out_{n-1} 을 호스트로 읽는다. +static std::vector> read_outputs(compute_graph const& g, int n, + std::vector>* hw) { + std::vector> out; + for (int i = 0; i < n; ++i) { + tensor t = ggml_graph_get_tensor(g.graph, ("out_" + std::to_string(i)).c_str()); + if (!t) break; + std::vector d(ggml_nelements(t)); + transfer_from_backend(t, std::span(d.data(), d.size())); + if (hw) hw->push_back({(int)t->ne[2], (int)t->ne[1]}); // cwhn: ne1=W, ne2=H + out.push_back(std::move(d)); + } + return out; +} + +int main(int argc, char** argv) { + if (argc < 6) { + fprintf(stderr, + "usage: %s [size]\n", + argv[0]); + return 1; + } + const char* ga = argv[1]; + const char* gb = argv[2]; + tiny_json J(argv[3]); + const char* inb = argv[4]; + const std::string pref = argv[5]; + const int SZ = argc > 6 ? atoi(argv[6]) : 512; + + backend_device backend = backend_init(); + + // ── 패스 0: SubA = 백본 + FPN + RPN head ───────────────────────────────── + model_file fa = model_load(ga); + model_weights wa = model_init(fa.n_tensors()); + model_transfer(fa, wa, backend, backend.preferred_float_type(), fa.tensor_layout()); + compute_graph g0 = compute_graph_init(262144); + model_ref ma(wa, g0); + tensor input = compute_graph_input(ma, GGML_TYPE_F32, {3, SZ, SZ, 1}, "x"); + ggml_build_forward_expand(g0, input); + ggml_build_forward_expand(g0, FWD_A(ma, input, PRM_A(fa))); + compute_graph_allocate(g0, backend); + auto in = load_bin(inb, (size_t)3 * SZ * SZ); + transfer_to_backend(input, std::span(in.data(), in.size())); + compute(g0, backend); + + // SubA 출력 규약(frcnn_wrap.FRCNN_SubA): RoI 레벨 feats(NF) + rpn_cls×L + rpn_bbox×L + // ⚠️ **NF 를 4 로 박으면 안 된다.** FPN 계열은 P2..P5 로 4개지만, C4 계열(TridentNet)은 + // neck 이 없어 백본 C4 **하나**만 온다. `roi_strides` 길이가 정답이다 + // (프론트엔드의 `_n_roi_levels` 와 같은 근거를 쓴다). + std::vector> all_hw; + auto outs = read_outputs(g0, 64, &all_hw); + const int L = (int)J.arr("rpn_strides").size(); + const int NF = (int)J.arr("roi_strides").size(); + if ((int)outs.size() < NF + 2 * L) { + fprintf(stderr, "SubA 출력 %zu 개 < 기대 %d (feats %d + rpn %d×2)\n", + outs.size(), NF + 2 * L, NF, L); + return 3; + } + std::vector> feats(outs.begin(), outs.begin() + NF); + std::vector> rpn_cls(outs.begin() + NF, outs.begin() + NF + L); + std::vector> rpn_box(outs.begin() + NF + L, outs.begin() + NF + 2 * L); + std::vector> feat_hw(all_hw.begin(), all_hw.begin() + NF); + std::vector> rpn_hw(all_hw.begin() + NF, all_hw.begin() + NF + L); + + // ── 호스트: RPN proposal (동적 — NMS) ──────────────────────────────────── + rpn_params rp; + rp.strides = J.arr("rpn_strides"); + rp.octave_base_scale = J.num("rpn_scale", 8.0f); + rp.ratios = J.arr("rpn_ratios"); + rp.nms_pre = (int)J.num("rpn_nms_pre", 1000); + rp.nms_thr = J.num("rpn_nms_thr", 0.7f); + rp.max_per_img = (int)J.num("rpn_max", 1000); + rp.input_w = rp.input_h = SZ; + std::vector props = rpn_proposals(rpn_cls, rpn_box, rpn_hw, rp); + const int M = (int)(props.size() / 4); + fprintf(stderr, "[frcnn] proposal %d 개 (nms %.2f)\n", M, rp.nms_thr); + if (M == 0) { + fprintf(stderr, "proposal 이 0 개다 — RPN 출력/규약 확인\n"); + return 4; + } + + // ── 호스트: RoIAlign (동적 — 좌표가 실행 중에 정해진다) ────────────────── + roi_align_params ap; + ap.output_size = (int)J.num("roi_out", 7); + // ⚠️ C4 계열은 FPN 이 없어 채널이 256 이 아니다(TridentNet=1024). + ap.channels = (int)J.num("roi_channels", 256.0f); + ap.strides = J.arr("roi_strides"); + ap.finest_scale = J.num("roi_finest_scale", 56.0f); + ap.sampling_ratio = (int)J.num("roi_sampling_ratio", 0); + ap.aligned = J.num("roi_aligned", 1.0f) != 0.0f; + std::vector roi = roi_align(feats, feat_hw, props.data(), M, ap); + + // 캐스케이드는 단계마다 박스를 정제하고 **그 박스로 RoIAlign 을 다시** 한다. + // 단계 수와 단계별 정규화 상수는 프론트엔드가 frcnn.json 에 실어 준다. + const int NS = (int)J.num("num_bbox_stages", 1); + + // ── 패스 1..NS: SubB = RoI head (단계마다) ───────────────────── + // 캐스케이드 단계들은 **구조가 같고 가중치만 다르다**(Shared2FCBBoxHead x3). + // 그래서 그래프는 매 단계 같은 함수로 짜고 gguf 만 갈아 \łłłł끼운다. + const int O = ap.output_size, C = ap.channels; + const std::vector st_stds = J.arr("stage_stds"), st_means = J.arr("stage_means"); + std::vector> cls_st, box_st; + std::vector rois = props; + + // gguf 경로는 쉼표로 구분해 단계 수만큼 받는다. + std::vector gbs; + { + std::string s(gb), cur; + for (char ch : s) { if (ch == ',') { gbs.push_back(cur); cur.clear(); } else cur += ch; } + gbs.push_back(cur); + } + if ((int)gbs.size() != NS) { + fprintf(stderr, "SubB gguf %zu 개 != 단계 %d\n", gbs.size(), NS); + return 6; + } + + // Double-Head R-CNN: 회귀용 RoI 는 상자를 중심 기준 `RSF` 배 키워 **다시** 자른다 + // (mmdet `BaseRoIExtractor.roi_rescale` — 이미지 경계로 자르지 않는다). + // 그래프 입력은 하나뿐이라 두 벌을 **배치 방향으로 이어** 넘기고, SubB 래퍼가 가른다. + const float RSF = J.num("reg_roi_scale_factor", 0.0f); + auto rescale_boxes = [&](std::vector const& b) { + std::vector o(b.size()); + for (size_t i = 0; i + 3 < b.size(); i += 4) { + const float cx = (b[i] + b[i + 2]) * 0.5f, cy = (b[i + 1] + b[i + 3]) * 0.5f; + const float w = (b[i + 2] - b[i]) * RSF * 0.5f; + const float h = (b[i + 3] - b[i + 1]) * RSF * 0.5f; + o[i] = cx - w; o[i + 1] = cy - h; o[i + 2] = cx + w; o[i + 3] = cy + h; + } + return o; + }; + auto with_reg_half = [&](std::vector rf, std::vector const& boxes) { + if (RSF <= 0.0f) return rf; + std::vector bx = rescale_boxes(boxes); + std::vector rg = roi_align(feats, feat_hw, bx.data(), M, ap); + rf.insert(rf.end(), rg.begin(), rg.end()); + return rf; + }; + + for (int st = 0; st < NS; ++st) { + std::vector roi_st = with_reg_half( + (st == 0) ? roi : roi_align(feats, feat_hw, rois.data(), M, ap), + (st == 0) ? props : rois); + const int MB = (RSF > 0.0f) ? 2 * M : M; // SubB 에 넣는 행 수 + + model_file fb = model_load(gbs[st].c_str()); + model_weights wb = model_init(fb.n_tensors()); + model_transfer(fb, wb, backend, backend.preferred_float_type(), fb.tensor_layout()); + compute_graph g1 = compute_graph_init(65536); + model_ref mb(wb, g1); + tensor rin = compute_graph_input(mb, GGML_TYPE_F32, {C, O, O, MB}, "roi"); + ggml_build_forward_expand(g1, rin); + ggml_build_forward_expand(g1, FWD_B(mb, rin, PRM_B(fb))); + compute_graph_allocate(g1, backend); + // roi_align 은 NCHW flat 을 낸다. 생성 코드는 cwhn 규약이라 채널을 ne0 로 돌린다. + std::vector roi_cwhn((size_t)MB * C * O * O); + for (int n = 0; n < MB; ++n) + for (int c = 0; c < C; ++c) + for (int y = 0; y < O; ++y) + for (int x = 0; x < O; ++x) + roi_cwhn[(((size_t)n * O + y) * O + x) * C + c] = + roi_st[(((size_t)n * C + c) * O + y) * O + x]; + transfer_to_backend(rin, std::span(roi_cwhn.data(), roi_cwhn.size())); + compute(g1, backend); + + auto bo = read_outputs(g1, 8, nullptr); + if (bo.size() < 2) { + fprintf(stderr, "SubB 출력이 2개 미만이다(cls_score, bbox_pred 필요)\n"); + return 5; + } + // ⚠️ head 가 (cls, box) **한 쌍만** 낸다고 가정하면 안 된다. CrowdDet 의 + // `MultiInstanceBBoxHead` 는 proposal 하나가 사람 둘을 낼 수 있다고 보고 + // 쌍을 2벌 낸다(총 4텐서). 앞 두 개만 재면 **나머지 절반이 검증 안 된 채** + // 통과한다 — 이 저장소가 계속 물린 "조용히 틀림" 부류다. + // 쌍 단위로 전부 담고, 단계 정제(캐스케이드)에는 0번 쌍만 쓴다. + const int NPAIR = (int)bo.size() / 2; + for (int p = 0; p < NPAIR; ++p) { + cls_st.push_back(bo[2 * p]); + box_st.push_back(bo[2 * p + 1]); + } + // 캐스케이드 정제·덤프 인덱스는 **단계**를 세는 것이라 쌍이 여럿이면 어긋난다. + // 지금 지원 조합은 (다단계 × 1쌍) 또는 (1단계 × 여러 쌍) 둘 중 하나다. + if (NS > 1 && NPAIR > 1) { + fprintf(stderr, "캐스케이드(%d단계)와 다중 인스턴스(%d쌍)를 함께 못 쓴다\n", + NS, NPAIR); + return 5; + } + if (st == 0) roi = roi_st; // 덤프용(1단계 RoI feature) + + if (st + 1 < NS) { + // ── 호스트: 박스 정제 (mmdet `regress_by_class`) ── + // 예측 클래스(배경 제외 argmax)의 delta 만 골라 디코드한다. + // ⚠ 단계마다 stds 가 다르다(0.1 -> 0.05 -> 0.033) — 하나로 쓰면 조용히 틀린다. + const int NC = (int)(cls_st[st].size() / M) - 1; // 배경 제외 + const bool agn = J.num("class_agnostic", 0.0f) != 0.0f; + const bool has_s = st_stds.size() >= (size_t)(st + 1) * 4; + const bool has_m = st_means.size() >= (size_t)(st + 1) * 4; + std::vector nb((size_t)M * 4); + for (int i = 0; i < M; ++i) { + int best = 0; + for (int c = 1; c < NC; ++c) + if (cls_st[st][(size_t)i * (NC + 1) + c] > cls_st[st][(size_t)i * (NC + 1) + best]) + best = c; + const size_t off = (size_t)i * (agn ? 4 : (size_t)NC * 4) + (agn ? 0 : (size_t)best * 4); + float d[4]; + for (int k = 0; k < 4; ++k) { + d[k] = box_st[st][off + k]; + if (has_s) d[k] *= st_stds[(size_t)st * 4 + k]; + if (has_m) d[k] += st_means[(size_t)st * 4 + k]; + } + const float x1 = rois[(size_t)i * 4 + 0], y1 = rois[(size_t)i * 4 + 1]; + const float x2 = rois[(size_t)i * 4 + 2], y2 = rois[(size_t)i * 4 + 3]; + const float pw = x2 - x1, ph = y2 - y1; + const float cx = x1 + pw * 0.5f + d[0] * pw, cy = y1 + ph * 0.5f + d[1] * ph; + const float w = pw * std::exp(d[2]), h = ph * std::exp(d[3]); + nb[(size_t)i * 4 + 0] = std::max(0.0f, cx - w * 0.5f); + nb[(size_t)i * 4 + 1] = std::max(0.0f, cy - h * 0.5f); + nb[(size_t)i * 4 + 2] = std::min((float)SZ, cx + w * 0.5f); + nb[(size_t)i * 4 + 3] = std::min((float)SZ, cy + h * 0.5f); + } + rois.swap(nb); + } + } + + // ── 덤프 (torch 대조용) ───────────────────────────────────────────────── + dump_bin(pref + ".props.bin", props); + dump_bin(pref + ".roi.bin", roi); + // 인덱스는 **담은 쌍의 수**로 돈다 — 캐스케이드면 단계 수, 다중 인스턴스면 쌍 수다. + // `NS` 로 돌면 CrowdDet 처럼 1단계·2쌍인 경우 뒤쪽 쌍이 덤프되지 않아 + // torch 대조에서 **절반이 빠진 채** 통과한다. + for (size_t k = 0; k < cls_st.size(); ++k) { + dump_bin(pref + ".cls." + std::to_string(k) + ".bin", cls_st[k]); + dump_bin(pref + ".box." + std::to_string(k) + ".bin", box_st[k]); + } + dump_bin(pref + ".rois.bin", rois); + for (int l = 0; l < L; ++l) { + dump_bin(pref + ".rpncls." + std::to_string(l) + ".bin", rpn_cls[l]); + dump_bin(pref + ".rpnbox." + std::to_string(l) + ".bin", rpn_box[l]); + } + printf("- frcnn: 2패스 (proposal %d · roi %dx%d) → %s.*.bin\n", M, O, O, pref.c_str()); + return 0; +} diff --git a/tools/verify/backbone/run_mmdet.cpp b/tools/verify/backbone/run_mmdet.cpp index d12fd5c..b76db75 100755 --- a/tools/verify/backbone/run_mmdet.cpp +++ b/tools/verify/backbone/run_mmdet.cpp @@ -3,26 +3,31 @@ // 백본 = g2c 가 생성한 output/.cpp (그대로 컴파일) → _forward // head = tools/detect/head.cpp 부품 (러너와 함께 컴파일, 라이브러리 아님) // decode+NMS = src/visp/postproc.cpp detect_anchor (라이브러리) -// cfg = .postproc.json (tools/frontend/mmdet/mmdet_to_pt.py 가 생성) +// cfg = .postproc.h (mmdet_params() from mmdet_to_pt.py, compiled in) // // 백본을 arch/ 로 복사하거나 cli REG 에 등록하지 않는다 — output/.cpp 를 직접 컴파일해 // libvisioncpp 와 링크(build_mmdet_cpp.sh). run_yolo_cpp 와 동일한 -DARCH 매크로 방식. // // 컴파일: -DARCH=<클래스명> -DVISP_ARCH_HEADER='"/.h"' -// 실행: run_mmdet [size=512] +// run: run_mmdet [size=512] +// an output ending in .bin holds raw f32 for comparison; anything else is an image #include VISP_ARCH_HEADER // 백본: _forward / _params / _detect_params -#include "head.h" // head 부품: anchor_head_forward (같은 폴더) +#include "head.h" + +#include // head 부품: anchor_head_forward (같은 폴더) +#include "draw.h" // draws detections onto the image (same folder) +#include MMDET_PARAMS_HEADER // generated mmdet_params(); values live in the binary #include "visp/image.h" // image_load (이미지 입력 pre) #include "visp/ml.h" #include "visp/postproc.h" // detect_anchor, preprocess, det_params, detection #include -#include +#include #include +#include #include -#include #include #include #include @@ -52,40 +57,51 @@ static std::vector to_vec(tensor t) { return d; } -// 입력이 이미지(.jpg/.png…)면 preprocess()(resize+normalize+to_rgb, postproc.json 메타)로 텐서 생성. -// .bin 이면 이미 전처리된 CWHN f32 텐서로 간주. → yolo run_yolo_cpp 는 .bin(외부 전처리)만, 여기선 둘 다. -static std::vector load_input(const char* path, int SZ, nlohmann::json const& j) { +// An image input goes through preprocess() with the generated constants; +// a .bin is taken as an already pre-processed CWHN f32 tensor. +static bool has_ext(std::string const& s, const char* e) { + size_t n = std::strlen(e); + return s.size() >= n && s.compare(s.size() - n, n, e) == 0; +} + +static bool is_image_path(std::string const& s) { + return has_ext(s, ".jpg") || has_ext(s, ".jpeg") || has_ext(s, ".png") || has_ext(s, ".bmp"); +} + +// A non-empty `source` means the input was an image, so the result can be drawn on it. +static std::vector load_input(const char* path, int SZ, mmdet_cfg const& c, + image_data* source) { std::string s(path); - auto ext = [&](const char* e) { - size_t n = std::strlen(e); - return s.size() >= n && s.compare(s.size() - n, n, e) == 0; - }; - if (ext(".jpg") || ext(".jpeg") || ext(".png") || ext(".bmp")) { + if (is_image_path(s)) { image_data img = image_load(path); int iw = img.extent[0], ih = img.extent[1]; int ic = n_channels(img.format); // stbi_load(...,0)=네이티브 채널수 (JPEG=3, PNG+α=4) - float mean[3] = {0, 0, 0}, sd[3] = {1, 1, 1}; - if (j.contains("img_mean")) { auto v = j["img_mean"].get>(); for (int i = 0; i < 3; ++i) mean[i] = v[i]; } - if (j.contains("img_std")) { auto v = j["img_std"].get>(); for (int i = 0; i < 3; ++i) sd[i] = v[i]; } - bool to_rgb = j.value("to_rgb", false); + float const (&mean)[3] = c.img_mean; + float const (&sd)[3] = c.img_std; + bool to_rgb = c.to_rgb; printf("- preprocess: image %dx%dx%d → %dx%d (mean %.1f,%.1f,%.1f std %.1f,%.1f,%.1f to_rgb=%d)\n", iw, ih, ic, SZ, SZ, mean[0], mean[1], mean[2], sd[0], sd[1], sd[2], (int)to_rgb); - return preprocess(img.data.get(), ih, iw, ic, SZ, mean, sd, to_rgb); + auto tensor_data = preprocess(img.data.get(), ih, iw, ic, SZ, mean, sd, to_rgb); + if (source) { + *source = std::move(img); + } + return tensor_data; } return load_bin(path, (size_t)3 * SZ * SZ); } int main(int argc, char** argv) { - if (argc < 5) { + if (argc < 4) { fprintf(stderr, - "usage: %s [size=512]\n", argv[0]); + "usage: %s [size=512]\n" + " output ending in .bin holds raw float32 detections;\n" + " any other extension is an image with the boxes drawn on it\n", argv[0]); return 1; } const char* gguf = argv[1]; const char* inp = argv[2]; - const char* jsonp = argv[3]; - const char* outp = argv[4]; - const int SZ = argc > 5 ? atoi(argv[5]) : 512; + const char* outp = argv[3]; + const int SZ = argc > 4 ? atoi(argv[4]) : 512; // 1) 가중치 (백본 + head 전부 이 gguf 에) backend_device backend = backend_init(); @@ -103,33 +119,17 @@ int main(int argc, char** argv) { tensor bb = FWD(m, input, p); ggml_build_forward_expand(graph, bb); - // 3) postproc.json → head-conv cfg + anchor decode cfg - nlohmann::json j; - { std::ifstream jf(jsonp); if (!jf) { fprintf(stderr, "cannot open %s\n", jsonp); return 1; } jf >> j; } - int L = (int)j["strides"].size(); - - anchor_head_cfg hc; - hc.stacked_convs = j.value("stacked_convs", 4); - hc.feat_channels = j.value("feat_channels", 256); - hc.num_base = j.value("num_base", 9); - hc.num_classes = j.value("num_classes", 80); - hc.cls_convs_prefix = j.value("cls_convs_prefix", std::string("bbox_head.cls_convs")); - hc.reg_convs_prefix = j.value("reg_convs_prefix", std::string("bbox_head.reg_convs")); - hc.cls_head = j.value("cls_head", std::string("bbox_head.retina_cls")); - hc.reg_head = j.value("reg_head", std::string("bbox_head.retina_reg")); - - det_params dp; - dp.strides = j["strides"].get>(); - dp.octave_base_scale = j.value("octave_base_scale", 4.0f); - dp.octave_scales = j["octave_scales"].get>(); - dp.ratios = j["ratios"].get>(); - dp.center_offset = j.value("center_offset", 0.0f); - dp.num_classes = j.value("num_classes", 80); - dp.use_sigmoid = j.value("use_sigmoid", true); + // 3) Configuration -- constants fixed at compile time. No file is read. + mmdet_cfg cfg = mmdet_params(); + anchor_head_cfg& hc = cfg.head; + det_params& dp = cfg.det; dp.input_w = SZ; dp.input_h = SZ; - { auto mn = j["means"].get>(); auto sd = j["stds"].get>(); - for (int i = 0; i < 4; ++i) { dp.means[i] = mn[i]; dp.stds[i] = sd[i]; } } + int L = (int)dp.strides.size(); + if (L == 0) { + fprintf(stderr, "no FPN strides — this config's head was not recognised at export\n"); + return 1; + } // 4) 백본 features(out_0..L-1) 를 잡아 head 부품 조립 std::vector feats; @@ -138,16 +138,156 @@ int main(int argc, char** argv) { if (!f) { fprintf(stderr, "백본 출력 out_%d 없음 (백본 .cpp 출력 규약 확인)\n", l); return 3; } feats.push_back(f); } - std::vector cls_t, box_t; - anchor_head_forward(m, feats, hc, cls_t, box_t); + // deformable 을 쓰는 계열(vfnet/reppoints)은 고정 3×3 격자가 필요하다. mmdet 의 + // dcn_base_offset 과 같은 값 — [y,x] 가 번갈아 놓인 18개다. 계열이 안 쓰면 그냥 남는다. + // ⚠️ 안 쓰는 계열에서 만들면 그래프에 안 실려 버퍼가 없고, add_buffer 가 + // "tensor buffer not set" 으로 죽는다. 쓰는 계열에서만 만든다. + tensor dcn_base = nullptr; + if (hc.kind == head_kind::vfnet || hc.kind == head_kind::reppoints) { + dcn_base = compute_graph_input(m, GGML_TYPE_F32, {18, 1, 1, 1}, "dcn_base"); + tensor_data d = tensor_alloc(dcn_base); + std::span v = d.as_f32(); + for (int i = 0; i < 9; ++i) { + v[2 * i] = float(i / 3 - 1); // y: -1,-1,-1, 0,0,0, 1,1,1 + v[2 * i + 1] = float(i % 3 - 1); // x: -1,0,1 반복 + } + m.add_buffer(std::move(d)); + } + + // ── DDQ 만 다중 패스다 ────────────────────────────────────────────────── + // decoder 층마다 호스트 NMS 로 중복 query 를 가려야 해서 한 그래프로 못 돈다(head.h). + // 패스 0(백본+encoder) → NMS → 패스 1..N(층 하나씩, 사이마다 NMS). + if (hc.kind == head_kind::ddq) { + std::vector lv; + ddq_levels(feats, lv); + ddq_stage s0; + ddq_encode_forward(m, feats, hc, s0); + for (tensor x : {s0.mem, s0.om, s0.ecls, s0.ecoord, s0.qmap}) + ggml_build_forward_expand(graph, x); + compute_graph_allocate(graph, backend); + image_data src0; + auto in0 = load_input(inp, SZ, cfg, &src0); + transfer_to_backend(input, std::span(in0.data(), in0.size())); + compute(graph, backend); + + const int64_t T = s0.mem->ne[1], E = s0.mem->ne[0], NC = s0.ecls->ne[0]; + const int64_t NQ = hc.num_queries; + std::vector mem_v = to_vec(s0.mem), qm_v = to_vec(s0.qmap); + std::vector ec_v = to_vec(s0.ecls), eb_v = to_vec(s0.ecoord); + + // 호스트: 점수 = sigmoid(클래스 최댓값), 박스 = cxcywh→xyxy(sigmoid(coord)). + auto sigm = [](float v) { return 1.0f / (1.0f + std::exp(-v)); }; + std::vector dets((size_t)T); + for (int64_t i = 0; i < T; ++i) { + float best = -1e30f; + for (int64_t k = 0; k < NC; ++k) best = std::max(best, ec_v[i * NC + k]); + float cx = sigm(eb_v[i * 4 + 0]), cy = sigm(eb_v[i * 4 + 1]); + float w = sigm(eb_v[i * 4 + 2]), h = sigm(eb_v[i * 4 + 3]); + dets[i] = {cx - w * 0.5f, cy - h * 0.5f, cx + w * 0.5f, cy + h * 0.5f, sigm(best), 0}; + } + // 이미 있는 NMS 를 그대로 쓴다(postproc.cpp — "mmcv nms 동일"). + std::vector keep = nms(dets, hc.ddq_iou_thr); + if ((int64_t)keep.size() > NQ) keep.resize((size_t)NQ); + if ((int64_t)keep.size() < NQ) { + fprintf(stderr, "ddq: NMS 후 %zu 개 < num_queries %lld — 그래프 모양이 안 맞는다\n", + keep.size(), (long long)NQ); + return 5; + } + // DDQ 는 query 내용을 학습 embedding 이 아니라 **선택된 위치의 feature** 로 채운다. + std::vector q_v((size_t)E * NQ), r_v((size_t)4 * NQ); + for (int64_t i = 0; i < NQ; ++i) { + const int64_t t0 = keep[(size_t)i]; + std::copy_n(&qm_v[(size_t)t0 * E], E, &q_v[(size_t)i * E]); + for (int k = 0; k < 4; ++k) r_v[(size_t)i * 4 + k] = sigm(eb_v[(size_t)t0 * 4 + k]); + } + fprintf(stderr, "[ddq] NMS 후보 %lld → 유지 %zu (임계 %.2f)\n", + (long long)T, keep.size(), hc.ddq_iou_thr); + std::vector mask_v((size_t)NQ * NQ, 0.0f); // 가산 마스크(0 = 통과) + std::vector alive((size_t)NQ, 1); // 0층은 전부 살아있다 + + std::vector> cls_out, box_out; + for (int li = 0; li < hc.dec_layers; ++li) { + compute_graph g = compute_graph_init(65536); + model_ref mi(weights, g); + tensor memT = compute_graph_input(mi, GGML_TYPE_F32, {E, T, 1, 1}, "mem"); + tensor qT = compute_graph_input(mi, GGML_TYPE_F32, {E, NQ, 1, 1}, "q"); + tensor rT = compute_graph_input(mi, GGML_TYPE_F32, {4, NQ, 1, 1}, "ref"); + tensor kT = compute_graph_input(mi, GGML_TYPE_F32, {NQ, NQ, 1, 1}, "mask"); + for (tensor x : {memT, qT, rT, kT}) ggml_build_forward_expand(g, x); + ddq_stage si; + ddq_layer_forward(mi, lv, hc, li, memT, qT, rT, kT, si); + for (tensor x : {si.query, si.ref, si.cls, si.box}) ggml_build_forward_expand(g, x); + compute_graph_allocate(g, backend); + transfer_to_backend(memT, std::span(mem_v.data(), mem_v.size())); + transfer_to_backend(qT, std::span(q_v.data(), q_v.size())); + transfer_to_backend(rT, std::span(r_v.data(), r_v.size())); + transfer_to_backend(kT, std::span(mask_v.data(), mask_v.size())); + compute(g, backend); + + cls_out.push_back(to_vec(si.cls)); + box_out.push_back(to_vec(si.box)); + q_v = to_vec(si.query); + r_v = to_vec(si.ref); + if (li + 1 < hc.dec_layers) { + // 다음 층 마스크. mmdet 규약 두 가지를 그대로 따른다: + // ① NMS 는 **지금까지 살아남은 query 끼리만** 돈다(집합이 단조 감소한다). + // ② 셀 (i,j) 는 **행이나 열 중 하나라도** 살아있으면 통과, 아니면 가린다. + // (mmdet 주석: "if it requires to keep index i, then all cells in row + // or column i should be kept") + std::vector ori; + for (int64_t i = 0; i < NQ; ++i) if (alive[(size_t)i]) ori.push_back((int)i); + std::vector dq(ori.size()); + for (size_t n = 0; n < ori.size(); ++n) { + const int64_t i = ori[n]; + float best = -1e30f; + for (int64_t k = 0; k < NC; ++k) + best = std::max(best, cls_out.back()[(size_t)i * NC + k]); + float cx = r_v[(size_t)i * 4 + 0], cy = r_v[(size_t)i * 4 + 1]; + float w = r_v[(size_t)i * 4 + 2], h = r_v[(size_t)i * 4 + 3]; + dq[n] = {cx - w * 0.5f, cy - h * 0.5f, cx + w * 0.5f, cy + h * 0.5f, + sigm(best), 0}; + } + std::vector next((size_t)NQ, 0); + for (int k : nms(dq, hc.ddq_iou_thr)) next[(size_t)ori[(size_t)k]] = 1; + alive.swap(next); + const float NEG = -1e30f; + for (int64_t i = 0; i < NQ; ++i) + for (int64_t j = 0; j < NQ; ++j) + mask_v[(size_t)i * NQ + j] = + (alive[(size_t)i] || alive[(size_t)j]) ? 0.0f : NEG; + } + } + if (const char* pre = std::getenv("MMDET_DUMP_HEAD")) { + auto dump = [&](const char* kind, size_t l, std::vector const& v) { + std::string path = std::string(pre) + "." + kind + "." + std::to_string(l) + ".bin"; + if (FILE* f = fopen(path.c_str(), "wb")) { + fwrite(v.data(), sizeof(float), v.size(), f); fclose(f); + } + }; + for (size_t l = 0; l < cls_out.size(); ++l) dump("cls", l, cls_out[l]); + for (size_t l = 0; l < box_out.size(); ++l) dump("box", l, box_out[l]); + printf("- ddq: %d 패스 (호스트 NMS %d 회)\n", hc.dec_layers + 1, hc.dec_layers); + } + return 0; + } + + head_outputs ho; + mmdet_head_forward(m, feats, hc, dcn_base, ho); + std::vector&cls_t = ho.cls, &box_t = ho.box; for (tensor t : cls_t) ggml_build_forward_expand(graph, t); for (tensor t : box_t) ggml_build_forward_expand(graph, t); + for (tensor t : ho.ctr) ggml_build_forward_expand(graph, t); + // ⚠️ extra 갈래도 **그래프에 넣어야** 버퍼가 생긴다. 안 넣고 읽으면 + // `GGML_ASSERT(buf != NULL && "tensor buffer not set")` 로 죽는다. + for (auto const& e : ho.extra) + for (tensor t : e.second) ggml_build_forward_expand(graph, t); printf("- mmdet runner: 백본 out_0..%d + C++ head(%d convs, %s)\n", L - 1, hc.stacked_convs, hc.cls_head.c_str()); // 5) 계산 (입력: 이미지면 preprocess, .bin 이면 전처리된 텐서) compute_graph_allocate(graph, backend); - auto in_data = load_input(inp, SZ, j); + image_data source; + auto in_data = load_input(inp, SZ, cfg, &source); if (const char* dp = std::getenv("MMDET_DUMP_PRE")) { // 디버그: 전처리 텐서 덤프 FILE* f = fopen(dp, "wb"); if (f) { fwrite(in_data.data(), sizeof(float), in_data.size(), f); fclose(f); } } @@ -158,6 +298,38 @@ int main(int argc, char** argv) { transfer_to_backend(input, std::span(in_data.data(), in_data.size())); compute(graph, backend); + // 5b) head 검증용 원시 덤프. **디코드 전** 값을 그대로 내보내 torch 의 bbox_head 출력과 + // 직접 대조한다 — NMS 를 거치면 어디가 틀렸는지 못 짚는다. + if (const char* pre = std::getenv("MMDET_DUMP_HEAD")) { + auto dump = [&](const char* kind, size_t l, tensor t) { + std::vector v = to_vec(t); + std::string path = std::string(pre) + "." + kind + "." + std::to_string(l) + ".bin"; + if (FILE* f = fopen(path.c_str(), "wb")) { + fwrite(v.data(), sizeof(float), v.size(), f); + fclose(f); + } + }; + for (size_t l = 0; l < cls_t.size(); ++l) dump("cls", l, cls_t[l]); + for (size_t l = 0; l < box_t.size(); ++l) dump("box", l, box_t[l]); + for (size_t l = 0; l < ho.ctr.size(); ++l) dump("ctr", l, ho.ctr[l]); + // 갈래가 셋을 넘는 계열(CornerHead 6갈래). 이름은 조립기가 정한다. + for (auto const& e : ho.extra) + for (size_t l = 0; l < e.second.size(); ++l) dump(e.first.c_str(), l, e.second[l]); + printf("- head raw dump: %s.{cls,box%s}..bin\n", pre, + ho.ctr.empty() ? "" : ",ctr"); + } + + // 코너 계열은 anchor 디코드가 아예 없다(코너 짝짓기 + embedding 그룹핑). 조립·덤프까지가 + // 이 러너의 몫이고, 그 뒤를 억지로 detect_anchor 에 넣으면 의미 없는 박스가 나온다. + if (hc.kind == head_kind::cornernet) return 0; + + // 조립기가 레벨 수를 못 채우면 아래 인덱싱이 널을 읽는다. 여기서 말한다. + if ((int)cls_t.size() < L || (int)box_t.size() < L) { + fprintf(stderr, "head 출력이 %d 레벨에 못 미친다 (cls %zu, box %zu)\n", + L, cls_t.size(), box_t.size()); + return 4; + } + // 6) raw cls/box → detect_anchor (decode + NMS) std::vector> cls_v(L), box_v(L); std::vector> feat_hw(L); @@ -168,12 +340,56 @@ int main(int argc, char** argv) { } std::vector dets = detect_anchor(cls_v, box_v, feat_hw, dp); - FILE* f = fopen(outp, "wb"); - for (detection const& d : dets) { - float rec[6] = { d.x1, d.y1, d.x2, d.y2, d.score, (float)d.label }; - fwrite(rec, sizeof(float), 6, f); + // An image by default, as with every other entry point here. Raw numbers on request. + std::string out_s(outp); + bool want_raw = has_ext(out_s, ".bin"); + if (!want_raw && source.extent[0] == 0) { + fprintf(stderr, "- input was a tensor, so there is no image to draw on; writing raw\n"); + want_raw = true; + } + + if (want_raw) { + FILE* f = fopen(outp, "wb"); + if (!f) { fprintf(stderr, "cannot write %s\n", outp); return 1; } + for (detection const& d : dets) { + float rec[6] = { d.x1, d.y1, d.x2, d.y2, d.score, (float)d.label }; + fwrite(rec, sizeof(float), 6, f); + } + fclose(f); + printf("- detect(anchor): %zu boxes → %s (x1,y1,x2,y2,score,label f32*6)\n", + dets.size(), outp); + } else { + float thr = 0.3f; + if (const char* e = std::getenv("VISP_DRAW_THRESHOLD")) { + thr = (float)atof(e); + } + // Coordinates are in the square input, so scale them back to the original resolution. + float sx = float(source.extent[0]) / float(SZ); + float sy = float(source.extent[1]) / float(SZ); + int drawn = draw_detections(source, dets, sx, sy, thr); + image_save(source, outp); + printf("- detect(anchor): %zu boxes, %d drawn at score >= %.2f → %s\n", + dets.size(), drawn, thr, outp); + } + + // The highest-scoring detections, so a run says something without opening the output. + // VISP_PRINT_DETS sets how many; 0 turns it off. + int n_print = 10; + if (const char* e = std::getenv("VISP_PRINT_DETS")) { + n_print = atoi(e); + } + n_print = std::min(n_print, (int)dets.size()); + if (n_print > 0) { + printf("\n %5s %8s %8s %8s %8s %8s %6s\n", "#", "x1", "y1", "x2", "y2", "score", "label"); + for (int i = 0; i < n_print; ++i) { + detection const& d = dets[i]; + printf(" %5d %8.1f %8.1f %8.1f %8.1f %8.3f %6d\n", + i, d.x1, d.y1, d.x2, d.y2, d.score, d.label); + } + if ((int)dets.size() > n_print) { + printf(" %5s (%zu more)\n", "...", dets.size() - n_print); + } + printf("\n"); } - fclose(f); - printf("- detect(anchor): %zu boxes → %s (x1,y1,x2,y2,score,label f32*6)\n", dets.size(), outp); return 0; } diff --git a/tools/verify/dense_head/fetch_checkpoints.py b/tools/verify/dense_head/fetch_checkpoints.py new file mode 100644 index 0000000..c011089 --- /dev/null +++ b/tools/verify/dense_head/fetch_checkpoints.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""fetch_checkpoints.py — 계열별 대표 config 의 학습 체크포인트를 받는다. + +왜 이게 필요한가 +--------------- +검증은 **학습된 가중치로만** 의미가 있다. 랜덤 초기화는 γ=1·β=0 같은 항등 초기값이 +빠진 연산을 덮어버려, 오늘 잡은 부류의 버그(GroupNorm affine·mmcv.Scale 누락)를 +**정의상** 못 잡는다. 그래서 체크포인트가 없으면 `verify_heads.py` 는 아예 안 돌린다. + +그런데 어떤 체크포인트가 어느 config 짝인지를 **손으로 적으면** 목록에 없는 계열이 +"존재하지 않는 것"처럼 보인다(실제로 `pisa`·`rpn` 이 그렇게 몇 주 동안 안 보였다). +mmdet 은 계열마다 `metafile.yml` 에 `Config → Weights` 를 적어 둔다 — 그걸 읽는다. + +사용: + python fetch_checkpoints.py # 100계열 전부(이미 있으면 건너뜀) + python fetch_checkpoints.py --dry-run # 무엇을 받을지만 출력 + python fetch_checkpoints.py atss dino # 일부만 +""" +import glob +import os +import sys +import urllib.request +from concurrent.futures import ThreadPoolExecutor + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.stdout.reconfigure(line_buffering=True) +import vconfig # noqa: E402 + +CFG, ARGS = vconfig.load() +DRY = "--dry-run" in ARGS +ARGS = [a for a in ARGS if not a.startswith("-")] +MM, CKPT = CFG.configs, CFG.ckpt + +import mmdet_families # noqa: E402 + +SKIP_DIRS = mmdet_families.SKIP_DIRS + + +def one(fam): + # ⚠️ `verify_heads.py` 와 **같은 함수**로 고른다 — 대표 config 선택이 어긋나면 + # 받은 가중치와 컴파일하는 config 가 달라져, 로드는 되는데 일부가 랜덤으로 남는다. + _, name, url = mmdet_families.resolve(MM, fam) + if not url: + return fam, "NO_METAFILE", "-" + dst = os.path.join(CKPT, name) + if os.path.exists(dst) and os.path.getsize(dst) > 0: + return fam, "HAVE", name + if DRY: + return fam, "WOULD_GET", name + try: + tmp = dst + ".part" + urllib.request.urlretrieve(url, tmp) + os.replace(tmp, dst) + return fam, "GOT", f"{name} ({os.path.getsize(dst)/1e6:.0f}MB)" + except Exception as e: + return fam, "FAIL", f"{type(e).__name__}: {e}"[:70] + + +def main(): + print(CFG.banner()) + os.makedirs(CKPT, exist_ok=True) + fams = [d for d in sorted(os.listdir(MM)) + if os.path.isdir(os.path.join(MM, d)) and d not in SKIP_DIRS] + if ARGS: + fams = [f for f in fams if f in ARGS] + rows = [] + # 네트워크 대기라 스레드로 충분하다. 서버에 무리 주지 않게 4개만. + with ThreadPoolExecutor(4) as ex: + for i, (fam, st, info) in enumerate(ex.map(one, fams), 1): + rows.append((fam, st, info)) + print(f"[{i:3d}/{len(fams)}] {fam:22s} {st:12s} {info}") + print() + for st in ("HAVE", "GOT", "WOULD_GET", "NO_WEIGHTS", "NO_METAFILE", "FAIL"): + n = sum(1 for r in rows if r[1] == st) + if n: + print(f"{st:12s} {n}") + + +if __name__ == "__main__": + main() diff --git a/tools/verify/dense_head/head_support_map.py b/tools/verify/dense_head/head_support_map.py new file mode 100644 index 0000000..a2c9976 --- /dev/null +++ b/tools/verify/dense_head/head_support_map.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""head_support_map.py — 100계열의 **head 지원 여부**만 조사한다(체크포인트 불필요). + +왜 따로인가 +---------- +`verify_heads.py` 는 체크포인트가 없으면 `CKPT_NONE` 으로 먼저 끊는다. 그건 옳다 — +랜덤 가중치로 수치를 재면 항등 초기값(γ=1·β=0)이 누락 연산을 덮어 **조용히 통과**한다. + +그런데 그 때문에 표에서 두 가지가 뒤섞인다: + + "체크포인트만 받으면 되는 것" vs "조립기가 아예 없는 것" + +앞은 곧 잴 수 있고 뒤는 새 부품이 필요하다 — 성격이 완전히 다른데 한 칸에 들어간다. + +**head 의 구조는 가중치와 무관하다.** config 로만 모델을 세워 `postproc_cfg` 가 어떤 +`head_type` 을 내는지 보면, 체크포인트 없이도 둘을 가를 수 있다. 수치는 재지 않는다 — +이 스크립트는 "지원하나" 만 답하고, "맞나" 는 `verify_heads.py` 가 답한다. + +사용: + python head_support_map.py # 100계열 전부 + python head_support_map.py atss ddq # 일부만 +""" +import glob +import os +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor + +# 파이프로 보내도 줄 단위로 나가게 한다(진행이 안 보이면 죽었는지 도는지 모른다). +sys.stdout.reconfigure(line_buffering=True) + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import vconfig # noqa: E402 + +V = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +FE = V + "/tools/frontend/mmdet" +CFG, ARGS = vconfig.load() +MM, PY = CFG.configs, sys.executable +WORKERS, WORK = CFG.workers, CFG.probe_workdir +# ⚠️ **트래커·반지도 래퍼는 config 를 한 겹 벗겨야 한다.** ByteTrack 등은 검출기를 +# `model.detector` 안에 넣고 자기는 껍데기만 갖는다 → `init_detector` 가 로드 전에 죽는다 +# (`'ConfigDict' object has no attribute 'backbone'`). 안 쓰면 7계열이 INIT_FAIL 로 나와 +# **모델 탓처럼 보인다** — 실제로는 조사 도구 탓이다. +UNWRAP = CFG.unwrap +DEPLOY = os.path.dirname(os.path.dirname(os.path.dirname(UNWRAP))) + +# `verify_heads.py`·`sweep_integ.py` 와 같은 목록. 모델이 아닌 폴더다(데이터셋 변형·레시피·뼈대). +SKIP_DIRS = {"_base_", "common", "misc", "legacy_1.x", "strong_baselines", "selfsup_pretrain", + "scratch", "dsdl", "objects365", "lvis", "openimages", "cityscapes", "wider_face", + "pascal_voc", "deepfashion", "v3det"} + +PROBE = r''' +import sys, types +for n in ("mmpretrain.models.multimodal.blip", "mmpretrain.models.multimodal.blip.language_model"): + m = types.ModuleType(n); m.__path__ = []; sys.modules[n] = m +sys.path.insert(0, %(FE)r) +import mmdet_wrap +mmdet_wrap.trace_friendly_ops() +from mmdet.apis import init_detector +det = init_detector(%(CFG)r, None, device="cpu") # 가중치 없이 구조만 본다 +det.eval() +cfg = mmdet_wrap.postproc_cfg(det) +print("HEADTYPE", cfg.get("head_type", "?"), type(getattr(det, "bbox_head", None)).__name__) +''' + + +def pick_cfg(d): + """대표 config **후보 목록**(점수순). 1순위가 없는 형제를 상속해 죽는 경우가 있어 + (`sort` 의 mot20 → FileNotFoundError) 차순위까지 시도해야 한다 — sweep_integ 와 같은 규약.""" + cands = [f for f in glob.glob(os.path.join(d, "*.py")) + if not os.path.basename(f).startswith("_") + and not os.path.splitext(os.path.basename(f))[0].endswith("_base")] + if not cands: + return None + + def score(f): + n, s = os.path.basename(f), 0 + for kw, w in (("r50", 4), ("fpn", 3), ("1x", 3), ("coco", 4), ("r18", 2)): + if kw in n: + s += w + return -s + return sorted(cands, key=score) + + +def _unwrap(cfg, workdir): + """래퍼 config 를 안쪽 검출기로 푼다. 풀 필요 없으면 원본을 그대로 돌려준다.""" + if not os.path.exists(UNWRAP): + return cfg + out = os.path.join(workdir, "cfg.py") + try: + r = subprocess.run([PY, UNWRAP, cfg, "-o", out], cwd=DEPLOY, capture_output=True, + text=True, timeout=300) + except Exception: + return cfg + if r.returncode == 0: + lines = (r.stdout or "").strip().splitlines() + if lines and os.path.exists(lines[-1].strip()): + return lines[-1].strip() + return cfg + + +def probe(name): + cands = pick_cfg(os.path.join(MM, name)) + if not cands: + return name, "NO_CONFIG", "-" + wd = os.path.join(WORK, name) + os.makedirs(wd, exist_ok=True) + err = "-" + for cfg in cands[:4]: + cfg = _unwrap(cfg, wd) + # ⚠️ mmdetection 루트에서 돌린다 — 증류 계열 config 가 교사 모델을 + # **CWD 기준 상대경로**로 적는다(`_base_` 와 달리 config 위치 기준이 아니다). + r = subprocess.run([PY, "-c", PROBE % {"FE": FE, "CFG": cfg}], + cwd=os.path.dirname(MM.rstrip("/")), capture_output=True, text=True, + timeout=600, env={**os.environ, "OMP_NUM_THREADS": "1", + "MPLCONFIGDIR": "/tmp"}) + for line in r.stdout.splitlines(): + if line.startswith("HEADTYPE"): + _, kind, cls = line.split(None, 2) + return name, ("HEAD_NONE" if kind == "raw" else "SUPPORTED"), f"{kind:16s} {cls}" + err = (r.stderr.strip().splitlines() or ["-"])[-1] + return name, "INIT_FAIL", err[:80] + + +def main(): + print(CFG.banner(), flush=True) + os.makedirs(WORK, exist_ok=True) + names = [d for d in sorted(os.listdir(MM)) + if os.path.isdir(os.path.join(MM, d)) and d not in SKIP_DIRS] + if ARGS: + names = [n for n in names if n in ARGS] + rows = [] + with ThreadPoolExecutor(WORKERS) as ex: + for name, st, info in ex.map(probe, names): + rows.append((name, st, info)) + print(f"{name:22s} {st:10s} {info}", flush=True) + print() + for st in ("SUPPORTED", "HEAD_NONE", "INIT_FAIL", "NO_CONFIG"): + n = sum(1 for r in rows if r[1] == st) + if n: + print(f"{st:10s} {n}") + + +if __name__ == "__main__": + main() diff --git a/tools/verify/dense_head/mmdet_families.py b/tools/verify/dense_head/mmdet_families.py new file mode 100644 index 0000000..d304ba0 --- /dev/null +++ b/tools/verify/dense_head/mmdet_families.py @@ -0,0 +1,90 @@ +"""mmdet_families.py — 계열 → (대표 config, 체크포인트) 매핑. **검증 도구들이 공유한다.** + +왜 공유해야 하나 +--------------- +config 와 체크포인트는 **짝이 맞아야** 한다. 도구마다 대표 config 를 따로 고르면 +`fetch_checkpoints.py` 가 받은 가중치와 `verify_heads.py` 가 컴파일하는 config 가 +어긋나고, 그러면 로드는 되는데(strict=False) 일부가 랜덤으로 남아 **조용히 틀린다.** + +그리고 매핑을 **손으로 적으면** 목록에 없는 계열이 존재하지 않는 것처럼 보인다 — +`pisa`·`rpn` 이 실제로 그랬다. mmdet 은 계열마다 `metafile.yml` 에 +`Config → Weights` 를 적어 둔다. 그걸 읽는다. +""" +import glob +import os + +# 모델이 아닌 폴더(데이터셋 변형·학습 레시피 변형·뼈대 조각). 대조 스크립트로 확인했다 — +# 이 안에 다른 폴더에 없는 아키텍처는 하나도 없다. `sweep_integ.py` 와 같은 목록. +SKIP_DIRS = {"_base_", "common", "misc", "legacy_1.x", "strong_baselines", "selfsup_pretrain", + "scratch", "dsdl", "objects365", "lvis", "openimages", "cityscapes", "wider_face", + "pascal_voc", "deepfashion", "v3det"} + + +def pick_cfgs(fam_dir): + """대표 config 후보(점수순). `_` 로 시작하거나 `_base` 로 끝나는 뼈대는 뺀다. + + 후보를 여럿 돌려주는 이유: 1순위가 없는 형제 config 를 상속해 죽는 계열이 있다 + (`sort` 의 mot20 → FileNotFoundError). 차순위까지 시도해야 한다. + """ + cands = [f for f in glob.glob(os.path.join(fam_dir, "*.py")) + if not os.path.basename(f).startswith("_") + and not os.path.splitext(os.path.basename(f))[0].endswith("_base")] + + def score(f): + n, s = os.path.basename(f), 0 + for kw, w in (("r50", 4), ("fpn", 3), ("1x", 3), ("coco", 4), ("r18", 2)): + if kw in n: + s += w + return -s + return sorted(cands, key=score) + + +def weights_map(configs_root, fam): + """{config 파일명: 체크포인트 URL} — 그 계열 `metafile.yml` 에서.""" + import yaml + mf = os.path.join(configs_root, fam, "metafile.yml") + if not os.path.exists(mf): + return {} + try: + d = yaml.safe_load(open(mf)) or {} + except Exception: + return {} + out = {} + for m in (d.get("Models") or []): + cfg, w = m.get("Config"), m.get("Weights") + # ⚠️ `Weights` 가 **리스트인 계열이 있다**(여러 체크포인트 나열). 첫 개를 쓴다. + if isinstance(w, (list, tuple)): + w = w[0] if w else None + if cfg and isinstance(w, str) and w.startswith("http"): + out[os.path.basename(cfg)] = w + return out + + +def resolve(configs_root, fam): + """(config 상대경로, 체크포인트 파일명, URL). 못 찾으면 해당 항목이 None. + + **metafile 에 가중치가 있는 첫 후보**를 고른다 — 그래야 config 와 체크포인트가 짝이 맞는다. + 가중치가 하나도 없으면 1순위 config 와 `None` 을 돌려준다(그 계열은 CKPT_NONE 이 된다). + """ + cands = pick_cfgs(os.path.join(configs_root, fam)) + if not cands: + return None, None, None + wm = weights_map(configs_root, fam) + for cfg in cands: + url = wm.get(os.path.basename(cfg)) + if url: + return os.path.relpath(cfg, configs_root), os.path.basename(url), url + return os.path.relpath(cands[0], configs_root), None, None + + +def families(configs_root): + """[(계열, config 상대경로, 체크포인트 파일명 or None)] — 아키텍처 계열 전부.""" + out = [] + for name in sorted(os.listdir(configs_root)): + d = os.path.join(configs_root, name) + if not os.path.isdir(d) or name in SKIP_DIRS: + continue + cfg, ckpt, _ = resolve(configs_root, name) + if cfg: + out.append((name, cfg, ckpt)) + return out diff --git a/tools/verify/dense_head/vconfig.py b/tools/verify/dense_head/vconfig.py new file mode 100644 index 0000000..9a785e5 --- /dev/null +++ b/tools/verify/dense_head/vconfig.py @@ -0,0 +1,94 @@ +"""vconfig.py — `verify.toml` 을 읽는다. 검증 도구들이 공유한다. + +**왜 환경변수를 걷어냈나.** env 로 설정을 받으면 어떤 값으로 잰 숫자인지 로그에 안 남는다. +같은 명령을 쳐도 셸마다 다른 결과가 나오고, 재현이 안 되면 그 숫자는 근거가 못 된다. +그래서 기본값은 파일에 두고, 덮어쓰려면 `--set key=value` 로만 — 그것도 실행 시 찍는다. +""" +import os +import sys + +try: + import tomllib # py3.11+ +except ModuleNotFoundError: # pragma: no cover + import tomli as tomllib # type: ignore + +HERE = os.path.dirname(os.path.abspath(__file__)) +DEFAULT = os.path.join(HERE, "verify.toml") + + +def _resolve(v): + """상대경로는 **이 파일 위치 기준**으로 절대화한다. `~` 도 편다.""" + v = os.path.expanduser(v) + return v if os.path.isabs(v) else os.path.normpath(os.path.join(HERE, v)) + + +class Config: + def __init__(self, path=None, overrides=()): + self.path = path or os.environ.get("VERIFY_CONFIG", DEFAULT) + with open(self.path, "rb") as f: + self.d = tomllib.load(f) + self.overrides = [] + for kv in overrides: + k, _, v = kv.partition("=") + sec, _, key = k.strip().partition(".") + if not key or sec not in self.d or key not in self.d[sec]: + raise SystemExit(f"--set {kv}: verify.toml 에 없는 키다 (예: run.workers=2)") + cur = self.d[sec][key] + self.d[sec][key] = type(cur)(v) if not isinstance(cur, bool) else v == "true" + self.overrides.append(f"{sec}.{key}={self.d[sec][key]}") + + # ── 경로 ── + @property + def mmdet(self): return _resolve(self.d["paths"]["mmdet"]) + @property + def configs(self): return os.path.join(self.mmdet, "configs") + @property + def ckpt(self): return os.path.join(self.mmdet, "checkpoints") + @property + def g2c(self): return _resolve(self.d["paths"]["g2c"]) + @property + def unwrap(self): return _resolve(self.d["paths"]["unwrap"]) + @property + def workdir(self): return _resolve(self.d["paths"]["workdir"]) + @property + def probe_workdir(self): return _resolve(self.d["paths"]["probe_workdir"]) + + # ── 실행 ── + @property + def workers(self): return int(self.d["run"]["workers"]) + @property + def opt(self): return self.d["run"]["opt"] + @property + def size(self): return int(self.d["run"]["size"]) + @property + def min_free_mb(self): return int(self.d["run"]["min_free_mb"]) + @property + def l1(self): return float(self.d["tolerance"]["l1"]) + @property + def l2(self): return float(self.d["tolerance"]["l2"]) + + def banner(self): + """무엇으로 쟀는지 로그 맨 위에 남긴다 — 이게 없으면 숫자가 근거가 못 된다.""" + s = (f"config {self.path}\n" + f" mmdet={self.mmdet}\n g2c={self.g2c}\n workdir={self.workdir}\n" + f" workers={self.workers} size={self.size} opt={self.opt}" + f" tol=L1{self.l1}/L2{self.l2}") + if self.overrides: + s += "\n --set " + " ".join(self.overrides) + return s + + +def load(argv=None): + """`--config PATH` 와 `--set a.b=v` 를 걷어내고 (Config, 남은 인자) 를 돌려준다.""" + argv = list(sys.argv[1:] if argv is None else argv) + path, sets, rest = None, [], [] + i = 0 + while i < len(argv): + a = argv[i] + if a == "--config" and i + 1 < len(argv): + path = argv[i + 1]; i += 2 + elif a == "--set" and i + 1 < len(argv): + sets.append(argv[i + 1]); i += 2 + else: + rest.append(a); i += 1 + return Config(path, sets), rest diff --git a/tools/verify/dense_head/verify.toml b/tools/verify/dense_head/verify.toml new file mode 100644 index 0000000..67a313e --- /dev/null +++ b/tools/verify/dense_head/verify.toml @@ -0,0 +1,39 @@ +# verify.toml — mmdet head 검증 설정. +# +# 예전에는 이 값들이 전부 환경변수였다. 그러면 **재현이 안 된다** — 어떤 값으로 잰 숫자인지 +# 로그에 안 남고, 다음 사람이 같은 명령을 쳐도 다른 결과가 나온다. +# 여기 적힌 값이 기본이고, 실행 시 `--set key=value` 로만 덮어쓴다(그것도 로그에 찍힌다). +# +# 상대경로는 **이 파일 위치 기준**이다. + +[paths] +# mmdet 저장소. 아래에 `configs/` 와 `checkpoints/` 가 있어야 한다. +mmdet = "~/mmbuild/mmdetection" +# g2c(컴파일러) 루트. 기본값은 vision.cpp 의 부모 = 이 브랜치의 g2c. +g2c = "../../../.." +# 트래커·반지도 래퍼 config 를 안쪽 검출기로 푸는 전처리기(형제 저장소). +# 없으면 그 계열만 INIT_FAIL 로 남는다 — 없다고 전체가 멈추지는 않는다. +unwrap = "../../../../../GTX_Compiler/test_script/mmdet/mmdet_unwrap_config.py" +# 중간 산출물(.pt · 생성 .cpp · gguf · 덤프). 지워도 된다. +workdir = "/tmp/visp-verify-heads" +# head 지원 여부 조사(head_support_map.py) 전용 작업 폴더. +probe_workdir = "/tmp/visp-headmap" + +[run] +# 동시 실행 계열 수. ⚠️ WSL 12GB 에서 4를 넘기면 터진다 — 계열마다 torch 를 띄운다. +workers = 3 +# 검증 빌드 최적화. 수치는 libggml 이 내므로 -O1 이면 충분하고 컴파일이 빠르다. +opt = "-O1" +# 입력 정방 크기. +size = 512 +# 이 아래로 가용 메모리가 떨어지면 새 계열 시작을 미룬다(MB). +min_free_mb = 2500 + +[tolerance] +# 판정 기준: 출력 텐서의 **상대 L1/L2 거리**. 둘 다 이하여야 PASS. +# rel_L1 = Σ|a−b| / Σ|a| 평균적으로 얼마나 어긋났나 +# rel_L2 = ‖a−b‖ / ‖a‖ 큰 오차에 더 민감 +# cosine 은 쓰지 않는다 — **스케일 불변**이라 크기가 통째로 틀려도 1.0 이 나온다 +# (rtmdet 실측: cos 0.999450 인데 값은 97% 틀렸다). +l1 = 0.05 +l2 = 0.05 diff --git a/tools/verify/dense_head/verify_heads.py b/tools/verify/dense_head/verify_heads.py new file mode 100644 index 0000000..0fa29a5 --- /dev/null +++ b/tools/verify/dense_head/verify_heads.py @@ -0,0 +1,737 @@ +#!/usr/bin/env python3 +"""tools/detect/head.cpp 가 mmdet 의 bbox_head 를 재현하는지 계열별로 잰다. + +백본만 컴파일하고 head 는 C++ 부품이 조립한다. 계열마다: +mmdet → backbone .pt + 파라미터 헤더 → 컴파일 → run_mmdet 빌드 → +MMDET_DUMP_HEAD 로 **디코드 전** 원시 텐서 덤프 → torch bbox_head 출력과 대조. +점수는 **출력 텐서의 상대 L1/L2 거리**다(cosine 은 스케일 불변이라 안 쓴다). + +디코드/NMS 앞에서 끊는 이유: NMS 를 거치면 어느 텐서가 틀렸는지 못 짚는다. + + python verify_heads.py # 8계열 전부 + python verify_heads.py vfnet tood # 골라서 + +체크포인트가 `~/mmbuild/mmdetection/checkpoints/` 에 있어야 한다. +**랜덤 초기화로 재지 마라** — 항등 초기값(γ=1·β=0, scale=1)이 빠진 연산을 덮어 +검증을 통과시킨다. 실제로 VFNet 의 scale 하드코딩이 그렇게 숨어 있었다. +""" +import os +import re +import subprocess +import sys + +# (계열, config, 학습된 체크포인트). **랜덤 초기화로 재지 않는다** — 항등 초기값이 +# 빠진 연산을 덮어 검증을 통과시킨다(group_norm affine 이 실제로 그랬다). +FAMILIES = [ + ("retinanet", "retinanet/retinanet_r18_fpn_1x_coco.py", + "retinanet_r18_fpn_1x_coco_20220407_171055-614fd399.pth"), + ("atss", "atss/atss_r50_fpn_1x_coco.py", + "atss_r50_fpn_1x_coco_20200209-985f7bd0.pth"), + ("paa", "paa/paa_r50_fpn_1x_coco.py", + "paa_r50_fpn_1x_coco_20200821-936edec3.pth"), + ("fcos", "fcos/fcos_r50-caffe_fpn_gn-head_1x_coco.py", + "fcos_r50_caffe_fpn_gn-head_1x_coco-821213aa.pth"), + ("gfl", "gfl/gfl_r50_fpn_1x_coco.py", + "gfl_r50_fpn_1x_coco_20200629_121244-25944287.pth"), + ("vfnet", "vfnet/vfnet_r50_fpn_1x_coco.py", + "vfnet_r50_fpn_1x_coco_20201027-38db6f58.pth"), + ("reppoints", "reppoints/reppoints-moment_r50_fpn_1x_coco.py", + "reppoints_moment_r50_fpn_1x_coco_20200330-b73db8d1.pth"), + ("tood", "tood/tood_r50_fpn_1x_coco.py", + "tood_r50_fpn_1x_coco_20211210_103425-20e20746.pth"), + + # 위 8계열의 head 를 **그대로 상속**한 계열들. 손실이나 백본만 다르므로 조립기는 + # 같은 것을 탄다. 새 함수를 쓰기 전에 이런 게 있는지 먼저 본다. + ("ghm", "ghm/retinanet_r50_fpn_ghm-1x_coco.py", # RetinaHead + "retinanet_ghm_r50_fpn_1x_coco_20200130-a437fda3.pth"), + ("pvt", "pvt/retinanet_pvt-t_fpn_1x_coco.py", # RetinaHead + "retinanet_pvt-t_fpn_1x_coco_20210831_103110-17b566bd.pth"), + ("free_anchor", "free_anchor/freeanchor_r50_fpn_1x_coco.py", # RetinaHead 상속 + "retinanet_free_anchor_r50_fpn_1x_coco_20200130-0f67375f.pth"), + ("fsaf", "fsaf/fsaf_r50_fpn_1x_coco.py", # RetinaHead 상속 + "fsaf_r50_fpn_1x_coco-94ccc51f.pth"), + ("dyhead", "dyhead/atss_r50-caffe_fpn_dyhead_1x_coco.py", # ATSSHead + "atss_r50_fpn_dyhead_for_reproduction_4x4_1x_coco_20220107_213939-162888e6.pth"), + ("nas_fcos", "nas_fcos/nas-fcos_r50-caffe_fpn_nashead-gn-head_4xb4-1x_coco.py", # FCOSHead + "nas_fcos_nashead_r50_caffe_fpn_gn-head_4x4_1x_coco_20200520-1bdba3ce.pth"), + ("ld", "ld/ld_r50-gflv1-r101_fpn_1x_coco.py", # GFLHead 상속 + "ld_r50_gflv1_r101_fpn_coco_1x_20220629_145355-8dc5bad8.pth"), + ("lad", "lad/lad_r101-paa-r50_fpn_2xb8_coco_1x.py", # PAAHead 상속 + "lad_r101_paa_r50_fpn_coco_1x_20220708_124357-9407ac54.pth"), + + + # ── 텍스트+이미지 계열 ───────────────────────────────────────────────── + # 언어 모델(BERT)이 함께 들어 있다. head 는 ATSS/DINO 계열을 상속하므로 조립기가 + # 있을 수도 있는데, **재본 적이 없어서** 결과를 말할 수 없었다 → 체크포인트를 받아 등록한다. + ("glip", "glip/glip_atss_swin-t_a_fpn_dyhead_pretrain_obj365.py", + "glip_tiny_a_mmdet-b3654169.pth"), + ("grounding_dino", "grounding_dino/grounding_dino_swin-t_finetune_16xb2_1x_coco.py", + "groundingdino_swint_ogc_mmdet-822d7e9d.pth"), + ("mm_grounding_dino", "mm_grounding_dino/grounding_dino_swin-t_pretrain_obj365.py", + "grounding_dino_swin-t_pretrain_obj365_goldg_grit9m_v3det_20231204_095047-b448804b.pth"), + + # ── 아직 조립기가 없는 계열 ───────────────────────────────────────────── + # 여기 있다고 지원한다는 뜻이 아니다. **어디서 어떻게 막히는지 재려고** 둔다 — + # 실패도 기록해야 다음 사람이 같은 걸 다시 조사하지 않는다. + ("ddod", "ddod/ddod_r50_fpn_1x_coco.py", + "ddod_r50_fpn_1x_coco_20220523_223737-29b2fc67.pth"), + ("autoassign", "autoassign/autoassign_r50-caffe_fpn_1x_coco.py", + "auto_assign_r50_fpn_1x_coco_20210413_115540-5e17991f.pth"), + ("foveabox", "foveabox/fovea_r50_fpn_4xb4-1x_coco.py", + "fovea_r50_fpn_4x4_1x_coco_20200219-ee4d5303.pth"), + ("yolof", "yolof/yolof_r50-c5_8xb8-1x_coco.py", + "yolof_r50_c5_8x8_1x_coco_20210425_024427-8e864411.pth"), + ("efficientnet", "efficientnet/retinanet_effb3_fpn_8xb4-crop896-1x_coco.py", + "retinanet_effb3_fpn_crop896_8x4_1x_coco_20220322_234806-615a0dda.pth"), + ("nas_fpn", "nas_fpn/retinanet_r50_fpn_crop640-50e_coco.py", + "retinanet_r50_fpn_crop640_50e_coco-9b953d76.pth"), + ("ssd", "ssd/ssd300_coco.py", + "ssd300_coco_20210803_015428-d231a06e.pth"), + ("yolo", "yolo/yolov3_d53_8xb8-320-273e_coco.py", + "yolov3_d53_320_273e_coco-421362b6.pth"), + ("yolox", "yolox/yolox_s_8xb8-300e_coco.py", + "yolox_s_8x8_300e_coco_20211121_095711-4592a793.pth"), + ("rtmdet", "rtmdet/rtmdet_tiny_8xb32-300e_coco.py", + "rtmdet_tiny_8xb32-300e_coco_20220902_112414-78e30dcc.pth"), + ("centernet", "centernet/centernet_r18-dcnv2_8xb16-crop512-140e_coco.py", + "centernet_resnet18_dcnv2_140e_coco_20210702_155131-c8cd631f.pth"), + ("cornernet", "cornernet/cornernet_hourglass104_10xb5-crop511-210e-mstest_coco.py", + "cornernet_hourglass104_mstest_10x5_210e_coco_20200824_185720-5fefbf1c.pth"), + ("centripetalnet", "centripetalnet/centripetalnet_hourglass104_16xb6-crop511-210e-mstest_coco.py", + "centripetalnet_hourglass104_mstest_16x6_210e_coco_20200915_204804-3ccc61e5.pth"), + ("yolact", "yolact/yolact_r50_1xb8-55e_coco.py", + "yolact_r50_1x8_coco_20200908-f38d58df.pth"), + ("condinst", "condinst/condinst_r50_fpn_ms-poly-90k_coco_instance.py", + "condinst_r50_fpn_ms-poly-90k_coco_instance_20221129_125223-4c186406.pth"), + ("boxinst", "boxinst/boxinst_r50_fpn_ms-90k_coco.py", + "boxinst_r50_fpn_ms-90k_coco_20221228_163052-6add751a.pth"), + + # DETR 계열 — transformer decoder 라 conv 타워 구조 자체가 없다. 별개 작업이다. + ("detr", "detr/detr_r50_8xb2-150e_coco.py", + "detr_r50_8xb2-150e_coco_20221023_153551-436d03e8.pth"), + ("conditional_detr", "conditional_detr/conditional-detr_r50_8xb2-50e_coco.py", + "conditional-detr_r50_8xb2-50e_coco_20221121_180202-c83a1dc0.pth"), + ("dab_detr", "dab_detr/dab-detr_r50_8xb2-50e_coco.py", + "dab-detr_r50_8xb2-50e_coco_20221122_120837-c1035c8c.pth"), + ("deformable_detr", "deformable_detr/deformable-detr_r50_16xb2-50e_coco.py", + "deformable-detr_r50_16xb2-50e_coco_20221029_210934-6bc7d21b.pth"), + ("dino", "dino/dino-4scale_r50_8xb2-12e_coco.py", + "dino-4scale_r50_8xb2-12e_coco_20221202_182705-55b2bba2.pth"), + ("ddq", "ddq/ddq-detr-4scale_r50_8xb2-12e_coco.py", + "ddq-detr-4scale_r50_8xb2-12e_coco_20230809_170711-42528127.pth"), +] +# 설정은 **파일**에서 온다(`verify.toml`). 환경변수로 받으면 어떤 값으로 잰 숫자인지 +# 로그에 안 남아 재현이 안 된다. 덮어쓰려면 `--set run.workers=2` 처럼 준다 — 그것도 찍힌다. +# ⚠️ **파이프로 보내면 블록 버퍼링**이라 30분간 아무것도 안 보이고, 중간에 죽으면 통째로 +# 유실된다(위키: 진행상황이-안보이고-타임아웃때-전부유실 — 여러 세션이 반복해 밟았다). +# 우회(파일 리다이렉트) 대신 원인을 고친다: 줄 단위로 내보낸다. +sys.stdout.reconfigure(line_buffering=True) + + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import vconfig # noqa: E402 +CFG, ARGS = vconfig.load() +CKPT = CFG.ckpt +MM = CFG.configs +UNWRAP = CFG.unwrap + +# ── 계열 열거 ───────────────────────────────────────────────────────────────── +# **손으로 적지 않는다.** mmdet 의 `metafile.yml` 이 계열마다 `Config → Weights` 를 갖고 +# 있으므로 그걸 읽는다(`mmdet_families`). 손목록은 그 위에 얹는 **예외**로만 남긴다 — +# metafile 의 대표 config 가 우리 조립기에 안 맞는 경우가 있어서다. +# +# 목록을 손으로 적으면 거기 없는 계열이 **존재하지 않는 것처럼** 보인다. +# `pisa`·`rpn` 이 실제로 그렇게 몇 주 동안 안 보였다. +import mmdet_families # noqa: E402 + + +def _all_families(): + override = {f[0]: f for f in FAMILIES} + out = [] + for name, cfg, ckpt in mmdet_families.families(MM): + if name in override: + out.append(override.pop(name)) # 손으로 고른 config/체크포인트가 우선 + else: + out.append((name, cfg, ckpt)) + out.extend(override.values()) # configs/ 에 없는 손목록 항목도 남긴다 + return out + + +ROOT = CFG.workdir +# head.cpp 를 미리 컴파일해 둘 자리(계열 무관). 지우면 다시 만든다. +HEAD_OBJ = os.path.join(ROOT, "head.o") +# 검증 빌드의 최적화 수준. 수치는 libggml 이 내므로 -O1 로 충분하다(컴파일이 빠르다). +OPT = CFG.opt +# 판정 기준: 출력 텐서의 **상대 L1/L2 거리**. 둘 다 임계 이하여야 PASS. +# rel_L1 = Σ|a−b| / Σ|a| 평균적으로 얼마나 어긋났나 +# rel_L2 = ‖a−b‖ / ‖a‖ 큰 오차에 더 민감(제곱) +# cosine 을 안 쓰는 이유: **스케일 불변**이라 크기가 통째로 틀려도 1.0 이 나온다. +# 저장소 관례(verify_pt.py)의 REL_L2_TOL=0.05 를 따른다. +L1_TOL = CFG.l1 +L2_TOL = CFG.l2 +# g2c(컴파일러)와 vision.cpp 경로. 이 파일은 vision.cpp/tools/verify/dense_head/ 에 있다. +V = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +P = CFG.g2c +PY = sys.executable +FE = V + "/tools/frontend/mmdet" +GGUF_PY = V + "/depend/llama/gguf-py" +SZ = CFG.size + +STUB = '''import sys, types +for n in ("mmpretrain.models.multimodal.blip", "mmpretrain.models.multimodal.blip.language_model"): + m = types.ModuleType(n); m.__path__ = []; sys.modules[n] = m +''' + +# torch 기준값. **C++ 이 내는 것과 같은 지점**까지만 계산한다 — 디코드는 양쪽 다 안 한다. +REF = r''' +import _stub, os, sys, torch, numpy as np +sys.path.insert(0, "%(FE)s") +import mmdet_wrap +mmdet_wrap.trace_friendly_ops() + +# ⚠️ **내보낸 .pt 를 그대로 로드한다.** config 로 다시 init 하면 랜덤 초기화가 새로 굴러 +# GGUF 와 다른 가중치가 된다 — 백본부터 cos 0 이 나온다(실제로 겪음). +fam, cfg_path, gen = sys.argv[1], sys.argv[2], sys.argv[3] +mod = torch.load("bb.pt", weights_only=False).cpu() +mod.eval() # 체이닝 금지 — train() 오버라이드 시 None +bh = mod.bbox_head +np.random.seed(0) +x = np.random.randn(1, 3, %(SZ)d, %(SZ)d).astype("float32") +LAYOUT = "chw" # 덤프 해석 방식. DETR 계열만 "flat" 이다(아래 참조). +with torch.no_grad(): + f = mod.backbone(torch.from_numpy(x)) + if mod.neck is not None: + f = mod.neck(f) + if getattr(mod, "decoder", None) is not None: + # ⚠️ **DETR 계열은 encoder/decoder 가 detector 에 달려 있다** — `bbox_head.forward` 는 + # decoder 출력을 받는 cls/reg 분기 둘뿐이다. 그래서 FPN feature 로는 못 부른다. + # detector 를 config 로 다시 세우되 **가중치는 bb.pt 것을 심는다** — config init 의 + # 랜덤이 굴러 GGUF 와 달라지는 사고를 막는다(백본부터 cos 0 이 나왔던 그 함정). + from mmdet.apis import init_detector + from mmdet.structures import DetDataSample + det = init_detector(cfg_path, None, device="cpu") + det.eval() + # `pos_embed` 처럼 **우리가 구우려고 추가한 버퍼**는 detector 에 없다. 그것만 뺀다 — + # 그 외에 안 붙는 키가 있으면 이름 규약이 어긋난 것이므로 시끄럽게 죽인다. + ours = {"pos_embed", "ref_sine_embed", "ref_inv_pad", + "dab_inv_dim_t", "dab_even", "dab_odd", "enc_ref", + "enc_proposals", "enc_valid"} + sd = {k: v for k, v in mod.state_dict().items() if k.split(".")[0] not in ours} + r = det.load_state_dict(sd, strict=False) + assert not r.unexpected_keys, ("bb.pt 키가 detector 에 안 붙는다", r.unexpected_keys[:5]) + ds = DetDataSample() + ds.set_metainfo({"batch_input_shape": (%(SZ)d, %(SZ)d), "img_shape": (%(SZ)d, %(SZ)d)}) + head_in = det.forward_transformer(tuple(f), [ds]) + outs = bh(**head_in) + # ⚠️ two-stage(DINO·DDQ)는 encoder 점수 상위 k 개를 query 로 고른다. 그 **점수가 극도로 + # 촘촘해서**(상위 900 인접 간격 중앙값 4.9e-04, 값 범위 −1~−4.4) fp16 가중치로는 + # **순서를 재현할 수 없다** — 852/899 인접쌍이 fp16 잡음 안이다. + # 출력은 원래 순서 없는 집합이므로 행을 정렬해 비교한다(집합이 틀리면 그대로 잡힌다). + # 출력이 (decoder 층, batch, query, ch) 다 — 공간 격자가 아니다. 층을 "레벨"로 쓴다. + LAYOUT = "set" if getattr(det, "memory_trans_fc", None) is not None else "flat" + else: + outs = bh(tuple(f)) + +# 계열별로 head 출력의 의미가 다르다. C++ 과 같은 형태로 맞춘다. +cls_l, box_l, ctr_l = [], [], [] +extra = {} +if isinstance(outs, tuple) and len(outs) == 8: + # CentripetalHead: (tl_heat, br_heat, tl_off, br_off, tl_gs, br_gs, tl_cs, br_cs). + cls_l, box_l, ctr_l = list(outs[0]), list(outs[1]), list(outs[2]) + extra["brof"] = list(outs[3]) + extra["tlgs"], extra["brgs"] = list(outs[4]), list(outs[5]) + extra["tlcs"], extra["brcs"] = list(outs[6]), list(outs[7]) +elif isinstance(outs, tuple) and len(outs) == 6: + # CornerHead: (tl_heat, br_heat, tl_emb, br_emb, tl_off, br_off). + # C++ 조립기와 **같은 이름**으로 맞춘다 — 안 맞으면 덤프가 안 겹쳐 조용히 통과한다. + cls_l, box_l, ctr_l = list(outs[0]), list(outs[1]), list(outs[4]) + extra["brof"] = list(outs[5]) + if outs[2][0] is not None: + extra["tlemb"], extra["bremb"] = list(outs[2]), list(outs[3]) +elif isinstance(outs, tuple) and len(outs) == 1: + # YOLOv3 는 `return tuple(pred_maps),` — 한 갈래를 **또 튜플로 감싸** 돌려준다. + cls_l = list(outs[0]) +elif isinstance(outs, tuple) and len(outs) == 3: + cls_l, box_l, ctr_l = [list(t) for t in outs] +else: + cls_l, box_l = [list(t) for t in outs[:2]] + +# **계열 이름이 아니라 head 속성으로 판단한다** — LD 는 GFLHead 를 상속해 DFL 을 쓴다. +if getattr(bh, "reg_max", 0): + # C++ 은 DFL 기댓값까지 낸다(디코드를 단순하게 두려고) → 기준값도 같은 지점으로. + strides = [s[0] for s in bh.prior_generator.strides] + with torch.no_grad(): + box_l = [bh.integral(b.permute(0, 2, 3, 1).reshape(-1, 4 * (bh.reg_max + 1))) + .reshape(1, b.shape[2], b.shape[3], 4).permute(0, 3, 1, 2) * s + for b, s in zip(box_l, strides)] + +def save(tag, lst): + for i, t in enumerate(lst): + np.ascontiguousarray(t[0].detach().numpy()).tofile(f"ref.{tag}.{i}.bin") + s = list(t.shape[1:]) + while len(s) < 3: + s.append(1) + print("SHAPE", tag, i, *s[:3], LAYOUT) + +save("cls", cls_l); save("box", box_l); save("ctr", ctr_l) +for _tag, _lst in extra.items(): + save(_tag, _lst) +np.ascontiguousarray(x[0].transpose(1, 2, 0)).tofile("in.bin") +print("LEVELS", len(cls_l), "CTR", len(ctr_l)) +''' + + +# 단계별 소요 시간. 어디가 느린지 **재고 나서** 고치려고 둔다. +# 실측(3계열 96초): g2c 46% · export 21% · ref 21% · g++ 9% · 실행 3%. +PHASE = {} +_PHASE_LOCK = __import__("threading").Lock() +# ⚠️ head.o 빌드용 락은 **따로** 둔다. 계측 락을 재사용하면 `run()` 안에서 같은 락을 +# 다시 잡아 자기 자신을 기다린다(비재진입 Lock → 교착). 실제로 한 번 걸렸다. +_HEAD_LOCK = __import__("threading").Lock() + +# 계열끼리 공유하는 상태가 없어 병렬로 돌릴 수 있다(전부 독립 프로세스). +# 실측 계열당 최대 RSS 1.16GB → 4개면 ~4.6GB. 6코어 중 4개만 쓴다(2개는 호스트 몫). +WORKERS = CFG.workers +# ⚠️ **메모리 가드.** 위키 `wsl-계속-터짐` — 병렬 torch 스윕이 WSL 을 통째로 죽인 적이 있다. +# 가용 메모리가 이 밑으로 내려가면 새 계열을 안 띄우고 기다린다. 느려질지언정 안 죽는다. +MIN_FREE_MB = CFG.min_free_mb + + +def _avail_mb(): + try: + for line in open("/proc/meminfo"): + if line.startswith("MemAvailable:"): + return int(line.split()[1]) // 1024 + except Exception: + pass + return 1 << 30 # 못 읽으면 가드를 끈다(측정 실패로 막지 않는다) + + +def _wait_for_memory(fam): + import time + waited = 0 + while _avail_mb() < MIN_FREE_MB: + if waited == 0: + print(f" … 메모리 대기 ({fam}): 가용 {_avail_mb()}MB < {MIN_FREE_MB}MB", flush=True) + time.sleep(5); waited += 5 + if waited > 600: # 10분을 기다려도 안 풀리면 그냥 간다(교착 방지) + break + + +def run(cmd, cwd, env_extra=None, timeout=2400, phase=None): + import time + env = dict(os.environ, OMP_NUM_THREADS="1") + env.update(env_extra or {}) + t0 = time.time() + r = subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True, timeout=timeout) + if phase: + with _PHASE_LOCK: + PHASE[phase] = PHASE.get(phase, 0.0) + (time.time() - t0) + return r + + + +REF_FRCNN = r''' +import _stub, sys, numpy as np, torch +sys.path.insert(0, "%(FE)s") +import mmdet_compat # noqa +import frcnn_wrap # noqa (피클 클래스 복원) +suba = torch.load("frcnn/FRCNN_SubA.pt", weights_only=False); suba.eval() +# 캐스케이드는 단계별 파일이다(`FRCNN_SubB0/1/2`). 여기서 재는 것은 **1단계** — +# 단계 사이 박스 정제는 호스트 코드라 별도 축이고, 그걸 섞으면 무엇이 틀렸는지 못 가른다. +import glob as _g, os as _o +_p = "frcnn/FRCNN_SubB.pt" +if not _o.path.exists(_p): + _p = sorted(_g.glob("frcnn/FRCNN_SubB[0-9].pt"))[0] +subb = torch.load(_p, weights_only=False); subb.eval() +np.random.seed(0) +x = np.random.randn(1, 3, %(SZ)d, %(SZ)d).astype("float32") +np.ascontiguousarray(x[0].transpose(1, 2, 0)).tofile("in.bin") # 러너 입력(cwhn) +with torch.no_grad(): + outs = suba(torch.from_numpy(x)) +L = (len(outs) - 4) // 2 +for i in range(L): + np.ascontiguousarray(outs[4 + i][0].numpy()).tofile(f"ref.rpncls.{i}.bin") + np.ascontiguousarray(outs[4 + i + L][0].numpy()).tofile(f"ref.rpnbox.{i}.bin") + print("SHAPE rpncls", i, *outs[4 + i].shape[1:], "chw") + print("SHAPE rpnbox", i, *outs[4 + i + L].shape[1:], "chw") +print("LEVELS", L) +''' + +REF_FRCNN_B = r''' +import _stub, sys, numpy as np, torch +sys.path.insert(0, "%(FE)s") +import mmdet_compat # noqa +import frcnn_wrap # noqa +# 캐스케이드는 단계별 파일이다(`FRCNN_SubB0/1/2`). 여기서 재는 것은 **1단계** — +# 단계 사이 박스 정제는 호스트 코드라 별도 축이고, 그걸 섞으면 무엇이 틀렸는지 못 가른다. +import glob as _g, os as _o +_p = "frcnn/FRCNN_SubB.pt" +if not _o.path.exists(_p): + _p = sorted(_g.glob("frcnn/FRCNN_SubB[0-9].pt"))[0] +subb = torch.load(_p, weights_only=False); subb.eval() +# ⚠️ **러너가 고른 proposal 로 만든 RoI feature 를 그대로 넣는다.** 여기서 재는 것은 +# RoI head 이고, RPN/RoIAlign 은 위 단계에서 따로 재기 때문이다. 다시 뽑으면 +# NMS 순서 차이가 섞여 무엇이 틀렸는지 못 가른다. +roi = np.fromfile("cpp.roi.bin", dtype="float32") +m = roi.size // (%(RC)d * %(O)d * %(O)d) +with torch.no_grad(): + _o = subb(torch.from_numpy(roi).view(m, %(RC)d, %(O)d, %(O)d)) +# ⚠️ **(cls, box) 한 쌍이라고 언패킹하지 마라.** CrowdDet `MultiInstanceBBoxHead` 는 +# proposal 하나가 사람 둘을 낸다고 보고 쌍을 2벌 낸다(총 4텐서). 언패킹하면 +# `ValueError: too many values to unpack` 로 죽고, 앞 둘만 쓰면 절반이 검증 안 된다. +_o = list(_o) if isinstance(_o, (list, tuple)) else [_o] +assert len(_o) >= 2 and len(_o) %% 2 == 0, "SubB 출력이 (cls, box) 쌍이 아니다: %%d" %% len(_o) +for _k in range(len(_o) // 2): + _c, _b = _o[2 * _k], _o[2 * _k + 1] + np.ascontiguousarray(_c.numpy()).tofile("ref.cls.%%d.bin" %% _k) + np.ascontiguousarray(_b.numpy()).tofile("ref.box.%%d.bin" %% _k) + print("SHAPE cls", _k, *_c.shape, 1, "flat") + print("SHAPE box", _k, *_b.shape, 1, "flat") +''' + + +def _two_stage(fam, cfg_path, cw, d): + """two-stage(Faster/Mask R-CNN 계열)를 **2패스**로 검증한다. + + dense head 는 한 그래프로 끝나지만 two-stage 는 안 된다 — RPN 후보에서 NMS 로 proposal 을 + 고르고(개수·좌표가 실행 중에 정해진다) 그 좌표에서 feature 를 잘라(RoIAlign) 두 번째 head 에 + 넣는다. 그래서 러너가 `run_frcnn.cpp` 로 패스를 나눠 돌린다. + + 호스트 부품(`rpn_proposals`·`roi_align`·`detect_roi`)은 `postproc.h` 에 이미 있다. + """ + import json + fr = os.path.join(d, "frcnn") + r = run([PY, FE + "/frcnn_to_pt.py", "--config", cfg_path, "--checkpoint", cw, + "--out", fr, "--size", str(SZ)], os.path.dirname(MM.rstrip("/")), + {"PYTHONPATH": f"{d}:{FE}"}, phase="1_export2") + if not os.path.exists(os.path.join(fr, "frcnn.json")): + # ⚠️ **왜 실패했는지 말한다.** 전부 "조립기 없음" 으로 뭉뚱그리면 진짜 미지원(SOLO 등)과 + # 고칠 수 있는 실패(export 버그)가 구분되지 않는다 — 5계열을 그렇게 놓칠 뻔했다. + err = _last_error(r.stderr) + if "roi_head" in err or "has no attribute" in err: + return fam, "HEAD_NONE", f"two-stage 아님: {err[:50]}" + return fam, "EXPORT2_FAIL", err[:70] + J = json.load(open(os.path.join(fr, "frcnn.json"))) + O, MX = int(J["roi_out"]), int(J["rpn_max"]) + RC = int(J.get("roi_channels", 256)) + # Double-Head 는 (cls용, reg용) 두 벌을 배치로 이어 넣는다 → 배치가 2배다. + MX = MX * 2 if float(J.get("reg_roi_scale_factor", 0) or 0) > 0 else MX + + # SubA / SubB 를 각각 컴파일한다. ⚠️ SubB 는 **proposal 상한 배치**로 컴파일해야 한다 — + # batch=1 로 굽고 1000개를 넣으면 reshape 이 안 맞아 죽는다. + NS = int(J.get("num_bbox_stages", 1)) + # 캐스케이드는 단계마다 SubB 가 따로 있다(`FRCNN_SubB0/1/2`). **구조가 같고 가중치만 + # 다르므로** 그래프는 0번 것만 컴파일해 재사용하고, gguf 는 단계 수만큼 굽는다. + subs = ["FRCNN_SubB"] if NS == 1 else ["FRCNN_SubB%d" % i for i in range(NS)] + + # (모델파일, 컴파일이름, 출력폴더, 입력shape) + jobs = [("FRCNN_SubA", "FRCNN_SubA", "out_FRCNN_SubA", "1,3,%d,%d" % (SZ, SZ))] + for s in subs: + # ⚠️ **이름은 subs[0] 로 통일한다.** 생성 코드가 `general.architecture` 를 검사하므로 + # 단계마다 다른 이름으로 구우면 gguf 를 갈아 끼울 수 없다(그래프 재사용이 목적이다). + jobs.append((s, subs[0], "out_" + s, "%d,%d,%d,%d" % (MX, RC, O, O))) + for src, name, outdir, shape in jobs: + r = run([PY, "-c", ''' +import _stub, sys +sys.argv = ["g2c","--model","%s.pt","--name","%s","--output","%s","--input-shape","%s"] +from shared.compile.pipeline import main; main() +''' % (src, name, outdir, shape)], fr, + {"PYTHONPATH": f"{d}:{fr}:{P}:{FE}:{GGUF_PY}"}, phase="2_g2c2") + if not os.path.exists(os.path.join(fr, outdir, f"{name}.gguf")): + return fam, "COMPILE_FAIL", _last_error(r.stderr)[:70] + + import shutil + for name, inc in (("FRCNN_SubA", "incA"), (subs[0], "incB")): + os.makedirs(os.path.join(fr, inc, "visp", "arch"), exist_ok=True) + shutil.copy(os.path.join(fr, "out_" + name, name + ".h"), + os.path.join(fr, inc, "visp", "arch")) + b = run(["g++", "-std=c++20", OPT, "-DARCH_A=FRCNN_SubA", + "-DARCH_B=" + subs[0], + '-DVISP_ARCH_HEADER_A="visp/arch/FRCNN_SubA.h"', + '-DVISP_ARCH_HEADER_B="visp/arch/' + subs[0] + '.h"', + "-IincA", "-IincB", + "-I" + V + "/include", "-I" + V + "/src", + "-I" + V + "/depend/llama/ggml/include", "-I" + V + "/depend/llama/vendor", + V + "/tools/verify/backbone/run_frcnn.cpp", + "out_FRCNN_SubA/FRCNN_SubA.cpp", "out_" + subs[0] + "/" + subs[0] + ".cpp", + "-L" + V + "/build/lib", "-lvisioncpp", "-lggml", "-lggml-base", "-lggml-cpu", + "-Wl,-rpath," + V + "/build/lib", "-o", "run_frcnn"], fr, phase="3_build2") + if not os.path.exists(os.path.join(fr, "run_frcnn")): + return fam, "BUILD_FAIL", _last_error(b.stderr)[:70] + + # ① torch 기준값(RPN) + 러너 입력 생성 + open(os.path.join(d, "ref_a.py"), "w").write(REF_FRCNN % {"FE": FE, "SZ": SZ}) + ra = run([PY, "ref_a.py"], d, {"PYTHONPATH": f"{d}:{FE}"}, phase="4_ref2") + if "LEVELS" not in ra.stdout: + return fam, "REF_FAIL", _last_error(ra.stderr)[:70] + + # ② 2패스 실행 + rr = run([os.path.join(fr, "run_frcnn"), + "out_FRCNN_SubA/FRCNN_SubA.gguf", + ",".join("out_" + s + "/" + subs[0] + ".gguf" for s in subs), + "frcnn.json", os.path.join(d, "in.bin"), os.path.join(d, "cpp"), str(SZ)], + fr, {"VISP_BACKEND": "cpu"}, phase="5_run2") + if not os.path.exists(os.path.join(d, "cpp.roi.bin")): + return fam, "RUN_FAIL", _last_error(rr.stderr)[:70] + + # ③ RoI head 기준값(러너가 만든 RoI feature 로) + open(os.path.join(d, "ref_b.py"), "w").write(REF_FRCNN_B % {"FE": FE, "O": O, "RC": RC}) + rb = run([PY, "ref_b.py"], d, {"PYTHONPATH": f"{d}:{FE}"}, phase="4_ref2") + if "SHAPE" not in rb.stdout: + return fam, "REF_FAIL", _last_error(rb.stderr)[:70] + + shapes = {} + for line in (ra.stdout + rb.stdout).splitlines(): + if line.startswith("SHAPE"): + parts = line.split() + shapes[(parts[1], int(parts[2]))] = (int(parts[3]), int(parts[4]), int(parts[5]), + parts[6] if len(parts) > 6 else "chw") + import numpy as np + w1 = w2 = 0.0 + for (tag, i), (c, h, w, lay) in sorted(shapes.items()): + pr, pc = os.path.join(d, f"ref.{tag}.{i}.bin"), os.path.join(d, f"cpp.{tag}.{i}.bin") + if not os.path.exists(pc): + return fam, "RUN_FAIL", f"덤프 없음 {tag}.{i}" + a, bnp = np.fromfile(pr, dtype="float32"), np.fromfile(pc, dtype="float32") + if a.size != bnp.size: + return fam, "SHAPE_MISMATCH", f"{tag}{i} {a.size} vs {bnp.size}" + if lay == "chw": # 그래프 출력은 cwhn — 되돌려야 한다(안 하면 L1 1.5 로 보인다) + bnp = bnp.reshape(h, w, c).transpose(2, 0, 1).reshape(-1) + w1 = max(w1, float(np.abs(a - bnp).sum() / (np.abs(a).sum() + 1e-12))) + w2 = max(w2, float(np.linalg.norm(a - bnp) / (np.linalg.norm(a) + 1e-12))) + st = "PASS" if (w1 <= L1_TOL and w2 <= L2_TOL) else "FAIL" + return fam, st, f"L1 {w1:.2e} · L2 {w2:.2e} · kind two_stage · 텐서 {len(shapes)}" + + +def one(fam, rel, ckpt): + d = os.path.join(ROOT, fam) + os.makedirs(d, exist_ok=True) # ROOT 도 같이 생긴다(head.o 자리) + open(os.path.join(d, "_stub.py"), "w").write(STUB) + cfg_path = os.path.join(MM, rel) + if not os.path.exists(cfg_path): + return fam, "CONFIG_NONE", "-" + + # 1) backbone .pt + .postproc.h (프론트엔드 CLI 그대로) + if not ckpt: + # **랜덤 가중치로는 검증이 안 된다** — γ=1·β=0 같은 항등 초기값이 누락 연산을 덮는다. + # 그래서 체크포인트가 없으면 "안 됨" 이 아니라 "안 해봄" 으로 남긴다. + return fam, "CKPT_NONE", "체크포인트 미다운로드" + cw = os.path.join(CKPT, ckpt) + if not os.path.exists(cw): + return fam, "CKPT_NONE", ckpt + # ⚠️ **mmdetection 루트에서 돌린다.** 증류 계열의 config 는 교사 모델을 + # `teacher_config: 'configs/gfl/...'` 처럼 **CWD 기준 상대경로**로 적는다 + # (`_base_` 와 달리 config 파일 위치 기준이 아니다). 다른 데서 돌리면 FileNotFound. + # 입출력 경로는 전부 절대라 cwd 를 옮겨도 안전하다. + mm_root = os.path.dirname(MM.rstrip("/")) + # ⚠️ **트래커·반지도 래퍼는 config 를 한 겹 벗겨야 한다.** ByteTrack 등은 검출기를 + # `model.detector` 안에 넣고 자기는 껍데기만 갖는다 → + # `'ConfigDict' object has no attribute 'backbone'` 으로 죽는다. + # 저장소에 전처리기가 이미 있다(`mmdet_unwrap_config.py`). 풀 필요 없는 config 는 + # 원본을 그대로 돌려주므로 분기 없이 전부 통과시킨다. + if os.path.exists(UNWRAP): + ru = run([PY, UNWRAP, cfg_path, "-o", os.path.join(d, "cfg.py")], + os.path.dirname(os.path.dirname(os.path.dirname(UNWRAP))), phase="0_unwrap") + if ru is not None and ru.returncode == 0: + lines = (ru.stdout or "").strip().splitlines() + if lines and os.path.exists(lines[-1].strip()): + cfg_path = lines[-1].strip() + # ⚠️ **먼저 지운다.** export 가 실패해도 지난 실행의 bb.pt/헤더가 남아 있으면 아래 존재 + # 검사를 통과해 **낡은 산출물로 계속 간다** — 그러면 고친 것이 반영 안 된 채 통과/실패가 + # 나온다(dab_detr 에서 실제로 겪었다: 헤더에 새 플래그가 없는데 조용히 진행됐다). + for stale in ("bb.pt", "bb.postproc.h"): + try: + os.remove(os.path.join(d, stale)) + except FileNotFoundError: + pass + r = run([PY, FE + "/mmdet_to_pt.py", "--config", cfg_path, "--checkpoint", cw, + "--out", os.path.join(d, "bb.pt"), "--size", str(SZ)], mm_root, + {"PYTHONPATH": f"{d}:{FE}"}, phase="1_export") + if not os.path.exists(os.path.join(d, "bb.pt")): + return fam, "EXPORT_FAIL", _last_error(r.stderr)[:70] + ph = os.path.join(d, "bb.postproc.h") + if not os.path.exists(ph): + return fam, "PARAMS_NONE", "postproc.h 미생성 (head_type=raw?)" + kind = next((l.split(":")[-1].strip() for l in open(ph) if l.startswith("// head_type")), "?") + if kind == "raw": + # 프론트엔드가 이 head 를 인식하지 못했다 = 조립기가 없다. 여기서 끝낸다 — + # 계속 가면 러너에서 크래시로 나타나 "버그" 처럼 보인다. + # dense head 가 아니면 two-stage 경로를 태워 본다 — 거기서도 아니면 HEAD_NONE. + return _two_stage(fam, cfg_path, cw, d) + + # 2) g2c 컴파일 (backbone+neck 만). **g2c 는 main 원본 그대로 쓴다.** + r = run([PY, "-c", ''' +import _stub, sys +sys.argv = ["g2c","--model","bb.pt","--name","Fam","--output","out","--input-shape","1,3,%d,%d"] +from shared.compile.pipeline import main; main() +''' % (SZ, SZ)], d, {"PYTHONPATH": f"{d}:{P}:{FE}:{GGUF_PY}"}, phase="2_g2c") + if not os.path.exists(os.path.join(d, "out", "Fam.gguf")): + return fam, "COMPILE_FAIL", _last_error(r.stderr)[:70] + + # 2b) 그래프 밖 가중치(head · DETR transformer)를 **프론트엔드가** 덧붙인다. + # trace 에 안 잡히는 건 g2c 잘못이 아니다 — head 를 C++ 로 뺀 이 경로의 사정이다. + r = run([PY, FE + "/append_head_weights.py", os.path.join(d, "bb.pt"), + os.path.join(d, "out", "Fam.gguf")], d, + {"PYTHONPATH": f"{d}:{FE}"}, phase="2b_weights") + if "추가" not in (r.stdout or ""): + return fam, "WEIGHTS_FAIL", _last_error(r.stderr)[:70] + + # 3) run_mmdet 빌드 (백본 .cpp + head.cpp 를 함께 컴파일) + gen = os.path.join(d, "out") + inc = os.path.join(gen, "inc", "visp", "arch") + os.makedirs(inc, exist_ok=True) + import shutil + shutil.copy(os.path.join(gen, "Fam.h"), inc) + # head.cpp 는 `ARCH`·파라미터 헤더를 안 쓴다 → **계열마다 다시 컴파일할 이유가 없다.** + # 한 번 .o 로 만들어 두고 링크만 한다(38계열이면 37번을 아낀다). + # 여러 계열이 동시에 들어와도 한 번만 만든다. + with _HEAD_LOCK: + # ⚠️ **head.cpp 가 바뀌면 캐시를 버린다.** mtime 비교가 없으면 조립기를 고쳐도 + # 옛 오브젝트로 링크돼 **값이 그대로**다 — 고친 줄 알고 한참 헤맨다(실제로 겪었다). + _src = V + "/tools/detect/head.cpp" + if (os.path.exists(HEAD_OBJ) + and os.path.getmtime(HEAD_OBJ) < max(os.path.getmtime(_src), + os.path.getmtime(V + "/tools/detect/head.h"))): + os.remove(HEAD_OBJ) + b = run(["g++", "-std=c++20", OPT, "-c", V + "/tools/detect/head.cpp", + "-I" + V + "/include", "-I" + V + "/src", "-I" + V + "/tools/detect", + "-I" + V + "/depend/llama/ggml/include", "-I" + V + "/depend/llama/vendor", + "-o", HEAD_OBJ], d, phase="3a_head") if not os.path.exists(HEAD_OBJ) else None + if not os.path.exists(HEAD_OBJ): + return fam, "BUILD_FAIL", "head.o: " + (_last_error(b.stderr)[:70]) + + # 최적화 수준은 **수치와 무관**하다 — 실제 계산은 libggml(사전 빌드)이 한다. + # 이 코드는 그래프를 짜기만 하므로 -O1 이면 충분하고, 컴파일이 훨씬 빠르다. + b = run(["g++", "-std=c++20", OPT, "-DARCH=Fam", + '-DVISP_ARCH_HEADER="visp/arch/Fam.h"', + f'-DMMDET_PARAMS_HEADER="{ph}"', + "-I" + gen + "/inc", "-I" + V + "/include", "-I" + V + "/src", + "-I" + V + "/tools/detect", + "-I" + V + "/depend/llama/ggml/include", "-I" + V + "/depend/llama/vendor", + V + "/tools/verify/backbone/run_mmdet.cpp", HEAD_OBJ, + gen + "/Fam.cpp", + "-L" + V + "/build/lib", "-lvisioncpp", "-lggml", "-lggml-base", "-lggml-cpu", + "-Wl,-rpath," + V + "/build/lib", "-o", gen + "/run_mmdet"], d, phase="3b_build") + if not os.path.exists(os.path.join(gen, "run_mmdet")): + return fam, "BUILD_FAIL", _last_error(b.stderr)[:80] + + # 4) torch 기준값 + open(os.path.join(d, "ref.py"), "w").write(REF % {"FE": FE, "SZ": SZ}) + r = run([PY, "ref.py", fam, cfg_path, gen], d, {"PYTHONPATH": f"{d}:{FE}"}, phase="4_ref") + if "LEVELS" not in r.stdout: + return fam, "REF_FAIL", _last_error(r.stderr)[:70] + shapes = {} + for line in r.stdout.splitlines(): + if line.startswith("SHAPE"): + parts = line.split() + _, tag, i, c, h, w = parts[:6] + lay = parts[6] if len(parts) > 6 else "chw" + shapes[(tag, int(i))] = (int(c), int(h), int(w), lay) + + # 5) C++ 실행 + 대조 + r = run([gen + "/run_mmdet", gen + "/Fam.gguf", "in.bin", "o.bin", str(SZ)], d, + {"MMDET_DUMP_HEAD": "cpp", "VISP_BACKEND": "cpu"}, phase="5_run") + import numpy as np + worst_l1, worst_l2, nmiss = 0.0, 0.0, 0 + for (tag, i), (c, h, w, lay) in sorted(shapes.items()): + pc = os.path.join(d, f"cpp.{tag}.{i}.bin") + pr = os.path.join(d, f"ref.{tag}.{i}.bin") + if not os.path.exists(pc): + nmiss += 1 + continue + a = np.fromfile(pr, dtype="float32") + bnp = np.fromfile(pc, dtype="float32") + if a.size != bnp.size: + return fam, "SHAPE_MISMATCH", f"{tag}{i} ref {a.size} vs cpp {bnp.size}" + # cwhn → chw. DETR 계열(query×ch)은 공간 격자가 아니라 축을 안 바꾼다 — + # ref (Q, C) 의 평탄화 순서와 ggml ne={C, Q} 의 평탄화 순서가 이미 같다. + if lay == "set": + # 순서 없는 출력(two-stage 의 query 집합)에는 **집합 거리**를 쓴다. + # 정렬 정렬은 안 된다 — 집합이 한 행만 어긋나도 그 뒤가 통째로 밀린다. + # 각 ref 행에 가장 가까운 cpp 행을 짝지어 그 거리를 잰다(순서와 무관). + # SHAPE 는 (c,h,w) = torch shape[1:] → DETR 은 c=query 수, h=채널 수. + ra, rb = a.reshape(c, -1), bnp.reshape(c, -1) + d2 = ((ra * ra).sum(1)[:, None] + (rb * rb).sum(1)[None, :] + - 2.0 * (ra @ rb.T)) + a, bnp = ra.reshape(-1), rb[d2.argmin(1)].reshape(-1) + elif lay != "flat": + bnp = bnp.reshape(h, w, c).transpose(2, 0, 1).reshape(-1) + d_ = a - bnp + # **상대** 거리로 잰다. 절대 L1/L2 는 텐서마다 스케일이 달라 비교가 안 된다 — + # 같은 계열 안에서도 cls 는 |x|~8, box 는 |x|~375 다(vfnet 실측). + worst_l1 = max(worst_l1, float(np.abs(d_).sum() / (np.abs(a).sum() + 1e-12))) + worst_l2 = max(worst_l2, float(np.linalg.norm(d_) / (np.linalg.norm(a) + 1e-12))) + if nmiss: + err = (r.stderr or r.stdout).strip().splitlines() + return fam, "RUN_FAIL", (err[0][:80] if err else f"덤프 {nmiss}개 없음") + # cosine 은 **스케일 불변**이라 크기가 통째로 틀려도 1.0 이 나온다(vfnet scale 하드코딩이 + # 그랬다: 박스가 2.4배 작은데 cos 는 높았다). L1/L2 는 그걸 그대로 드러낸다. + st = "PASS" if (worst_l1 <= L1_TOL and worst_l2 <= L2_TOL) else "FAIL" + return fam, st, f"L1 {worst_l1:.2e} · L2 {worst_l2:.2e} · kind {kind} · 텐서 {len(shapes)}" + + +# 기본은 **전 계열**이다. 인자를 주면 그것만 — 디버깅용. +FAMILIES = _all_families() +print(CFG.banner(), flush=True) # 무엇으로 쟀는지 로그에 남긴다 +if ARGS: + FAMILIES = [x for x in FAMILIES if x[0] in ARGS] + +def _last_error(text): + """stderr 에서 **진짜 원인 줄**을 고른다. + + 그냥 마지막 줄을 쓰면 `ResourceWarning: Implicitly cleaning up …` 같은 경고가 + 원인을 가린다 — 세 번이나 헛짚었다. 예외처럼 보이는 줄을 뒤에서부터 찾고, + 없으면 경고가 아닌 마지막 줄을 쓴다. + """ + lines = [l.strip() for l in (text or "").splitlines() if l.strip()] + if not lines: + return "-" + for l in reversed(lines): + if re.match(r"^[A-Za-z_.]*(Error|Exception|Warning)?\b", l) and ( + "Error" in l or "Exception" in l) and "Warning" not in l: + return l + for l in reversed(lines): + if "Warning" not in l and not l.startswith(("File ", " ", "warnings.warn")): + return l + return lines[-1] + + +def _one_guarded(args): + fam, rel, ckpt = args + _wait_for_memory(fam) # 가용 메모리가 회복될 때까지 시작을 미룬다 + try: + return one(fam, rel, ckpt) + except subprocess.TimeoutExpired: + return fam, "TIMEOUT", "-" + except Exception as e: + return fam, "ERROR", f"{type(e).__name__}: {e}"[:70] + + +print(f"{'':>7} {'계열':<18} {'판정':<14} 비고", flush=True) +print("-" * 78, flush=True) + +# 각 단계가 별도 프로세스라 GIL 을 잡지 않는다 → 스레드 풀로 충분하다. +# 완료 순서가 아니라 **등록 순서**로 출력한다(실행마다 표가 달라지면 비교를 못 한다). +def _emit(row, i, n): + f, st, note = row + mark = "O" if st == "PASS" else ("X" if st == "FAIL" else "-") + print(f"[{i:3d}/{n}] {f:<18} {mark} {st:<12} {note}", flush=True) + return row + +# ⚠️ `list(ex.map(...))` 로 다 모은 뒤 찍으면 **30분간 화면이 빈다.** map 은 게으른 +# 제너레이터이므로 그대로 순회하면 등록 순서를 지키면서 끝나는 대로 나온다. +n = len(FAMILIES) +if WORKERS > 1 and n > 1: + os.makedirs(ROOT, exist_ok=True) + from concurrent.futures import ThreadPoolExecutor + with ThreadPoolExecutor(max_workers=WORKERS) as ex: + results = [_emit(r, i, n) for i, r in enumerate(ex.map(_one_guarded, FAMILIES), 1)] +else: + results = [_emit(_one_guarded(x), i, n) for i, x in enumerate(FAMILIES, 1)] + +if PHASE: + tot = sum(PHASE.values()) + print("\n단계별 소요 (합계 %.0f초)" % tot) + for k in sorted(PHASE): + print(f" {k:<10} {PHASE[k]:7.1f}초 {PHASE[k]/tot*100:4.1f}%") From 47f583487ebe96b7ea4f5ed42d60e71af3a07123 Mon Sep 17 00:00:00 2001 From: eunchae Date: Thu, 13 Aug 2026 14:02:26 +0900 Subject: [PATCH 08/89] =?UTF-8?q?fix(mmdet):=20append=5Fhead=5Fweights=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=EC=9D=84=20=EC=98=81=EB=AC=B8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/frontend/mmdet/append_head_weights.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/frontend/mmdet/append_head_weights.py b/tools/frontend/mmdet/append_head_weights.py index cb88acd..b764794 100644 --- a/tools/frontend/mmdet/append_head_weights.py +++ b/tools/frontend/mmdet/append_head_weights.py @@ -114,7 +114,7 @@ def main(argv=None): print(__doc__) return 2 n = append(a[0], a[1]) - print(f" · 그래프 밖 가중치 {n} 개 추가 → {a[1]}") + print(f" → appended {n} weights that the graph does not use: {a[1]}") return 0 From c591f6aab76bd6fd8efcb6a1c1e39a89afd2acd9 Mon Sep 17 00:00:00 2001 From: eunchae Date: Thu, 13 Aug 2026 14:54:37 +0900 Subject: [PATCH 09/89] =?UTF-8?q?docs(mmdet):=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=EC=95=88=EB=82=B4=20=EB=AC=B8=EC=84=9C=EC=99=80=20?= =?UTF-8?q?README=20=ED=95=AD=EB=AA=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README 가 가리킬 문서가 없으면 링크가 깨지고, 문서만 있으면 아무도 못 찾는다 — 같이 넣는다. 'head 는 데이터 의존 제어흐름 때문에 trace 가 안 된다' 는 문장을 뺐다. **틀렸다** — head 도 trace 된다. C++ 로 조립한 이유는 그게 원래 설계 방향이고, 한 번 조립하면 구조를 공유하는 계열이 전부 열리며, 이음매에서 수치를 그대로 볼 수 있어서다. --- README.md | 1 + docs/mmdet-detectors.md | 504 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 505 insertions(+) create mode 100644 docs/mmdet-detectors.md diff --git a/README.md b/README.md index a45382c..599f201 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Based on [ggml](https://github.com/ggml-org/ggml) similar to the [llama.cpp](htt | [**MI-GAN**](#mi-gan) | Inpainting | CPU, Vulkan | | [**ESRGAN**](#real-esrgan) | Super-resolution | CPU, Vulkan | | [**YOLOv9t**](#yolov9t) | Object detection | CPU | +| [**MMDetection** models](docs/mmdet-detectors.md) | Object detection, segmentation, tracking | CPU | | [_Implement a model [**Guide**]_](docs/model-implementation-guide.md) | | | **Backbones:** SWIN (v1), DINO (v2), TinyViT diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md new file mode 100644 index 0000000..964780f --- /dev/null +++ b/docs/mmdet-detectors.md @@ -0,0 +1,504 @@ +# Object Detection with MMDetection Models + +This guide describes how to run detectors from +[MMDetection](https://github.com/open-mmlab/mmdetection) with vision.cpp. + +MMDetection defines hundreds of detectors as compositions of a backbone, a neck and a head. +The backbone and neck are plain feed-forward networks and translate directly into a ggml graph. +vision.cpp splits the model there: the feature extractor runs as a compiled ggml graph, and the +head, decoding and post-processing are C++ built from library primitives. One head assembled +that way serves every family that shares its structure, and the numbers stay inspectable at the +seam, which is what the per-family verification in this guide measures. + +``` + image ──▶ backbone + neck (compiled ggml graph) ──▶ FPN features + │ + head │ tools/detect/head.cpp + ▼ + raw cls / bbox + │ + decode + NMS │ visp/postproc.h + ▼ + detections +``` + +Everything below the graph is ordinary CPU code in `visp/postproc.h`, so it is reusable +outside MMDetection: the decoders are parameterised by plain structs, not by framework config +objects. + +## Contents + +- [Prerequisites](#prerequisites) +- [Pipeline](#pipeline) +- [Output](#output) +- [Detection heads](#detection-heads) +- [Post-processing API](#post-processing-api) +- [Two-stage detectors](#two-stage-detectors) +- [Instance segmentation](#instance-segmentation) +- [Multi-object tracking](#multi-object-tracking) +- [Configuration reference](#configuration-reference) + + +## Prerequisites + +- vision.cpp built from source — see [Building](../README.md#building). + The runners link against `libvisioncpp` plus the ggml libraries. +- A Python environment with MMDetection installed, for the export step only. + Nothing in the runtime path depends on Python. + +## Pipeline + +Running a detector takes four steps. Steps 1 and 2 happen once per model; steps 3 and 4 are +the deployment path. + +### Step 1 — Export the detector + +`mmdet_to_pt.py` loads an MMDetection config, wraps the detector so that the backbone and neck +form a traceable `nn.Module`, and writes two files. + +```sh +python tools/frontend/mmdet/mmdet_to_pt.py \ + --config /path/to/retinanet_r18_fpn_1x_coco.py \ + --checkpoint retinanet_r18.pth \ + --out backbone.pt \ + --size 512 +``` + +| Option | Description | +| :--- | :--- | +| `--config` | MMDetection config file (`.py`). Required. | +| `--checkpoint` | Weights (`.pth`). If omitted, the config's initialisation is used — useful for shape checks, not for accuracy. | +| `--out` | Output path for the traceable module (`.pt`). Required. | +| `--size` | Square input resolution used for tracing. Default `512`. | + +Outputs: + +`backbone.pt` +: The backbone and neck as a traceable module. The head is kept as an attribute so that its + weights are preserved in the `state_dict`, but it does not participate in the forward pass. + +`backbone.postproc.h` +: Everything the C++ side needs to reconstruct the head and decode its output: anchor + generator settings, bbox coder statistics, head convolution layout, and the pre-processing + normalisation taken from the config's `data_preprocessor` — emitted as a generated + `mmdet_params()` function. These values are constants once the architecture is chosen, so + they are compiled into the runner rather than read at run time. + See [Configuration reference](#configuration-reference). + +The `.pt` file pickles by module name `mmdet_wrap`, so the export directory must be on +`PYTHONPATH` when it is loaded again. + +### Step 2 — Compile the backbone + +`backbone.pt` is a plain PyTorch module and is compiled to a vision.cpp arch module by a +PyTorch-to-ggml model compiler. That compiler is outside the scope of this document; what +matters is the interface the generated code must satisfy. + +Compile at the resolution `--size` used in step 1. Tracing records the operations for one +input shape, so the graph runs at that shape and no other. A graph built for a different size +aborts at run time in `ggml_can_repeat` once a tensor of the wrong extent reaches a residual +addition. + +Generated files — for an architecture named `MMDetBackbone`: + +| File | Contents | +| :--- | :--- | +| `MMDetBackbone.h` | Declarations (see below). | +| `MMDetBackbone.cpp` | Builds the ggml graph for backbone and neck. | +| `MMDetBackbone.gguf` | Weights for both the backbone and the head, under their original `state_dict` names. | + +Header contract — the runner includes the header and reaches the graph through three +macro-expanded names: + +```c++ +namespace visp { + +struct MMDetBackbone_params { /* ... */ }; + +tensor MMDetBackbone_forward(model_ref m, tensor x, MMDetBackbone_params const& p); +MMDetBackbone_params MMDetBackbone_detect_params(model_file const& f); + +} // namespace visp +``` + +Graph contract + +- The input tensor is named `x`, type `f32`, shape `{3, size, size, 1}` in ggml `ne` order + (CWHN — channels vary fastest). +- Each FPN level is exposed as a named graph tensor `out_0`, `out_1`, … `out_{L-1}`, + ordered from the finest level to the coarsest. The runner resolves them with + `ggml_graph_get_tensor`, so the names must match exactly. +- Head weights must be present in the GGUF under the names the config uses, for example + `bbox_head.cls_convs.0.conv.weight` and `bbox_head.retina_cls.weight`. The head component + looks them up by prefix. + +If a level cannot be found the runner stops and reports the missing `out_`, which means the +generated graph did not name its outputs as expected. + +### Step 3 — Build the runner + +```sh +bash tools/build/build_mmdet_cpp.sh output/MMDetBackbone backbone.postproc.h +``` + +The script compiles three translation units together, with the generated parameters +included as a header, and links them against `libvisioncpp`: + +- `tools/verify/backbone/run_mmdet.cpp` — the runner, +- `tools/detect/head.cpp` — the head component, +- `output/MMDetBackbone/MMDetBackbone.cpp` — the generated graph, +- `backbone.postproc.h` — the generated parameters. + +`build_mmdet_cpp.sh [params.h] [arch_name]` +: `gen_dir` is the directory holding the generated `.cpp`, `.h` and `.gguf`. The parameters + header is found in `gen_dir` when it is there, and named explicitly otherwise. + `arch_name` defaults to the base name of the `.cpp` found there. + +The library is looked up in `build/`, which is where [Building](../README.md#building) puts it. +If you configured elsewhere, point `VISP_BUILD` at that directory: + +```sh +VISP_BUILD=/path/to/that/directory \ + bash tools/build/build_mmdet_cpp.sh output/MMDetBackbone backbone.postproc.h +``` + +The result is `/run_mmdet`. + +### Step 4 — Run + +```sh +output/MMDetBackbone/run_mmdet \ + output/MMDetBackbone/MMDetBackbone.gguf \ + image.jpg \ + detected.png \ + 512 +``` + +``` +run_mmdet [size=512] +``` + +`` +: Weights produced in step 2. + +`` +: An image (`.jpg`, `.jpeg`, `.png`, `.bmp`) or a pre-processed tensor (`.bin`). + Images are resized and normalised in-process using `preprocess()`; the mean, standard + deviation and channel order are compiled in. A `.bin` file is taken as-is and must + contain `3 × size × size` `float32` values in CWHN order. + +`` +: Where to write the result. The extension decides what is written: `.bin` gives raw + detections, anything else gives the input image with the boxes drawn on it. + +`[size]` +: Input resolution. Must match the value passed to `--size` in step 1 and the shape the graph + was compiled for. + +### Output + +The extension of the output path decides what is written. + +```sh +run_mmdet model.gguf image.jpg detected.png 512 # the image, boxes drawn on it +run_mmdet model.gguf image.jpg boxes.bin 512 # raw float32, six numbers per box +``` + +An image is the default, which is what the rest of the command-line tools produce and what a +person looking at a result wants. Raw `float32` is what comparing against a reference +implementation needs, so it stays one extension away rather than being the only option. + +Either way the highest-scoring detections are printed: + +``` +- detect(anchor): 100 boxes, 12 drawn at score >= 0.30 → detected.png + + # x1 y1 x2 y2 score label + 0 459.3 241.1 512.0 263.3 0.837 63 + 1 306.4 69.4 361.2 84.5 0.835 63 + ... (98 more) +``` + +The image carries where, the table carries what. Class is drawn as colour rather than text, +which keeps a font out of the runner. + +| Variable | Effect | +| :--- | :--- | +| `VISP_DRAW_THRESHOLD` | Minimum score to draw. Default `0.3` | +| `VISP_PRINT_DETS` | How many rows to print. `0` turns the table off | + +In the raw form each detection is six `float32` values written back to back: + +``` +x1 y1 x2 y2 score label +``` + +Coordinates are pixels in the square input the detector ran on. There is no header and no +count — the number of detections is the file size divided by 24 bytes. + +```python +import numpy as np +d = np.fromfile("boxes.bin", dtype="float32").reshape(-1, 6) +``` + +`tools/verify/draw_boxes.py` draws such a file afterwards, with class names and scores as text. + + +## Detection heads + +Head components take FPN features and produce the raw per-level tensors that the decoders +expect. They are declared in `tools/detect/head.h`. + +### `anchor_head_forward` + +Shared convolution tower followed by classification and regression convolutions — the layout +used by RetinaNet, ATSS, GFL and other anchor-based dense heads. All levels share one set of +weights. + +```c++ +void anchor_head_forward(model_ref m, std::vector const& feats, + anchor_head_cfg const& c, + std::vector& cls_out, std::vector& box_out); +``` + +`anchor_head_cfg` + +| Field | Default | Description | +| :--- | :--- | :--- | +| `stacked_convs` | `4` | Depth of the shared cls/reg tower. | +| `feat_channels` | `256` | Channels inside the tower. | +| `num_base` | `9` | Anchors per location. | +| `num_classes` | `80` | Classification output channels. | +| `cls_convs_prefix` | `bbox_head.cls_convs` | Weight-name prefix of the cls tower. | +| `reg_convs_prefix` | `bbox_head.reg_convs` | Weight-name prefix of the reg tower. | +| `cls_head` | `bbox_head.retina_cls` | Final classification convolution. | +| `reg_head` | `bbox_head.retina_reg` | Final regression convolution. | +| `head_has_norm` | `false` | Whether the tower contains normalisation layers. | + +Output shapes, per level `l`, in ggml `ne` order: + +- `cls_out[l]` — `{num_base * num_classes, feat_w, feat_h, 1}` +- `box_out[l]` — `{num_base * 4, feat_w, feat_h, 1}` + +Feed these to [`detect_anchor`](#detect_anchor). + +### `vfnet_head_forward` + +VFNet's head predicts distances rather than anchor deltas, and refines them with a +star-shaped deformable convolution whose offsets are computed from the first bbox prediction. +That offset computation is exactly the part that cannot be traced, so it is assembled here as +an explicit graph. + +```c++ +void vfnet_head_forward(model_ref m, std::vector const& feats, + vfnet_head_cfg const& c, tensor dcn_base, + std::vector& cls_out, std::vector& box_out); +``` + +`dcn_base` is the fixed 3×3 sampling grid, shape `{18, 1, 1, 1}`, supplied by the caller. The +component computes `offset = star_dcn_offset(bbox_pred) - dcn_base` and applies +`conv_2d_deform`, a library primitive. Per level, `cls_out[l]` is `{num_classes, w, h, 1}` and +`box_out[l]` is `{4, w, h, 1}`. + +`vfnet_head_cfg` adds `gn_groups` (GroupNorm groups in the tower), `strides` (per level, used +to project offsets into feature scale) and `reg_denoms` (per level, `bbox_pred = exp(reg) * +reg_denom`). + +### Adding a head + +1. Add a `_head_forward` function to `tools/detect/head.cpp` that turns FPN features + into raw per-level tensors. Use library primitives (`conv_2d`, `group_norm`, + `conv_2d_deform`); do not add framework-specific code to `src/visp`. +2. Extract the head's structural parameters in `mmdet_wrap.postproc_cfg`; they are emitted + into the generated parameters header. +3. Connect the raw output to the matching decoder in `visp/postproc.h`, or add one if the + decoding scheme is new. + +## Post-processing API + +Declared in `src/visp/postproc.h`, implemented as plain CPU code with no ggml dependency. +All multi-level inputs are per-level flat `float` buffers in CWHN order +(`index = (y * W + x) * C + c`), with the per-level `(feat_h, feat_w)` passed alongside. + +```c++ +struct detection { + float x1, y1, x2, y2; // pixel coordinates + float score; + int label; +}; +``` + +### Pre-processing + +`std::vector preprocess(uint8_t const* img, int img_h, int img_w, int img_c, int out_size, float const mean[3], float const std[3], bool to_rgb, int* out_w = nullptr, int* out_h = nullptr)` +: Resize to `out_size × out_size` and normalise to `(v - mean) / std`, optionally swapping + channel order. Returns a CWHN `float32` tensor ready for the graph input. + +### Dense heads + + +`std::vector detect_anchor(cls_scores, bbox_preds, feat_hw, det_params const& p)` +: Anchor-based decoding: anchor generation, delta decoding, per-level top-k, score + thresholding and NMS. Used by RetinaNet, ATSS, GFL and RPN-style heads. + +`std::vector detect_fcos(cls_scores, bbox_preds, centerness, feat_hw, fcos_params const& p)` +: Anchor-free distance decoding with centerness weighting. + +`std::vector detect_yolox(cls, box, obj, feat_hw, yolox_params const& p)` +: Grid-based decoding with an objectness branch; score is `sigmoid(cls) * sigmoid(obj)`. + +`std::vector detect_detr(float const* cls, float const* bbox, detr_params const& p)` +: Set prediction. Takes query logits and normalised `cxcywh` boxes, applies top-k, and + performs no NMS. Set `use_sigmoid` for Deformable-DETR-style heads. + +`det_params` carries the anchor generator (`strides`, `octave_base_scale`, `octave_scales`, +`ratios`, `center_offset`), the bbox coder (`means`, `stds`), and the test-time thresholds +(`score_thr`, `nms_thr`, `nms_pre`, `max_per_img`). `input_w`/`input_h` clip boxes to the +image. + +### Two-stage components + +`std::vector rpn_proposals(rpn_cls, rpn_bbox, feat_hw, rpn_params const& p)` +: Region proposals from RPN outputs: anchor decode, per-level top-k, NMS across levels. + Returns `M × 4` boxes in image coordinates, `M ≤ max_per_img`. + +`std::vector roi_align(feats, feat_hw, float const* rois, int m, roi_align_params const& p)` +: MMCV-compatible RoIAlign (`aligned = true`, adaptive `sampling_ratio`). Level assignment + follows `clamp(floor(log2(sqrt(w*h) / finest_scale + 1e-6)), 0, L-1)`. + Returns `M × C × out × out` in NCHW order. + +`std::vector detect_roi(float const* scores, float const* bbox_deltas, float const* proposals, int n, roi_params const& p)` +: Final RoI-head decoding: class-wise delta decoding and per-class NMS. `scores` are + post-softmax with background last. Set `class_agnostic` when `bbox_pred` has four columns + instead of `num_classes * 4`. + +### Masks and keypoints + +`std::vector paste_mask(float const* mask_logit, int mh, int mw, detection const& box, float thr = 0.5f, int* out_h = nullptr, int* out_w = nullptr)` +: Sigmoid, resize to the box, threshold. Returns a binary mask covering the box. + +`std::vector decode_keypoints(float const* heatmap, int k, int hm_h, int hm_w, float stride)` +: Per-keypoint argmax over a heatmap; returns `k × 3` as `(x, y, score)`. + +### Building blocks + +`gen_anchors`, `gen_points`, `delta2bbox`, `distance2bbox` and `nms` are exposed individually +for building custom decoders. + +## Two-stage detectors + +RPN proposals and RoIAlign are data-dependent — the number of proposals is not known until the +network has run — so a two-stage detector cannot be a single graph. It is split into two +compiled sub-graphs with host code in between. + +``` + image + │ SubA (backbone + neck + RPN) 14 outputs: P2-P5, rpn_cls×5, rpn_bbox×5 + ▼ + │ rpn_proposals(host) decode + per-level NMS -> 1000 proposals + │ roi_align(host) proposals + P2-P5 -> roi_feat (N,256,7,7) + ▼ + │ SubB (bbox head, Shared2FC) -> cls_score (N,81), bbox_pred (N,320) + ▼ + │ detect_roi(host) softmax + delta decode + per-class NMS + ▼ detections +``` + +Export both sub-graphs with `frcnn_to_pt.py`, compile each, then build and run: + +```sh +python tools/frontend/mmdet/frcnn_to_pt.py \ + --config faster-rcnn_r50_fpn_1x_coco.py --checkpoint frcnn.pth --out /tmp/frcnn +# compile /tmp/frcnn/FRCNN_SubA.pt at 1,3,800,800 and /tmp/frcnn/FRCNN_SubB.pt at 4,256,7,7 + +bash tools/build/build_frcnn_cpp.sh output/FRCNN_SubA output/FRCNN_SubB + +output/FRCNN_SubA/run_frcnn \ + output/FRCNN_SubA/FRCNN_SubA.gguf output/FRCNN_SubB/FRCNN_SubB.gguf \ + /tmp/frcnn/frcnn.json input.bin 800 +``` + +`run_roi_verify` and `run_rpn_verify` check the two host stages in isolation against dumps +from the reference implementation. + +## Instance segmentation + +Mask R-CNN extends the above with a second RoIAlign at output size 14 over the final boxes, +a mask sub-graph, and host-side mask pasting. + +``` + final boxes + │ roi_align(out=14, host) -> mask_feat (M,256,14,14) + │ SubC (mask head FCN) -> mask_logits (M,80,28,28) + │ paste_mask(host) -> per-instance binary masks + ▼ +``` + +```sh +run_maskrcnn [size=800] +``` + +> Note +> ggml's `conv_transpose_2d_p0` does not support batching. `run_maskrcnn` therefore evaluates +> the mask sub-graph one RoI at a time. Running it batched leaves only the first RoI correct. + +## Multi-object tracking + +Tracking is state management, not a network — there is nothing to compile. `ByteTracker` +(`src/visp/tracker.h`) keeps track state across frames and is detector-agnostic: it +consumes `std::vector` from any of the decoders above. + +```c++ +ByteTracker tracker; // byte_params overrides thresholds +for (int frame = 0; frame < n; ++frame) { + std::vector dets = /* run the detector */; + std::vector tracks = tracker.track(dets, frame); + // tracks[i].id is stable across frames +} +``` + +Each call performs Kalman prediction over an 8-state `cxcyah` model, two-stage IoU matching +(high-score detections first, then low-score), and track lifecycle management — +`num_tentatives` consecutive matches to confirm a track, `num_frames_retain` frames without a +match to drop it. Passing `frame_id == 0` resets the tracker. + +## Configuration reference + +`mmdet_params()` in `.postproc.h` is generated by the export step and compiled into the +runner. Its fields are grouped here by consumer. + +Pre-processing (`c.img_mean`, `c.img_std`, `c.to_rgb`) — used only when the runner is +given an image rather than a `.bin`. + +| Field | Description | +| :--- | :--- | +| `img_mean`, `img_std` | Per-channel normalisation, taken from the config's `data_preprocessor`. | +| `to_rgb` | Whether to swap channel order before normalising. | + +Head reconstruction (`c.head`) — maps onto `anchor_head_cfg`. + +| Field | Description | +| :--- | :--- | +| `stacked_convs`, `feat_channels` | Shape of the shared tower. | +| `cls_convs_prefix`, `reg_convs_prefix` | Weight-name prefixes of the towers. | +| `cls_head`, `reg_head` | Names of the final convolutions. | +| `head_has_norm` | Whether the tower contains normalisation layers. | + +Decoding (`c.det`) — maps onto `det_params`. + +| Field | Description | +| :--- | :--- | +| `strides` | Stride per FPN level; its length defines the level count `L`. | +| `octave_base_scale`, `octave_scales`, `ratios`, `center_offset` | Anchor generator. | +| `num_base` | Anchors per location, `len(octave_scales) * len(ratios)`. | +| `means`, `stds` | Delta coder statistics. | +| `num_classes`, `use_sigmoid` | Classification output layout and activation. | + +When the config's head is not recognised the generated function returns defaults and leaves +the stride list empty, and the runner stops rather than decoding with meaningless anchors. The +backbone still exports, but decoding must then be supplied by the caller. + +Isolation harnesses for each stage live in `tools/verify/`: `run_vfnet_head` for a dense head, +`run_rpn_verify` and `run_roi_verify` for the two-stage host components, and +`run_bytetrack_verify` for tracking. Each one runs a single stage against a dump from the +reference implementation, which is the fastest way to locate a mismatch. From a725e6bd013c28df7148d3ddc1eaa06b47cedd5a Mon Sep 17 00:00:00 2001 From: eunchae Date: Thu, 13 Aug 2026 15:21:03 +0900 Subject: [PATCH 10/89] =?UTF-8?q?fix(tools):=20install=5Farch=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=EB=A5=BC=20=EC=98=81=EB=AC=B8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 영문 가이드에서만 쓰이는 도구인데 오류가 한국어로 났다. 가장 자주 보는 것은 `--name` 을 빠뜨렸을 때다 — 모델 스펙이 클래스명(`DetectionModel`)을 쓰므로 `--name Yolo26m` 으로 등록하려면 컴파일 때도 같은 이름을 줘야 한다. --- tools/install_arch.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/install_arch.py b/tools/install_arch.py index 88f6b7e..4b1035d 100644 --- a/tools/install_arch.py +++ b/tools/install_arch.py @@ -74,11 +74,11 @@ def find_outputs(out_dir, name): else: cand = cpps if len(cand) != 1: - sys.exit(f"오류: .cpp 를 하나로 못 좁혔다 ({cand}). --name 으로 지정할 것.") + sys.exit(f"error: cannot tell which .cpp to install ({cand}). Name it with --name.") cls = os.path.splitext(cand[0])[0] h = os.path.join(out_dir, cls + ".h") if not os.path.exists(h): - sys.exit(f"오류: 헤더가 없다: {h}") + sys.exit(f"error: header not found: {h}") return cls, os.path.join(out_dir, cand[0]), h @@ -91,7 +91,7 @@ def gguf_arch(out_dir, cls): src = open(os.path.join(out_dir, cls + ".cpp"), encoding="utf-8").read() m = re.search(r'arch\s*!=\s*"([^"]+)"', src) if not m: - sys.exit("오류: 생성 .cpp 에서 general.architecture 를 못 찾았다") + sys.exit("error: the generated .cpp declares no general.architecture") return m.group(1) @@ -166,7 +166,7 @@ def main(): def _triple(spec, what): v = [x.strip() for x in spec.split(",") if x.strip()] if len(v) != 3: - sys.exit(f"오류: --{what} 는 값 3개여야 한다: {spec}") + sys.exit(f"error: --{what} takes three values, got: {spec}") return "{" + ", ".join(f"{float(x)}f" for x in v) + "}" norm = [f" t.mean = {_triple(a.mean, 'mean')};", @@ -204,7 +204,7 @@ def _triple(spec, what): print(f" {ARCH_DIR}/{header}") print(f" {reg_path}") print() - print("다음:") + print("next:") print(f" cmake --build {os.path.join(VCPP, 'build')} -j4") print(f" {os.path.join(VCPP, 'build/bin/vision-cli')} {arch} -m -i -o out.jpg") From 0d75e91a62b93a0f50fa95307d0304fb5170e59f Mon Sep 17 00:00:00 2001 From: eunchae Date: Thu, 13 Aug 2026 15:36:05 +0900 Subject: [PATCH 11/89] =?UTF-8?q?docs(mmdet):=20--build=20=ED=94=8C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=EB=A1=9C=20=ED=86=B5=EC=9D=BC=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20reg=5Fstacked=5Fconvs=20=EB=A5=BC=20=ED=91=9C?= =?UTF-8?q?=EC=97=90=20=EB=84=A3=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 일을 두 문서가 다르게 시켰다 — 이쪽은 `VISP_BUILD=` 환경변수를, 배포 가이드는 `--build ` 을. 둘 다 동작하지만 플래그가 지금 방식이다(명령줄만 보고 어느 라이브러리로 빌드했는지 알 수 있어야 해서 만들었다). `reg_stacked_convs` 는 `head.h:45` 에 실재하고 YOLOF 가 쓰는데 표에서 빠져 있었다. --- docs/mmdet-detectors.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 964780f..cdf1ad0 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -155,11 +155,11 @@ included as a header, and links them against `libvisioncpp`: `arch_name` defaults to the base name of the `.cpp` found there. The library is looked up in `build/`, which is where [Building](../README.md#building) puts it. -If you configured elsewhere, point `VISP_BUILD` at that directory: +If you configured elsewhere, name that directory: ```sh -VISP_BUILD=/path/to/that/directory \ - bash tools/build/build_mmdet_cpp.sh output/MMDetBackbone backbone.postproc.h +bash tools/build/build_mmdet_cpp.sh --build /path/to/that/directory \ + output/MMDetBackbone backbone.postproc.h ``` The result is `/run_mmdet`. @@ -266,6 +266,7 @@ void anchor_head_forward(model_ref m, std::vector const& feats, | Field | Default | Description | | :--- | :--- | :--- | | `stacked_convs` | `4` | Depth of the shared cls/reg tower. | +| `reg_stacked_convs` | `0` | Depth of the regression tower when it differs from the classification tower. `0` means they match; YOLOF has 2 and 4. | | `feat_channels` | `256` | Channels inside the tower. | | `num_base` | `9` | Anchors per location. | | `num_classes` | `80` | Classification output channels. | From 855a0d76fe757d5778ab5a8e4665fc3902b9bd1c Mon Sep 17 00:00:00 2001 From: eunchae Date: Thu, 13 Aug 2026 16:04:19 +0900 Subject: [PATCH 12/89] =?UTF-8?q?docs:=20=EC=BB=B4=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=90=9C=20=EB=AA=A8=EB=8D=B8=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20?= =?UTF-8?q?README=20=EC=97=90=EC=84=9C=20=EC=B0=BE=EC=9D=84=20=EC=88=98=20?= =?UTF-8?q?=EC=9E=88=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README 에서 출발하면 yolo26m 을 못 돌린다. 모델을 넣는 방법으로 `scripts/convert.py ` 만 안내하는데 그 `arch` 는 고정 목록이라 yolo26 항목이 없고, `g2c`·`install_arch.py` 는 README 에 **한 번도** 안 나온다. 진입점에서 길이 끊긴다. 표에 한 줄과 `mmdet-detectors.md` 에 짧은 절을 넣어 닫는다. 그 절의 수치는 실측이다 — yolo26m 640, ultralytics 대비 검출 3건 클래스·점수 일치(cat 0.918 · couch 0.771 · tv 0.681), 박스 0.09px 이내, 디코드 전 상대 L1 box 1.7e-03 · cls 4.7e-04. --- README.md | 1 + docs/mmdet-detectors.md | 27 +++++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 599f201..16fbae8 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Based on [ggml](https://github.com/ggml-org/ggml) similar to the [llama.cpp](htt | [**ESRGAN**](#real-esrgan) | Super-resolution | CPU, Vulkan | | [**YOLOv9t**](#yolov9t) | Object detection | CPU | | [**MMDetection** models](docs/mmdet-detectors.md) | Object detection, segmentation, tracking | CPU | +| [**Compiled PyTorch models**](docs/mmdet-detectors.md#models-whose-head-survives-tracing) | Any traceable `nn.Module` — ultralytics YOLO, torchvision | CPU | | [_Implement a model [**Guide**]_](docs/model-implementation-guide.md) | | | **Backbones:** SWIN (v1), DINO (v2), TinyViT diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index cdf1ad0..8898c9e 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -7,8 +7,7 @@ MMDetection defines hundreds of detectors as compositions of a backbone, a neck The backbone and neck are plain feed-forward networks and translate directly into a ggml graph. vision.cpp splits the model there: the feature extractor runs as a compiled ggml graph, and the head, decoding and post-processing are C++ built from library primitives. One head assembled -that way serves every family that shares its structure, and the numbers stay inspectable at the -seam, which is what the per-family verification in this guide measures. +that way serves every family that shares its structure. ``` image ──▶ backbone + neck (compiled ggml graph) ──▶ FPN features @@ -244,6 +243,30 @@ d = np.fromfile("boxes.bin", dtype="float32").reshape(-1, 6) `tools/verify/draw_boxes.py` draws such a file afterwards, with class names and scores as text. +## Models whose head survives tracing + +Everything above splits the model because an MMDetection head cannot be traced. Most other +detectors have no such problem — an ultralytics YOLO or a torchvision model traces whole, and +then none of the steps above apply. A compiler emits the entire graph, `install_arch.py` drops +it into `src/visp/arch/` with a registration unit beside it, and `vision-cli` dispatches on the +architecture name recorded in the GGUF: + +```sh +g2c --model "ultralytics.YOLO('yolo26m.pt')" --name Yolo26m --output out --input-shape 1,3,640,640 +python tools/install_arch.py out --name Yolo26m --detect-yolo +cmake --build build -j4 +./build/bin/vision-cli yolo26m -m out/Yolo26m.gguf -i photo.jpg -o detected.jpg +``` + +`--detect-yolo` supplies what the GGUF does not carry — class count, strides, whether the head +is NMS-free — so the result comes back as boxes. Without it the graph outputs are written as +raw `float32` files instead, which is what a numerical comparison needs. + +Measured on `yolo26m` at 640, against ultralytics on the same pixels: the three detections +above 0.25 agree in class and score (cat 0.918, couch 0.771, tv 0.681) with box coordinates +within 0.09 px, and the pre-decode tensors are at relative L1 1.7e-03 on the boxes and +4.7e-04 on the class logits. + ## Detection heads Head components take FPN features and produce the raw per-level tensors that the decoders From 8fee8dc200409cfc88097405784d697c9fba610d Mon Sep 17 00:00:00 2001 From: eunchae Date: Thu, 13 Aug 2026 16:48:15 +0900 Subject: [PATCH 13/89] =?UTF-8?q?docs(mmdet):=20=EB=AA=85=EB=A0=B9=20?= =?UTF-8?q?=EB=B8=94=EB=A1=9D=EC=9D=B4=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=8F=8C=EA=B2=8C=20=EA=B3=A0=EC=B9=98=EA=B3=A0=20=EA=B0=80?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EB=A7=81=ED=81=AC=EB=A5=BC=20=EB=8B=A8?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지난 라운드에 넣은 블록이 **안 돌았다.** 맨 앞 `g2c` 가 exit 127 이다 — 상위 프로젝트의 콘솔 스크립트지 vision.cpp 것이 아니다. 게다가 그 블록이 암시하는 cwd(`vision.cpp/`)에서 `uv run g2c` 를 부르면 **거기에 빈 .venv 를 만들고** 죽는다. 실행 위치를 못박고 `uv run` 을 붙였다. 그리고 vision.cpp 트리 안에 배포 가이드로 가는 링크가 **하나도 없었다**(재귀 grep 0건). README 에서 출발한 사람은 도는 명령에 영영 도달하지 못했다. README 표와 이 문서에 링크를 단다. --- README.md | 2 +- docs/mmdet-detectors.md | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 16fbae8..86a75ca 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Based on [ggml](https://github.com/ggml-org/ggml) similar to the [llama.cpp](htt | [**ESRGAN**](#real-esrgan) | Super-resolution | CPU, Vulkan | | [**YOLOv9t**](#yolov9t) | Object detection | CPU | | [**MMDetection** models](docs/mmdet-detectors.md) | Object detection, segmentation, tracking | CPU | -| [**Compiled PyTorch models**](docs/mmdet-detectors.md#models-whose-head-survives-tracing) | Any traceable `nn.Module` — ultralytics YOLO, torchvision | CPU | +| [**Compiled PyTorch models**](docs/mmdet-detectors.md#models-whose-head-survives-tracing) | Any traceable `nn.Module` — ultralytics YOLO, torchvision · [full guide](../docs/vision-cpp-mmdet-guide-en.md) | CPU | | [_Implement a model [**Guide**]_](docs/model-implementation-guide.md) | | | **Backbones:** SWIN (v1), DINO (v2), TinyViT diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 8898c9e..21dda54 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -251,17 +251,27 @@ then none of the steps above apply. A compiler emits the entire graph, `install_ it into `src/visp/arch/` with a registration unit beside it, and `vision-cli` dispatches on the architecture name recorded in the GGUF: +Run these from the **compiler checkout root** — the directory that holds `vision.cpp/` and +`pyproject.toml`. `g2c` is that project's console script, not one of vision.cpp's, and `uv run` +started inside `vision.cpp/` finds no project there: it builds a second virtual environment and +then fails to spawn. + ```sh -g2c --model "ultralytics.YOLO('yolo26m.pt')" --name Yolo26m --output out --input-shape 1,3,640,640 -python tools/install_arch.py out --name Yolo26m --detect-yolo -cmake --build build -j4 -./build/bin/vision-cli yolo26m -m out/Yolo26m.gguf -i photo.jpg -o detected.jpg +uv run g2c --model "ultralytics.YOLO('yolo26m.pt')" --name Yolo26m \ + --output out --input-shape 1,3,640,640 +python vision.cpp/tools/install_arch.py out --name Yolo26m --detect-yolo +cmake --build vision.cpp/build -j4 +./vision.cpp/build/bin/vision-cli yolo26m -m out/Yolo26m.gguf -i photo.jpg -o detected.jpg ``` `--detect-yolo` supplies what the GGUF does not carry — class count, strides, whether the head is NMS-free — so the result comes back as boxes. Without it the graph outputs are written as raw `float32` files instead, which is what a numerical comparison needs. +The full version of this route — options, failure modes, how to measure it — is +[`../docs/vision-cpp-mmdet-guide-en.md`](../docs/vision-cpp-mmdet-guide-en.md), in the +compiler checkout beside this one. + Measured on `yolo26m` at 640, against ultralytics on the same pixels: the three detections above 0.25 agree in class and score (cat 0.918, couch 0.771, tv 0.681) with box coordinates within 0.09 px, and the pre-decode tensors are at relative L1 1.7e-03 on the boxes and From bc9690c1a70c2abac39dd103019c2863c15072ca Mon Sep 17 00:00:00 2001 From: eunchae Date: Fri, 14 Aug 2026 08:21:20 +0900 Subject: [PATCH 14/89] =?UTF-8?q?feat(cli):=20=EA=B2=80=EC=B6=9C=20?= =?UTF-8?q?=EC=A2=8C=ED=91=9C=EB=A5=BC=20=EC=B0=8D=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 번의 독립 실주행이 연달아 '마지막 장애물' 로 같은 것을 꼽았다 — 등록된 검출기는 그림과 개수만 내놓아서, 결과를 수치로 확인하려면 `--detect-yolo` 없이 다시 등록하고 전체를 다시 빌드하고 `postproc.cpp` 를 읽어 디코더를 밖에서 다시 구현해야 했다. 두 번 다 그렇게 했다. 좌표는 이미 `dets` 에 있었다. 원본 이미지 좌표로 되돌려 찍는다: # x1 y1 x2 y2 score class 0 97.05 205.82 571.48 587.81 0.9184 15 cat ultralytics 기준값과 최대 0.087px 차이다. 문서 쪽: 실행 위치가 이 파일 안에서 두 가지였다(대부분 vision.cpp 기준, 컴파일러 절만 루트 기준)는 것을 본문에 못박고, `.bin` 규약이 `run_mmdet` 것이라는 경고를 등록 절에도 둔다. README 의 가이드 링크는 단독 저장소에서 404 라 문구로 바꿨다. --- README.md | 2 +- docs/mmdet-detectors.md | 9 ++++++++- src/cli/cli.cpp | 13 +++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 86a75ca..a4df78c 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Based on [ggml](https://github.com/ggml-org/ggml) similar to the [llama.cpp](htt | [**ESRGAN**](#real-esrgan) | Super-resolution | CPU, Vulkan | | [**YOLOv9t**](#yolov9t) | Object detection | CPU | | [**MMDetection** models](docs/mmdet-detectors.md) | Object detection, segmentation, tracking | CPU | -| [**Compiled PyTorch models**](docs/mmdet-detectors.md#models-whose-head-survives-tracing) | Any traceable `nn.Module` — ultralytics YOLO, torchvision · [full guide](../docs/vision-cpp-mmdet-guide-en.md) | CPU | +| [**Compiled PyTorch models**](docs/mmdet-detectors.md#models-whose-head-survives-tracing) | Any traceable `nn.Module` — ultralytics YOLO, torchvision · full guide in the compiler checkout, `docs/vision-cpp-mmdet-guide-en.md` | CPU | | [_Implement a model [**Guide**]_](docs/model-implementation-guide.md) | | | **Backbones:** SWIN (v1), DINO (v2), TinyViT diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 21dda54..82ed2f3 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -50,6 +50,11 @@ objects. Running a detector takes four steps. Steps 1 and 2 happen once per model; steps 3 and 4 are the deployment path. +Commands in this chapter run from the **vision.cpp checkout** — the paths are written +`tools/...`. The one exception is the compiler itself: `g2c` belongs to the project that +carries vision.cpp as a submodule, so *Models whose head survives tracing*, at the end, runs +from that project's root instead and says so. + ### Step 1 — Export the detector `mmdet_to_pt.py` loads an MMDetection config, wraps the detector so that the backbone and neck @@ -265,7 +270,9 @@ cmake --build vision.cpp/build -j4 ``` `--detect-yolo` supplies what the GGUF does not carry — class count, strides, whether the head -is NMS-free — so the result comes back as boxes. Without it the graph outputs are written as +is NMS-free — so the result comes back as boxes: `vision-cli` draws them and prints their +coordinates and scores. The `.bin` rule described under *Output* above belongs to `run_mmdet`; +a registered detector writes an image whatever the output path is called. Without it the graph outputs are written as raw `float32` files instead, which is what a numerical comparison needs. The full version of this route — options, failure modes, how to measure it — is diff --git a/src/cli/cli.cpp b/src/cli/cli.cpp index d9fb78e..bc0726b 100644 --- a/src/cli/cli.cpp +++ b/src/cli/cli.cpp @@ -576,6 +576,19 @@ void run_generated(cli_args const& args) { const float sx = float(image.extent[0]) / float(SZ); const float sy = float(image.extent[1]) / float(SZ); + + // 좌표까지 찍는다. 그리기만 하면 결과를 수치로 확인할 방법이 없어서, 참조 구현과 + // 맞춰 보려면 디코더를 밖에서 다시 짜야 했다. 여기 이미 다 있는 값이다. + printf(" %4s %9s %9s %9s %9s %8s %s\n", "#", "x1", "y1", "x2", "y2", "score", "class"); + for (size_t i = 0; i < dets.size(); ++i) { + detection const& d = dets[i]; + char const* name = (d.label >= 0 && size_t(d.label) < task.class_names.size()) + ? task.class_names[d.label].c_str() + : ""; + printf(" %4zu %9.2f %9.2f %9.2f %9.2f %8.4f %d %s\n", i, + d.x1 * sx, d.y1 * sy, d.x2 * sx, d.y2 * sy, d.score, d.label, name); + } + draw_detections(image_span(image), dets, task.class_names, sx, sy); image_save(image, args.output); printf("-> %zu boxes drawn, saved to %s\n", dets.size(), args.output); From 55161d63d8f8461efbca0a66aa45bee887d08d6a Mon Sep 17 00:00:00 2001 From: eunchae Date: Fri, 14 Aug 2026 08:50:32 +0900 Subject: [PATCH 15/89] =?UTF-8?q?docs(mmdet):=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=EC=9D=84=20=EA=B3=A0=EB=A5=B8=20=EC=9D=B4=EC=9C=A0=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20=EB=AC=B4=EC=97=87=EC=9D=B4=20=EB=82=98?= =?UTF-8?q?=EC=98=A4=EB=8A=94=EC=A7=80=EB=A7=8C=20=EC=A0=81=EB=8A=94?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mmdet-detectors.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 82ed2f3..18a3db6 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -208,9 +208,8 @@ run_mmdet model.gguf image.jpg detected.png 512 # the image, boxes drawn on it run_mmdet model.gguf image.jpg boxes.bin 512 # raw float32, six numbers per box ``` -An image is the default, which is what the rest of the command-line tools produce and what a -person looking at a result wants. Raw `float32` is what comparing against a reference -implementation needs, so it stays one extension away rather than being the only option. +An image is the default; a `.bin` extension gives raw `float32` instead, which is what +comparing against a reference implementation needs. Either way the highest-scoring detections are printed: From 3ffb3474340bb11189ee62eba4cb2a4d353968f9 Mon Sep 17 00:00:00 2001 From: eunchae Date: Fri, 14 Aug 2026 12:13:20 +0900 Subject: [PATCH 16/89] =?UTF-8?q?docs(mmdet):=204=EC=B0=A8=20=EB=8F=85?= =?UTF-8?q?=EB=A6=BD=20=EB=A6=AC=EB=B7=B0=EC=97=90=EC=84=9C=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=EB=90=9C=20=EA=B2=83=EB=93=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 'PYTHONPATH 에 있어야 한다' — 낡았다. export 가 래퍼 모듈을 옆에 쓰고 로더가 디렉토리를 import 경로에 넣는다(pipeline.py:490). 가이드는 이미 현행이었는데 이 문서만 옛날이었다. - `num_base` 가 'Decoding (c.det)' 표에 있었는데 실제로는 `c.head.num_base` (mmdet_to_pt.py:73-76, head.h:47). det_params 에 그 멤버가 없다 — head 표로 옮겼다. - 가이드로 가는 상대링크가 `vision.cpp/docs/...` 로 풀려 존재하지 않았다. 텍스트 경로로. - '박스 0.09px 이내' — 4차 실측 최대 0.092px. 0.1px 로. - 'Without it' 의 지시 대상이 두 문장 앞이라 오독됐다. 문장 순서 재배치. --- docs/mmdet-detectors.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 18a3db6..5200abc 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -89,8 +89,9 @@ Outputs: they are compiled into the runner rather than read at run time. See [Configuration reference](#configuration-reference). -The `.pt` file pickles by module name `mmdet_wrap`, so the export directory must be on -`PYTHONPATH` when it is loaded again. +The `.pt` pickles by module name `mmdet_wrap`. The export writes `mmdet_wrap.py` and +`mmdet_compat.py` beside it, and the compiler's loader puts the `.pt`'s own directory on the +import path, so the file opens anywhere with nothing set in the environment. ### Step 2 — Compile the backbone @@ -270,17 +271,18 @@ cmake --build vision.cpp/build -j4 `--detect-yolo` supplies what the GGUF does not carry — class count, strides, whether the head is NMS-free — so the result comes back as boxes: `vision-cli` draws them and prints their -coordinates and scores. The `.bin` rule described under *Output* above belongs to `run_mmdet`; -a registered detector writes an image whatever the output path is called. Without it the graph outputs are written as -raw `float32` files instead, which is what a numerical comparison needs. +coordinates and scores. Registered without the flag, the same command writes the graph +outputs as raw `float32` files instead, which is what a numerical comparison needs. The `.bin` +rule described under *Output* above belongs to `run_mmdet`; a registered detector writes an +image whatever the output path is called. The full version of this route — options, failure modes, how to measure it — is -[`../docs/vision-cpp-mmdet-guide-en.md`](../docs/vision-cpp-mmdet-guide-en.md), in the -compiler checkout beside this one. +`docs/vision-cpp-mmdet-guide-en.md` in the compiler checkout that carries this repository +as a submodule. Measured on `yolo26m` at 640, against ultralytics on the same pixels: the three detections above 0.25 agree in class and score (cat 0.918, couch 0.771, tv 0.681) with box coordinates -within 0.09 px, and the pre-decode tensors are at relative L1 1.7e-03 on the boxes and +within 0.1 px, and the pre-decode tensors are at relative L1 1.7e-03 on the boxes and 4.7e-04 on the class logits. ## Detection heads @@ -523,6 +525,7 @@ Head reconstruction (`c.head`) — maps onto `anchor_head_cfg`. | `cls_convs_prefix`, `reg_convs_prefix` | Weight-name prefixes of the towers. | | `cls_head`, `reg_head` | Names of the final convolutions. | | `head_has_norm` | Whether the tower contains normalisation layers. | +| `num_base` | Anchors per location, `len(octave_scales) * len(ratios)`. | Decoding (`c.det`) — maps onto `det_params`. @@ -530,7 +533,6 @@ Decoding (`c.det`) — maps onto `det_params`. | :--- | :--- | | `strides` | Stride per FPN level; its length defines the level count `L`. | | `octave_base_scale`, `octave_scales`, `ratios`, `center_offset` | Anchor generator. | -| `num_base` | Anchors per location, `len(octave_scales) * len(ratios)`. | | `means`, `stds` | Delta coder statistics. | | `num_classes`, `use_sigmoid` | Classification output layout and activation. | From 8b8c8eea7e790808ceccdc2b121c5f2e9b6eb33f Mon Sep 17 00:00:00 2001 From: eunchae Date: Fri, 14 Aug 2026 12:45:57 +0900 Subject: [PATCH 17/89] =?UTF-8?q?fix(tools):=20build=5Ffrcnn=5Fcpp.sh=20?= =?UTF-8?q?=EA=B0=80=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20=EB=8F=88=EB=8B=A4=20?= =?UTF-8?q?=E2=80=94=20=EB=AC=B8=EC=84=9C=EC=97=90=20=EC=8B=A4=EB=A6=B0=20?= =?UTF-8?q?=EC=B1=84=20=ED=95=9C=20=EB=B2=88=EB=8F=84=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=EB=90=9C=20=EC=A0=81=20=EC=97=86=EC=97=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FRCNN(two-stage) 경로를 문서대로 처음 돌리자 스크립트가 깨졌다. - 생성 .cpp 는 `visp/arch/.h` 를 include 하는데 헤더를 그 형태로 스테이징하지 않았다(`build_mmdet_cpp.sh` 는 한다). - 옛 러너(verify/roi/, 직접 include)를 컴파일했다. 정본은 하네스가 쓰는 verify/backbone/run_frcnn.cpp (ARCH_A/B 정의 방식, verify_heads.py:445-459)다. → 하네스의 빌드 라인을 그대로 옮기고 --build/SubB arch 자동 감지를 mmdet 스크립트와 맞췄다. 실측: faster-rcnn r50(학습 체크포인트) 800×800 에서 2패스 완주, 최고 RoI 가 class 15(cat) softmax 0.893. 문서: SubB 컴파일 shape 4,256,7,7 → **1000,256,7,7** (러너가 proposal 전부를 한 배치로 넣는다 — 4 로 구우면 첫 reshape 에서 abort, 실측). run_frcnn 호출에 빠져 있던 인자와 입력(정규화 CWHN .bin)·출력(prefix 덤프) 설명을 넣었다. --- docs/mmdet-detectors.md | 20 +++++++++++++-- tools/build/build_frcnn_cpp.sh | 47 ++++++++++++++++++++++++++-------- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 5200abc..b6225c1 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -451,15 +451,31 @@ Export both sub-graphs with `frcnn_to_pt.py`, compile each, then build and run: ```sh python tools/frontend/mmdet/frcnn_to_pt.py \ --config faster-rcnn_r50_fpn_1x_coco.py --checkpoint frcnn.pth --out /tmp/frcnn -# compile /tmp/frcnn/FRCNN_SubA.pt at 1,3,800,800 and /tmp/frcnn/FRCNN_SubB.pt at 4,256,7,7 +# compile /tmp/frcnn/FRCNN_SubA.pt at 1,3,800,800 and /tmp/frcnn/FRCNN_SubB.pt at +# rpn_max,roi_channels,roi_out,roi_out — 1000,256,7,7 for this config; the +# values are in frcnn.json. The runner feeds every proposal as one batch, +# so a smaller batch dimension aborts at the first reshape. bash tools/build/build_frcnn_cpp.sh output/FRCNN_SubA output/FRCNN_SubB output/FRCNN_SubA/run_frcnn \ output/FRCNN_SubA/FRCNN_SubA.gguf output/FRCNN_SubB/FRCNN_SubB.gguf \ - /tmp/frcnn/frcnn.json input.bin 800 + /tmp/frcnn/frcnn.json input.bin out 800 ``` +`input.bin` is the image at the traced resolution, normalised with `frcnn.json`'s +`img_mean`/`img_std`, written as raw CWHN `float32`: + +```python +x = (np.asarray(img.resize((800, 800)), dtype=np.float32) - mean) / std +np.ascontiguousarray(x).tofile("input.bin") # HWC memory order == CWHN +``` + +`out` is a prefix: the runner writes `out.cls.0.bin` (`rpn_max × num_classes+1` logits), +`out.box.0.bin`, the proposals, and the per-level RPN tensors — raw `float32`, for comparing +against the reference. Run on a trained Faster R-CNN R50 at 800, the highest-scoring RoI on +`cat-and-hat.jpg` is class 15 at 0.89 after softmax. + `run_roi_verify` and `run_rpn_verify` check the two host stages in isolation against dumps from the reference implementation. diff --git a/tools/build/build_frcnn_cpp.sh b/tools/build/build_frcnn_cpp.sh index aec20f4..60121b0 100755 --- a/tools/build/build_frcnn_cpp.sh +++ b/tools/build/build_frcnn_cpp.sh @@ -1,30 +1,55 @@ #!/usr/bin/env bash # # build_frcnn_cpp.sh — Faster R-CNN two-stage 러너(run_frcnn) 컴파일. -# g2c 가 생성한 SubA(backbone+neck+RPN)·SubB(bbox_head) output/.cpp 를 run_frcnn.cpp 와 함께 +# g2c 가 생성한 SubA(backbone+neck+RPN)·SubB(bbox_head) 산출을 run_frcnn.cpp 와 함께 # 컴파일해 libvisioncpp 와 링크. (host op: rpn_proposals/roi_align/detect_roi 는 라이브러리) # -# 사용: build_frcnn_cpp.sh +# ⚠️ 빌드 라인은 verify_heads.py(3_build2 단계)와 같아야 한다 — 하네스가 정본이다. +# 생성 .cpp 는 `visp/arch/.h` 를 include 하므로 헤더를 그 형태로 스테이징한다. +# +# 사용: build_frcnn_cpp.sh [--build ] [SubB_arch] # 예: build_frcnn_cpp.sh output/FRCNN_SubA output/FRCNN_SubB # env: VISP_BUILD = libvisioncpp 빌드 디렉토리 (기본: /build) -# set -e SELF="$(cd "$(dirname "$0")" && pwd)" V="$(cd "$SELF/../.." && pwd)" -DETECT="$V/tools/detect" # head.h (run_frcnn 이 include) -RUN="$V/tools/verify" # E2E 검증 러너 -GA="$(cd "${1:?usage: build_frcnn_cpp.sh }" && pwd)" +RUN="$V/tools/verify" + +ARG_BUILD="" +POS=() +while [ $# -gt 0 ]; do + case "$1" in + --build) ARG_BUILD="${2:?--build needs a directory}"; shift 2 ;; + --build=*) ARG_BUILD="${1#--build=}"; shift ;; + *) POS+=("$1"); shift ;; + esac +done +set -- "${POS[@]}" + +GA="$(cd "${1:?usage: build_frcnn_cpp.sh [--build ] [SubB_arch]}" && pwd)" GB="$(cd "${2:?SubB_dir 필요}" && pwd)" -BUILD="${VISP_BUILD:-$V/build}"; LIB="$BUILD/lib" -[ -f "$LIB/libvisioncpp.so" ] || { echo "libvisioncpp.so 없음: $LIB"; exit 1; } +ARCH_B="${3:-}" +if [ -z "$ARCH_B" ]; then + ARCH_B="$(basename "$(ls "$GB"/*.cpp | grep -v run_ | head -1)" .cpp)" +fi +BUILD="${ARG_BUILD:-${VISP_BUILD:-$V/build}}"; LIB="$BUILD/lib" +[ -f "$LIB/libvisioncpp.so" ] || { echo "libvisioncpp.so 없음: $LIB (--build 로 지정)"; exit 1; } FMT_INC="$BUILD/_deps/fmt-src/include"; FMT_FLAGS="" [ -f "$FMT_INC/fmt/format.h" ] && FMT_FLAGS="-DVISP_FMT_LIB -I$FMT_INC" -echo "SubA=$GA SubB=$GB build=$BUILD" +echo "SubA=$GA SubB=$GB arch_b=$ARCH_B build=$BUILD" +INC_A="$GA/inc"; INC_B="$GB/inc" +mkdir -p "$INC_A/visp/arch" "$INC_B/visp/arch" +cp "$GA/FRCNN_SubA.h" "$INC_A/visp/arch/" +cp "$GB/$ARCH_B.h" "$INC_B/visp/arch/" + g++ -std=c++20 -O2 $FMT_FLAGS \ - -I"$GA" -I"$GB" -I"$DETECT" -I"$V/include" -I"$V/src" \ + -DARCH_A=FRCNN_SubA -DARCH_B="$ARCH_B" \ + -DVISP_ARCH_HEADER_A='"visp/arch/FRCNN_SubA.h"' \ + -DVISP_ARCH_HEADER_B="\"visp/arch/$ARCH_B.h\"" \ + -I"$INC_A" -I"$INC_B" -I"$V/include" -I"$V/src" \ -I"$V/depend/llama/ggml/include" -I"$V/depend/llama/vendor" \ - "$RUN/roi/run_frcnn.cpp" "$GA/FRCNN_SubA.cpp" "$GB/FRCNN_SubB.cpp" \ + "$RUN/backbone/run_frcnn.cpp" "$GA/FRCNN_SubA.cpp" "$GB/$ARCH_B.cpp" \ -L"$LIB" -lvisioncpp -lggml -lggml-base -lggml-cpu -Wl,-rpath,"$LIB" \ -o "$GA/run_frcnn" echo "built: $GA/run_frcnn" From fbbdfc7173623d0d013e9ee3198f4a7feb6a6acc Mon Sep 17 00:00:00 2001 From: eunchae Date: Fri, 14 Aug 2026 12:55:58 +0900 Subject: [PATCH 18/89] =?UTF-8?q?docs(tools):=20=EB=82=A1=EC=9D=80=20?= =?UTF-8?q?=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80=20=EC=88=98=EC=B9=98?= =?UTF-8?q?=EB=A5=BC=20=ED=98=84=ED=96=89=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '조립기 함수 4개로 8계열' · '41 중 22 커버' — 초기에 쓴 숫자다. 현행은 함수 13개, 41 중 38이 허용치 안(근거 full100_i.log), 나머지 3은 공개 체크포인트가 없는 텍스트+이미지 모델이라 측정 대상이 아니다. --- tools/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/README.md b/tools/README.md index 8aa73a7..dfae01e 100755 --- a/tools/README.md +++ b/tools/README.md @@ -32,7 +32,7 @@ control flow that depends on the data — suppression counts that are not known deformable offsets derived from an earlier prediction, proposal counts that vary per image — and tracing records only the path one input happened to take. -`detect/head.cpp` assembles eight MMDetection families with four functions. RetinaNet, ATSS, +`detect/head.cpp` assembles the dense-head families with thirteen functions. RetinaNet, ATSS, PAA, FCOS and GFL share one skeleton — two convolution towers and a few output convolutions — so they differ only through flags on `anchor_head_cfg`: a third `centerness` branch, per-level learnable scales, the FCOS bbox transform, and GFL's distribution decode. VFNet, RepPoints and @@ -50,7 +50,8 @@ perfect cosine. Of the 100 MMDetection families, 41 carry a dense head; the rest are two-stage detectors, trackers, or panoptic and instance models whose output goes through `roi/` and `seg/` instead. -Twenty-two of the 41 are covered. Ten of those needed no new code at all: they subclass a head +Thirty-eight of the 41 measure within tolerance; the other three are text-and-image models +with no published checkpoint to measure against. Ten needed no new code at all: they subclass a head that was already handled and change only the loss, the backbone or the neck — GHM and PVT are `RetinaHead`, DyHead is `ATSSHead`, NAS-FCOS is `FCOSHead`, LD subclasses `GFLHead`, LAD subclasses `PAAHead`, BoxInst and CondInst reach `FCOSHead`. The assembler picks its path by From 0480479485ae0b198b23325de95960dc84b3a61f Mon Sep 17 00:00:00 2001 From: eunchae Date: Fri, 14 Aug 2026 14:08:29 +0900 Subject: [PATCH 19/89] =?UTF-8?q?fix(detect):=20test=5Fcfg=20=EC=9E=84?= =?UTF-8?q?=EA=B3=84=EA=B0=92=EC=9D=84=20=EC=8B=A3=EA=B3=A0,=20centerness?= =?UTF-8?q?=20=EB=A5=BC=20score=20factor=20=EB=A1=9C=20=EA=B3=B1=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit atss 가 박스는 0.14px 로 맞는데 **점수만 0.071** 어긋났다. 두 원인이었다. ① mmdet 의 `test_cfg` 가 **하나도 emit 되지 않아** 라이브러리 기본값(0.05/0.5/1000/100)을 쓰고 있었다. 실측: retinanet 은 nms 0.5(기본값과 우연히 같다) 인데 **ATSS 는 0.6**, RTMDet 은 score 0.001·nms 0.65·max 300 이다. 임계값이 다르면 살아남는 박스 **집합**이 달라져 비교 자체가 성립하지 않는다. 게이트 앞으로 빼서 디코더 종류와 무관하게 나간다. ② ATSS 의 centerness 가 디코드에 안 들어갔다. mmdet 은 그것을 `score_factors` 로 받아 `_bbox_post_process` 에서 `scores *= score_factors` 를 한다 — 그래서 우리 점수가 `sigmoid(centerness)` 배만큼 높았다. **곱하는 자리가 중요하다.** mmdet 은 `filter_scores_and_topk` 로 cls 점수만 보고 자른 **뒤에** 곱한다. 앞에서 곱하면 살아남는 후보가 달라져, 점수는 맞는데 경계선 박스가 조용히 사라진다 — 짝지어 비교하면 안 보인다. YOLACT 의 coeff 갈래는 점수가 아니므로 `ctr_tanh` 로 가른다. ③ 검사기가 **개수를 안 봤다.** `match()` 가 ref→got 만 걸어서, 임계값이 어긋나 집합이 달라진 경우가 '짝지은 것들은 잘 맞음' 으로 통과했다. 실제로 이번에 개수차 2건이 그렇게 숨어 있었다. 개수 비교를 통과 조건에 넣었다. 실측 (mmdet predict_by_feat 대비, 같은 픽셀): atss 점수 0.071 → **0.000** · 박스 0.14px · 개수차 2 → **0** retinanet 0.27px · 0.000 · 개수차 0 — 회귀 없음 --- src/visp/postproc.cpp | 16 ++- src/visp/postproc.h | 7 +- tools/frontend/mmdet/mmdet_to_pt.py | 12 +- tools/frontend/mmdet/mmdet_wrap.py | 18 +++ tools/verify/backbone/run_mmdet.cpp | 14 +- tools/verify/dense_head/verify_postproc.py | 155 +++++++++++++++++++++ 6 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 tools/verify/dense_head/verify_postproc.py diff --git a/src/visp/postproc.cpp b/src/visp/postproc.cpp index d690953..fb4a34b 100755 --- a/src/visp/postproc.cpp +++ b/src/visp/postproc.cpp @@ -102,7 +102,8 @@ static inline float sigmoidf(float x) { return 1.0f / (1.0f + std::exp(-x)); } std::vector detect_anchor( std::vector> const& cls_scores, std::vector> const& bbox_preds, - std::vector> const& feat_hw, det_params const& p) { + std::vector> const& feat_hw, det_params const& p, + std::vector> const* score_factors) { int num_base = (int)(p.octave_scales.size() * p.ratios.size()); int nc = p.num_classes; @@ -137,6 +138,15 @@ std::vector detect_anchor( [](auto const& a, auto const& c) { return std::get<0>(a) > std::get<0>(c); }); lvl.resize(p.nms_pre); } + // score factor (ATSS/PAA 의 centerness). **여기서 곱한다 — top-k 뒤다.** + // mmdet 은 `filter_scores_and_topk` 로 cls 점수만 보고 자른 뒤 + // `_bbox_post_process` 에서 `scores *= score_factors` 를 한다 + // (base_dense_head.py). 앞에서 곱하면 **살아남는 후보가 달라진다** — + // 점수는 맞는데 경계선 박스 몇 개가 사라지고, 짝지어 비교하면 안 보인다. + float const* sf = (score_factors && l < (int)score_factors->size() && + !(*score_factors)[l].empty()) + ? (*score_factors)[l].data() + : nullptr; for (auto const& t : lvl) { int aidx = std::get<2>(t), b = aidx % num_base, pos = aidx / num_base; float delta[4]; @@ -145,7 +155,9 @@ std::vector detect_anchor( float outb[4]; delta2bbox(anchors.data() + (size_t)aidx * 4, delta, 1, outb, p.means, p.stds, p.input_w, p.input_h); - cand.push_back({outb[0], outb[1], outb[2], outb[3], std::get<0>(t), std::get<1>(t)}); + float sc = std::get<0>(t); + if (sf) sc *= sigmoidf(sf[(size_t)pos * num_base + b]); + cand.push_back({outb[0], outb[1], outb[2], outb[3], sc, std::get<1>(t)}); } } // multiclass NMS (label별) — mmcv batched_nms 동등 diff --git a/src/visp/postproc.h b/src/visp/postproc.h index ab25d7e..590add6 100755 --- a/src/visp/postproc.h +++ b/src/visp/postproc.h @@ -48,11 +48,16 @@ struct det_params { // per-level 원시출력: cls_scores[level] = [num_base*num_classes, feat_w, feat_h](CWHN flat), // bbox_preds[level] = [num_base*4, feat_w, feat_h]. feat 크기는 shapes 로 전달. // 반환: 최종 detection(픽셀좌표, label, score). +// score_factors: ATSS/PAA 의 centerness 갈래(레벨별 [num_base, feat_w, feat_h] CWHN flat). +// nullptr 이면 cls 점수만 쓴다. **top-k 뒤에 곱한다** — mmdet 이 그렇다 +// (`base_dense_head._bbox_post_process`). 앞에서 곱하면 살아남는 후보가 달라져, +// 점수는 맞는데 경계선 박스가 조용히 사라진다. std::vector detect_anchor( std::vector> const& cls_scores, std::vector> const& bbox_preds, std::vector> const& feat_hw, // 레벨별 (feat_h, feat_w) - det_params const& p); + det_params const& p, + std::vector> const* score_factors = nullptr); // ── anchor-free 검출 (FCOS/FCOS계열) ──────────────────────────────────────── // point 생성 (mmdet MlvlPointGenerator): point = (idx + offset) * stride. diff --git a/tools/frontend/mmdet/mmdet_to_pt.py b/tools/frontend/mmdet/mmdet_to_pt.py index ca53a66..2af6235 100755 --- a/tools/frontend/mmdet/mmdet_to_pt.py +++ b/tools/frontend/mmdet/mmdet_to_pt.py @@ -91,8 +91,16 @@ def emit_params(cfg, config_name): out.append(f" c.head.ddq_iou_thr = {_f(cfg.get('ddq_iou_thr', 0.8))};\n") out.append(f" c.head.center_offset = {_f(cfg.get('center_offset', 0.0))};\n\n") - # anchor(Delta) 디코드가 되는 계열만 c.det 를 채운다. 조립은 되는데 코더가 - # 다른 계열(FSAF 의 TBLRBBoxCoder 등)은 head 원시 출력까지만 낸다. + # 임계값은 **게이트 앞**이다 — 디코더가 무엇이 되든 mmdet 의 test_cfg 를 써야 한다. + # 안 실으면 구조체 기본값(0.05/0.5/1000/100)이 쓰이는데, ATSS 는 nms 0.6, + # RTMDet 은 score 0.001·nms 0.65·max 300 이라 살아남는 박스 집합이 달라진다. + out.append(f" c.det.score_thr = {_f(cfg.get('score_thr', 0.05))};\n") + out.append(f" c.det.nms_thr = {_f(cfg.get('nms_thr', 0.5))};\n") + out.append(f" c.det.nms_pre = {int(cfg.get('nms_pre', 1000))};\n") + out.append(f" c.det.max_per_img = {int(cfg.get('max_per_img', 100))};\n") + + # anchor(Delta) 디코드가 되는 계열만 c.det 의 **앵커 파라미터**를 채운다. 조립은 + # 되는데 코더가 다른 계열(FSAF 의 TBLRBBoxCoder 등)은 head 원시 출력까지만 낸다. if h != "anchor" or not cfg.get("can_decode", True): out += [ " // Decoding for this family is the caller's: the head above emits raw\n", diff --git a/tools/frontend/mmdet/mmdet_wrap.py b/tools/frontend/mmdet/mmdet_wrap.py index 232b809..43d6b2c 100755 --- a/tools/frontend/mmdet/mmdet_wrap.py +++ b/tools/frontend/mmdet/mmdet_wrap.py @@ -430,12 +430,30 @@ def _last_conv(seq): img_std = [float(v) for v in dp.std.flatten().tolist()] to_rgb = not bool(getattr(dp, "_channel_conversion", False)) + # ── 디코드 임계값: mmdet 의 test_cfg 가 정본이다 ──────────────────────────── + # 안 실으면 러너가 라이브러리 기본값(0.05/0.5/1000/100)을 쓴다. 계열마다 다르다 — + # 실측: retinanet 은 nms 0.5 인데 **ATSS 는 0.6**, RTMDet 은 score 0.001·nms 0.65· + # max 300 이다. 다르면 살아남는 박스 집합이 달라져 mmdet 과의 비교가 성립하지 않는다. + tc = getattr(bh, "test_cfg", None) or getattr(det, "test_cfg", None) + + def _tc(key, default): + return default if tc is None else tc.get(key, default) + + _nms = _tc("nms", {}) or {} + thresholds = { + "score_thr": float(_tc("score_thr", 0.05)), + "nms_thr": float(_nms.get("iou_threshold", 0.5)), + "nms_pre": int(_tc("nms_pre", 1000)), + "max_per_img": int(_tc("max_per_img", 100)), + } + return { "head_type": kind, # ── 전처리(pre) — 이미지→텐서 (vision.cpp preprocess) ── "img_mean": img_mean, "img_std": img_std, "to_rgb": to_rgb, + **thresholds, "use_sigmoid": bool(getattr(bh, "use_sigmoid_cls", True)), "num_classes": ncls, "strides": [float(s) for s in strides], diff --git a/tools/verify/backbone/run_mmdet.cpp b/tools/verify/backbone/run_mmdet.cpp index b76db75..b0daaa6 100755 --- a/tools/verify/backbone/run_mmdet.cpp +++ b/tools/verify/backbone/run_mmdet.cpp @@ -330,15 +330,23 @@ int main(int argc, char** argv) { return 4; } - // 6) raw cls/box → detect_anchor (decode + NMS) - std::vector> cls_v(L), box_v(L); + // 6) raw cls/box(+ctr) → detect_anchor (decode + NMS) + std::vector> cls_v(L), box_v(L), ctr_v; std::vector> feat_hw(L); for (int l = 0; l < L; ++l) { feat_hw[l] = { (int)cls_t[l]->ne[2], (int)cls_t[l]->ne[1] }; // (fh, fw) cls_v[l] = to_vec(cls_t[l]); box_v[l] = to_vec(box_t[l]); } - std::vector dets = detect_anchor(cls_v, box_v, feat_hw, dp); + // centerness 갈래가 있으면 score factor 로 넘긴다(ATSS·PAA·DDOD…). mmdet 은 이걸 + // top-k 뒤에 곱한다 — 안 넘기면 **박스는 맞고 점수만** 높게 나온다(실측 Δ0.071). + // ⚠️ YOLACT 의 coeff 갈래는 점수가 아니다 — `ctr_tanh` 로 가른다. + if ((int)ho.ctr.size() >= L && !hc.ctr_tanh) { + ctr_v.resize(L); + for (int l = 0; l < L; ++l) ctr_v[l] = to_vec(ho.ctr[l]); + } + std::vector dets = + detect_anchor(cls_v, box_v, feat_hw, dp, ctr_v.empty() ? nullptr : &ctr_v); // An image by default, as with every other entry point here. Raw numbers on request. std::string out_s(outp); diff --git a/tools/verify/dense_head/verify_postproc.py b/tools/verify/dense_head/verify_postproc.py new file mode 100644 index 0000000..8384846 --- /dev/null +++ b/tools/verify/dense_head/verify_postproc.py @@ -0,0 +1,155 @@ +"""verify_postproc.py — **디코드 이후**까지 계열별로 잰다. + +`verify_heads.py` 는 디코드 직전에서 끊는다(NMS 를 거치면 어느 텐서가 틀렸는지 못 짚기 +때문). 그래서 그 숫자에는 앵커 생성 · delta→box · 임계값 · NMS 가 들어가지 않는다. +이 스크립트가 그 뒷단만 본다: + + mmdet 자신의 `predict_by_feat` vs `run_mmdet` 이 낸 최종 박스 + +**후처리는 계열이 아니라 head 종류별로 공유된다** — `postproc.cpp` 의 한 함수를 여러 +계열이 함께 탄다. 그래서 종류를 덮는 것이 계열을 덮는 것보다 의미가 크다. + +⚠️ **양쪽에 같은 픽셀을 준다.** 각자 리사이즈하게 두면 백엔드가 아니라 리사이즈 구현을 +재게 된다. 원본이 정사각이 아니면 한 번만 줄여 무손실로 저장해서 둘 다에 준다. + +⚠️ **채널 순서를 맞춘다.** 파라미터 헤더의 `to_rgb` 가 러너의 규약이고, mmdet 쪽 +`data_preprocessor` 와 반대일 수 있다. 처음 재면서 이걸 뒤집어 점수가 0.836 → 0.757 로 +어긋났다 — 백엔드 문제로 보이지만 하네스 버그였다. + +사용: + python verify_postproc.py [size] + +`gen_dir` 은 `build_mmdet_cpp.sh` 가 `run_mmdet` 을 만들어 둔 디렉토리다. +""" +import os +import subprocess +import sys + +import numpy as np + +# mmpretrain 의 blip 이 이 조합에서 import 시 죽는다 — mmdet 로드에 필요 없으므로 막는다. +import types +for _n in ("mmpretrain.models.multimodal.blip", + "mmpretrain.models.multimodal.blip.language_model"): + _m = types.ModuleType(_n) + _m.__path__ = [] + sys.modules[_n] = _m + +import torch # noqa: E402 +from PIL import Image # noqa: E402 + + +def cpp_boxes(gen_dir, image, size, thr): + """run_mmdet 을 돌려 디코드된 박스를 받는다(.bin = 박스당 6값).""" + out = os.path.join(gen_dir, "_postproc_check.bin") + exe = os.path.join(gen_dir, "run_mmdet") + if not os.path.exists(exe): + sys.exit(f"run_mmdet 이 없다: {exe} (build_mmdet_cpp.sh 로 먼저 만든다)") + r = subprocess.run([exe, _one_gguf(gen_dir), image, out, str(size)], + capture_output=True, text=True) + if not os.path.exists(out): + sys.exit("run_mmdet 이 박스를 안 냈다:\n" + (r.stderr or r.stdout)[-400:]) + d = np.fromfile(out, dtype=np.float32).reshape(-1, 6) + return d[d[:, 4] >= thr] + + +def _one_gguf(gen_dir): + g = [f for f in os.listdir(gen_dir) if f.endswith(".gguf")] + if len(g) != 1: + sys.exit(f"gguf 를 하나로 못 좁혔다: {g}") + return os.path.join(gen_dir, g[0]) + + +def mmdet_boxes(cfg, ckpt, image, size, thr, to_rgb): + """mmdet 자신의 predict_by_feat — 앵커·디코드·NMS 의 정본.""" + from mmdet.apis import init_detector + + det = init_detector(cfg, ckpt, device="cpu") + det.eval() + dp = det.data_preprocessor + mean = dp.mean.view(3).numpy() + std = dp.std.view(3).numpy() + + im = np.asarray(Image.open(image).convert("RGB").resize((size, size), Image.BILINEAR), + dtype=np.float32) + x = im[:, :, ::-1] if to_rgb else im # 러너 규약에 맞춘다 + x = (np.ascontiguousarray(x) - mean) / std + t = torch.from_numpy(x).permute(2, 0, 1).unsqueeze(0) + + with torch.no_grad(): + outs = det.bbox_head(det.extract_feat(t)) + meta = [{"img_shape": (size, size), "ori_shape": (size, size), + "scale_factor": (1.0, 1.0), "batch_input_shape": (size, size)}] + res = det.bbox_head.predict_by_feat(*outs, batch_img_metas=meta, rescale=False)[0] + + keep = res.scores.numpy() >= thr + return np.concatenate([res.bboxes.numpy()[keep], + res.scores.numpy()[keep, None], + res.labels.numpy()[keep, None].astype(np.float32)], 1) + + +def match(ref, got): + """ref 각 박스에 가장 가까운 got 박스를 짝지어 최대 오차를 낸다. + + 정렬해서 맞추면 안 된다 — 한 건만 어긋나도 뒤가 통째로 밀린다. + + ⚠️ **개수는 따로 본다.** 이 함수는 `ref → got` 만 걸으므로 C++ 이 **더 낸** 박스나 + **못 낸** 박스를 못 본다. 그것만 보고 통과시키면, 임계값이 어긋나 집합이 달라진 + 경우가 "짝지은 것들은 잘 맞음" 으로 조용히 통과한다 — 호출부가 개수를 먼저 비교한다. + """ + if len(ref) == 0 or len(got) == 0: + return None + rows = [] + for r in ref: + d = np.abs(got[:, :4] - r[:4]).max(1) + j = int(d.argmin()) + rows.append((r, got[j], d[j])) + return rows + + +def main(): + if len(sys.argv) < 5: + print(__doc__) + return 2 + gen, cfg, ckpt, image = sys.argv[1:5] + size = int(sys.argv[5]) if len(sys.argv) > 5 else 512 + thr = 0.30 + + to_rgb = False + hdr = [f for f in os.listdir(os.path.dirname(cfg) or ".") if f.endswith(".postproc.h")] + for d in (gen, os.path.dirname(gen)): + for f in os.listdir(d): + if f.endswith(".postproc.h"): + txt = open(os.path.join(d, f), encoding="utf-8").read() + to_rgb = "c.to_rgb = true" in txt + print(f"to_rgb={to_rgb} (파라미터 헤더 기준) · thr={thr} · size={size}") + + got = cpp_boxes(gen, image, size, thr) + ref = mmdet_boxes(cfg, ckpt, image, size, thr, to_rgb) + print(f"\nmmdet {len(ref)}건 · run_mmdet {len(got)}건") + + rows = match(ref, got) + if rows is None: + print(" 한쪽이 비어 비교 불가") + return 1 + print(" 라벨 mmdet 박스/점수 C++ 박스/점수 박스Δ 점수Δ") + worst_b = worst_s = 0.0 + bad_label = 0 + for r, g, db in rows: + ds = abs(r[4] - g[4]) + worst_b, worst_s = max(worst_b, db), max(worst_s, ds) + bad_label += int(r[5] != g[5]) + print(f" {int(r[5]):4d} [{r[0]:6.1f},{r[1]:6.1f},{r[2]:6.1f},{r[3]:6.1f}] {r[4]:.3f}" + f" [{g[0]:6.1f},{g[1]:6.1f},{g[2]:6.1f},{g[3]:6.1f}] {g[4]:.3f}" + f" {db:6.2f}px {ds:6.3f}") + n_gap = abs(len(ref) - len(got)) + print(f"\n최대: 박스 {worst_b:.2f}px · 점수 {worst_s:.3f} · 라벨 불일치 {bad_label}건" + f" · 개수차 {n_gap}건") + if n_gap: + print(" ⚠️ 개수가 다르다 — 임계값(score_thr/nms_thr/max_per_img)이 mmdet 과 어긋났을 때" + " 나는 증상이다. 짝지은 것만 보면 조용히 통과한다.") + return 0 if (worst_b < 2.0 and worst_s < 0.05 and bad_label == 0 and n_gap == 0) else 1 + + +if __name__ == "__main__": + sys.exit(main()) From 5f3dca9771cefdb113feca7392f2b402e03cc15b Mon Sep 17 00:00:00 2001 From: eunchae Date: Fri, 14 Aug 2026 14:25:52 +0900 Subject: [PATCH 20/89] =?UTF-8?q?feat(detect):=20=EA=B1=B0=EB=A6=AC=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EA=B3=84=EC=97=B4=EC=9D=84=20=EB=94=94?= =?UTF-8?q?=EC=BD=94=EB=93=9C=ED=95=9C=EB=8B=A4=20(fcos=C2=B7gfl=C2=B7vfne?= =?UTF-8?q?t)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 디코드는 **조립과 다른 축**이다. 조립은 타워 모양(head_kind)으로 갈리지만 디코드는 박스가 무엇이냐로 갈린다 — 앵커 대비 delta 냐, 격자점 대비 거리냐. 러너가 그걸 안 갈라 `detect_anchor` 하나만 불러서, 거리 계열은 앵커 파라미터가 비어 후보 0개였다. `detect_fcos` 는 이미 구현돼 있었고 아무도 안 불렀다. 부르면서 세 결함을 고쳤다: ① **centerness 를 무조건 읽었다.** GFL 은 cls 가 품질을 겸하고 VFNet 은 IoU-aware 라 그 갈래가 없다. 0 벡터를 넘기면 sigmoid(0)=0.5 라 점수가 **정확히 절반**이 된다 — 비어 있으면 곱하지 않는다. ② **격자 오프셋이 0.5 로 박혀 있었다.** FCOS 의 MlvlPointGenerator 만 0.5 이고 GFL·VFNet·RTMDet 은 AnchorGenerator(center_offset=0) 다. 틀리면 박스가 반 칸씩 밀리는데, 오차가 stride 에 비례해 커지는 것이 그 증상이다. 프론트엔드도 같이 고쳤다 — `MlvlPointGenerator` 의 속성명은 `center_offset` 이 아니라 **`offset`** 이라(point_generator.py:107), 하나만 보면 point 계열이 전부 0.0 으로 나갔다. ③ **점수를 임계값 전에 곱했다.** mmdet 은 `filter_scores_and_topk` 로 cls 만 보고 자른 뒤 centerness 를 곱한다. 앞에서 곱하면 살아남는 후보가 달라진다. 그리고 `norm_on_bbox` 재적용을 없앴다 — 조립기가 이미 픽셀 거리로 만들어 놓는다. 실측 (mmdet predict_by_feat 대비, 같은 픽셀 512): fcos 0.13px · 0.000 (이전: 박스 0개) gfl 0.23px · 0.004 (이전: 박스 0개) vfnet 0.46px · 0.003 (이전: 박스 0개) atss 0.14px · 0.000 회귀 없음 retinanet 0.15px · 0.006 회귀 없음 TOOD 소경로도 넣었다(격자 좌표 ×stride, cls 는 이미 확률이라 시그모이드 재적용 금지 — mmdet tood_head.py:546 과 같다). 다만 **검증은 못 했다**: tood 는 cls 는 torch 와 맞는데 box 갈래가 상대 L1 0.63 으로 어긋난다 — 조립 문제라 디코드 범위 밖이다. RTMDet 도 같은 부류다(레벨 1·2 의 cls 가 torch 와 크게 다르다). --- src/visp/postproc.cpp | 30 ++++++--- src/visp/postproc.h | 9 ++- tools/frontend/mmdet/mmdet_wrap.py | 20 +++++- tools/verify/backbone/run_mmdet.cpp | 72 +++++++++++++++++++++- tools/verify/dense_head/verify_postproc.py | 11 ++++ 5 files changed, 126 insertions(+), 16 deletions(-) diff --git a/src/visp/postproc.cpp b/src/visp/postproc.cpp index fb4a34b..68487c6 100755 --- a/src/visp/postproc.cpp +++ b/src/visp/postproc.cpp @@ -216,16 +216,25 @@ std::vector detect_fcos( for (int l = 0; l < nlev; ++l) { int fh = feat_hw[l].first, fw = feat_hw[l].second, npos = fh * fw; float stride = p.strides[l]; - auto pts = gen_points(fh, fw, stride); + // 격자 중심 오프셋은 **계열마다 다르다.** FCOS 의 MlvlPointGenerator 는 0.5, + // GFL·VFNet·RTMDet 은 AnchorGenerator(center_offset=0) 라 0 이다. 0.5 를 잘못 + // 쓰면 전 레벨에서 박스가 반 칸씩 밀린다 — 오차가 stride 에 비례해 커지는 것이 + // 그 증상이다(실측 사례: TOOD 에서 어느 레벨이나 +0.47). + auto pts = gen_points(fh, fw, stride, p.point_offset); float const* cls = cls_scores[l].data(); // HWC: pos*nc + j float const* box = bbox_preds[l].data(); // HWC: pos*4 + k - float const* ctr = centerness[l].data(); // HWC: pos*1 + // centerness 갈래가 없는 계열이 있다(GFL 은 cls 가 품질을 겸하고, VFNet 은 IoU-aware). + // 0 벡터를 넘기면 sigmoid(0)=0.5 라 점수가 **정확히 절반**이 되므로, 없으면 곱하지 않는다. + float const* ctr = (l < (int)centerness.size() && !centerness[l].empty()) + ? centerness[l].data() + : nullptr; std::vector> lvl; for (int pos = 0; pos < npos; ++pos) { - float cen = sigmoidf(ctr[pos]); float const* cs = cls + (size_t)pos * nc; for (int j = 0; j < nc; ++j) { - float sc = sigmoidf(cs[j]) * cen; + // 임계값과 top-k 는 **cls 점수만** 보고 건다 — mmdet 의 + // `filter_scores_and_topk` 가 그렇고, centerness 는 그 뒤에 곱한다. + float sc = sigmoidf(cs[j]); if (sc > p.score_thr) lvl.emplace_back(sc, j, pos); } } @@ -237,14 +246,15 @@ std::vector detect_fcos( for (auto const& t : lvl) { int pos = std::get<2>(t); float dist[4]; - // bbox_pred 는 traced head(forward_single) 가 이미 scale·relu·*stride 적용한 최종 - // distance 다 → 여기선 그대로 사용(재적용 금지). norm_on_bbox=false 면 raw*stride. - for (int k = 0; k < 4; ++k) - dist[k] = box[(size_t)pos * 4 + k] * (p.norm_on_bbox ? 1.0f : stride); - (void)stride; + // bbox_pred 는 조립기(head.cpp)가 이미 scale·relu·exp·×stride 를 적용한 + // **최종 픽셀 거리**다 → 여기서 다시 곱하지 않는다. 곱하면 레벨마다 + // 8~128배 커진다. + for (int k = 0; k < 4; ++k) dist[k] = box[(size_t)pos * 4 + k]; float outb[4]; distance2bbox(pts.data() + (size_t)pos * 2, dist, 1, outb, p.input_w, p.input_h); - cand.push_back({outb[0], outb[1], outb[2], outb[3], std::get<0>(t), std::get<1>(t)}); + float sc = std::get<0>(t); + if (ctr) sc *= sigmoidf(ctr[pos]); + cand.push_back({outb[0], outb[1], outb[2], outb[3], sc, std::get<1>(t)}); } } std::vector out; diff --git a/src/visp/postproc.h b/src/visp/postproc.h index 590add6..a428efa 100755 --- a/src/visp/postproc.h +++ b/src/visp/postproc.h @@ -68,7 +68,10 @@ void distance2bbox(float const* points, float const* distance, int n, float* out struct fcos_params { std::vector strides; // 8,16,32,64,128 - bool norm_on_bbox = true; // bbox_pred *= stride + // 격자 중심 오프셋. FCOS 의 MlvlPointGenerator 는 0.5, GFL·VFNet·RTMDet 은 + // AnchorGenerator(center_offset=0) 라 0 이다. 틀리면 박스가 반 칸씩 밀리는데, + // 오차가 stride 에 비례해 커지는 것이 그 증상이다. + float point_offset = 0.5f; int num_classes = 80; float score_thr = 0.05f; float nms_thr = 0.5f; @@ -76,7 +79,9 @@ struct fcos_params { int max_per_img = 100; int input_w = 0, input_h = 0; }; -// cls_scores[l]=[nc,W,H]HWC · bbox_preds[l]=[4,W,H]HWC · centerness[l]=[1,W,H]HWC. +// cls_scores[l]=[nc,W,H]HWC · bbox_preds[l]=[4,W,H]HWC. +// bbox_preds 는 조립기가 이미 픽셀 거리로 만든 값이다 — 여기서 stride 를 곱하지 않는다. +// centerness 는 **비어 있어도 된다**(GFL·VFNet 은 cls 가 품질을 겸한다). 비면 안 곱한다. std::vector detect_fcos( std::vector> const& cls_scores, std::vector> const& bbox_preds, diff --git a/tools/frontend/mmdet/mmdet_wrap.py b/tools/frontend/mmdet/mmdet_wrap.py index 43d6b2c..b61a823 100755 --- a/tools/frontend/mmdet/mmdet_wrap.py +++ b/tools/frontend/mmdet/mmdet_wrap.py @@ -181,6 +181,21 @@ def _tolist(v): return [v] +def _center_offset(pg): + """격자 중심 오프셋. 생성기마다 **속성 이름이 다르다.** + + AnchorGenerator 는 `center_offset`(기본 0), MlvlPointGenerator 는 `offset`(기본 0.5)다 + (`point_generator.py:107`). 하나만 보면 point 계열이 전부 0.0 으로 나가 박스가 반 칸씩 + 밀린다 — 오차가 stride 에 비례해 커지는 것이 그 증상이다. + """ + if pg is None: + return 0.0 + v = getattr(pg, "center_offset", None) + if v is None: + v = getattr(pg, "offset", None) + return float(v or 0.0) + + def postproc_cfg(det): """검출 head 의 decode/anchor **config** + head-conv **구조**를 dict 로 추출(→ .postproc.json). @@ -460,7 +475,10 @@ def _tc(key, default): "octave_base_scale": obs, "octave_scales": [s / obs for s in scales], # =2^(i/n) "ratios": ratios, - "center_offset": float(getattr(pg, "center_offset", 0.0) or 0.0) if pg is not None else 0.0, + # ⚠️ 속성 이름이 생성기마다 다르다. AnchorGenerator 는 `center_offset`, + # MlvlPointGenerator 는 **`offset`** 이다(point_generator.py:107). + # 하나만 보면 point 계열이 전부 0.0 으로 나가 박스가 반 칸씩 밀린다. + "center_offset": _center_offset(pg), "means": [float(v) for v in getattr(bc, "means", [0.0] * 4)], "stds": [float(v) for v in getattr(bc, "stds", [1.0] * 4)], "can_decode": can_decode and uniform_priors, diff --git a/tools/verify/backbone/run_mmdet.cpp b/tools/verify/backbone/run_mmdet.cpp index b0daaa6..600409e 100755 --- a/tools/verify/backbone/run_mmdet.cpp +++ b/tools/verify/backbone/run_mmdet.cpp @@ -338,15 +338,81 @@ int main(int argc, char** argv) { cls_v[l] = to_vec(cls_t[l]); box_v[l] = to_vec(box_t[l]); } - // centerness 갈래가 있으면 score factor 로 넘긴다(ATSS·PAA·DDOD…). mmdet 은 이걸 + // centerness 갈래가 있으면 score factor 로 넘긴다(ATSS·PAA·DDOD·FCOS…). mmdet 은 이걸 // top-k 뒤에 곱한다 — 안 넘기면 **박스는 맞고 점수만** 높게 나온다(실측 Δ0.071). // ⚠️ YOLACT 의 coeff 갈래는 점수가 아니다 — `ctr_tanh` 로 가른다. if ((int)ho.ctr.size() >= L && !hc.ctr_tanh) { ctr_v.resize(L); for (int l = 0; l < L; ++l) ctr_v[l] = to_vec(ho.ctr[l]); } - std::vector dets = - detect_anchor(cls_v, box_v, feat_hw, dp, ctr_v.empty() ? nullptr : &ctr_v); + + // 디코드는 **조립과 다른 축**이다. 조립은 타워 모양(head_kind)으로 갈리지만, 디코드는 + // 박스가 무엇이냐로 갈린다 — 앵커 대비 delta 냐, 격자점 대비 거리냐. + // · anchor(Delta 코더) → detect_anchor + // · fcos·gfl·vfnet → detect_fcos (조립기가 이미 픽셀 거리로 만들어 놨다) + // · anchor 인데 거리 예측 → 같은 거리 경로 (RTMDet 의 bbox_mul_stride 가 그 표시) + // 앵커 파라미터가 안 실린 계열은 `octave_scales` 가 비어 있어 detect_anchor 가 + // 후보 0개를 낸다 — 그건 "조용히 틀린 박스" 가 아니라 안전한 정지다. + const bool distance_box = hc.kind == head_kind::fcos || hc.kind == head_kind::gfl || + hc.kind == head_kind::vfnet || hc.bbox_mul_stride; + + std::vector dets; + if (hc.kind == head_kind::tood) { + // TOOD 는 조립기가 이미 `distance2bbox` 까지 그래프로 펴서 **격자 좌표계 xyxy** 를 + // 낸다(head.cpp:1260) — stride 만 곱하면 픽셀이다. + // ⚠️ cls 는 `sqrt(σ(logits)·σ(align))` 라 **이미 확률**이다(head.cpp:1218). + // 시그모이드를 또 걸면 박스는 그대로인데 점수만 낮아진다 — 눈에 안 띈다. + std::vector cand; + for (int l = 0; l < L; ++l) { + const int fh = feat_hw[l].first, fw = feat_hw[l].second, npos = fh * fw; + const float stride = dp.strides[l]; + float const* cs = cls_v[l].data(); + float const* bx = box_v[l].data(); + for (int pos = 0; pos < npos; ++pos) { + float const* c1 = cs + (size_t)pos * dp.num_classes; + int best = 0; + for (int j = 1; j < dp.num_classes; ++j) + if (c1[j] > c1[best]) best = j; + if (c1[best] <= dp.score_thr) continue; + float const* b1 = bx + (size_t)pos * 4; + detection d{b1[0] * stride, b1[1] * stride, b1[2] * stride, b1[3] * stride, + c1[best], best}; + if (dp.input_w > 0) { + d.x1 = std::min(std::max(d.x1, 0.0f), (float)dp.input_w); + d.x2 = std::min(std::max(d.x2, 0.0f), (float)dp.input_w); + d.y1 = std::min(std::max(d.y1, 0.0f), (float)dp.input_h); + d.y2 = std::min(std::max(d.y2, 0.0f), (float)dp.input_h); + } + cand.push_back(d); + } + } + int maxlabel = 0; + for (auto const& c : cand) maxlabel = std::max(maxlabel, c.label); + for (int lab = 0; lab <= maxlabel; ++lab) { + std::vector per; + for (auto const& c : cand) if (c.label == lab) per.push_back(c); + if (per.empty()) continue; + for (int k : nms(per, dp.nms_thr)) dets.push_back(per[k]); + } + std::sort(dets.begin(), dets.end(), + [](detection const& a, detection const& b) { return a.score > b.score; }); + if ((int)dets.size() > dp.max_per_img) dets.resize(dp.max_per_img); + } else if (distance_box) { + fcos_params fp; + fp.strides = dp.strides; + fp.num_classes = dp.num_classes; + fp.score_thr = dp.score_thr; + fp.nms_thr = dp.nms_thr; + fp.nms_pre = dp.nms_pre; + fp.max_per_img = dp.max_per_img; + fp.input_w = dp.input_w; + fp.input_h = dp.input_h; + // FCOS 만 MlvlPointGenerator(0.5) 다. 나머지는 AnchorGenerator(center_offset=0). + fp.point_offset = hc.kind == head_kind::fcos ? 0.5f : hc.center_offset; + dets = detect_fcos(cls_v, box_v, ctr_v, feat_hw, fp); + } else { + dets = detect_anchor(cls_v, box_v, feat_hw, dp, ctr_v.empty() ? nullptr : &ctr_v); + } // An image by default, as with every other entry point here. Raw numbers on request. std::string out_s(outp); diff --git a/tools/verify/dense_head/verify_postproc.py b/tools/verify/dense_head/verify_postproc.py index 8384846..c015777 100644 --- a/tools/verify/dense_head/verify_postproc.py +++ b/tools/verify/dense_head/verify_postproc.py @@ -64,6 +64,17 @@ def mmdet_boxes(cfg, ckpt, image, size, thr, to_rgb): """mmdet 자신의 predict_by_feat — 앵커·디코드·NMS 의 정본.""" from mmdet.apis import init_detector + # mmdet v3 체크포인트는 학습 메타(HistoryBuffer)를 함께 담고 있어 torch 2.6 의 + # weights_only=True 기본값에서 로드가 거부된다(rtmdet 이 그랬다). 프론트엔드가 + # 이미 쓰는 우회를 그대로 쓴다 — 필요한 클래스만 이름으로 허용한다. + sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), + "..", "..", "frontend", "mmdet")) + try: + import mmdet_wrap + mmdet_wrap.allow_mmengine_checkpoint_globals() + except Exception as e: # 실패는 조용히 넘기지 않는다 + print(f" ⚠️ 체크포인트 허용 목록 설정 실패: {type(e).__name__}: {e}") + det = init_detector(cfg, ckpt, device="cpu") det.eval() dp = det.data_preprocessor From db8ad382af3af7552d47431fd1ffa1fd1d59d20c Mon Sep 17 00:00:00 2001 From: eunchae Date: Fri, 14 Aug 2026 14:45:06 +0900 Subject: [PATCH 21/89] =?UTF-8?q?feat(detect):=20DETR=20=EA=B3=84=EC=97=B4?= =?UTF-8?q?=EC=9D=84=20=EB=94=94=EC=BD=94=EB=93=9C=ED=95=9C=EB=8B=A4=20(4/?= =?UTF-8?q?5=20=EA=B2=80=EC=A6=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `detect_detr` 도 이미 구현돼 있었고 아무도 안 불렀다. DETR 계열은 early return 조차 없어서 `detect_anchor` 로 떨어졌고, 앵커 파라미터가 비어 후보 0개였다. 배선하면서 고친 것: - **출력 인덱스가 FPN 레벨이 아니라 decoder 층이다**(head.h:172-175). mmdet 도 `all_layers_*[-1]` 만 쓴다 — 앞 층을 쓰면 shape 가 맞아 안 죽고 더 거친 박스가 나온다. query 수는 설정이 아니라 **텐서에서 다시 확인**한다(어긋나면 조용히 잘못 읽는다). - **`use_sigmoid` 를 잘못 읽고 있었다.** DETR 계열은 `use_sigmoid_cls` 속성이 **없어서** 기본값 True 로 떨어졌다 — 고전 DETR 은 softmax 인데 sigmoid 로 디코드될 뻔했다. 정본은 `loss_cls.use_sigmoid` 다(detr_head.py:114). - **`num_classes` 가 채널 폭이었다.** softmax head 는 배경을 한 칸 더 써서 `cls_out_channels = num_classes + 1` 이다. 채널 폭과 의미상 클래스 수를 갈랐다 — 뒤바꾸면 배경을 클래스로 세거나 마지막 클래스를 잃는다. - `detect_detr` 에 이미지 클램프를 넣었다(mmdet 도 자른다). - 레벨 수 가드와 레벨별 수집을 DETR 에서 건너뛴다. 검사기도 둘 고쳤다: - DETR 은 transformer 가 head 가 아니라 **detector** 에 달려 있어 `bbox_head(feats)` 로 못 부른다 — 그런 계열은 `det.predict()` 를 탄다. - 매처가 **라벨을 안 봤다.** 한 물체에 두 클래스가 겹쳐 나오면(DETR 이 흔하다) 두 ref 가 같은 got 에 붙어 '라벨 불일치' 로 잘못 보고됐다 — DINO 가 그렇게 0.109 로 실패했다. 같은 라벨 안에서 먼저 짝짓게 고치니 0.030 · 불일치 0. 실측 (mmdet 대비, 같은 픽셀 512): conditional_detr 0.27px · 0.002 dab_detr 0.28px · 0.001 dino 0.41px · 0.030 detr 1.85px · 0.044 기존 5계열 회귀 없음 deformable_detr 은 **검증 못 했다** — head 출력이 다르다(우리 cls 최대 sigmoid 0.366 vs mmdet 0.833). tood·rtmdet 과 같은 부류로, 조립 문제라 디코드 범위 밖이다. --- src/visp/postproc.cpp | 10 +++++ tools/frontend/mmdet/mmdet_to_pt.py | 7 +++- tools/frontend/mmdet/mmdet_wrap.py | 15 ++++++- tools/verify/backbone/run_mmdet.cpp | 46 +++++++++++++++++++--- tools/verify/dense_head/verify_postproc.py | 37 +++++++++++++---- 5 files changed, 100 insertions(+), 15 deletions(-) diff --git a/src/visp/postproc.cpp b/src/visp/postproc.cpp index 68487c6..4e285bd 100755 --- a/src/visp/postproc.cpp +++ b/src/visp/postproc.cpp @@ -347,6 +347,16 @@ std::vector detect_detr(float const* cls, float const* bbox, detr_par int k = std::min(p.max_per_img, (int)q_best.size()); out.assign(q_best.begin(), q_best.begin() + k); } + // mmdet 도 이미지 밖을 잘라낸다(`detr_head._predict_by_feat_single`). 안 자르면 + // 가장자리 물체의 박스가 화면 밖으로 나가 비교가 어긋난다. + if (p.input_w > 0 && p.input_h > 0) { + for (detection& d : out) { + d.x1 = std::min(std::max(d.x1, 0.0f), (float)p.input_w); + d.x2 = std::min(std::max(d.x2, 0.0f), (float)p.input_w); + d.y1 = std::min(std::max(d.y1, 0.0f), (float)p.input_h); + d.y2 = std::min(std::max(d.y2, 0.0f), (float)p.input_h); + } + } return out; } diff --git a/tools/frontend/mmdet/mmdet_to_pt.py b/tools/frontend/mmdet/mmdet_to_pt.py index 2af6235..f238ac0 100755 --- a/tools/frontend/mmdet/mmdet_to_pt.py +++ b/tools/frontend/mmdet/mmdet_to_pt.py @@ -107,7 +107,12 @@ def emit_params(cfg, config_name): " // per-level tensors, and only the anchor(Delta) path fills c.det here.\n", ] out.append(" c.det.strides = {" + ", ".join(_f(v) for v in cfg["strides"]) + "};\n") - out.append(f" c.det.num_classes = {int(cfg.get('num_classes', 80))};\n") + # ⚠️ `c.det.num_classes` 는 **배경을 뺀** 클래스 수다. softmax head(고전 DETR)는 + # 채널이 하나 더 많으므로(`cls_out_channels = num_classes + 1`), 채널 폭은 + # `c.head.num_classes` 를 쓰고 여기엔 의미상 값을 싣는다. 뒤바꾸면 배경을 + # 클래스로 세거나 마지막 클래스를 잃는다. + out.append(" c.det.num_classes = " + f"{int(cfg.get('num_classes_semantic', cfg.get('num_classes', 80)))};\n") out.append(f" c.det.use_sigmoid = {str(bool(cfg.get('use_sigmoid', True))).lower()};\n") for i, v in enumerate(cfg.get("img_mean", [0.0] * 3)): out.append(f" c.img_mean[{i}] = {_f(v)};\n") diff --git a/tools/frontend/mmdet/mmdet_wrap.py b/tools/frontend/mmdet/mmdet_wrap.py index b61a823..78a241f 100755 --- a/tools/frontend/mmdet/mmdet_wrap.py +++ b/tools/frontend/mmdet/mmdet_wrap.py @@ -260,7 +260,18 @@ def postproc_cfg(det): can_decode = bc is not None and "Delta" in type(bc).__name__ # (레벨별 anchor 수가 다르면 뒤에서 취소한다 — num_base 하나로는 못 푼다) + # cls 출력 채널 수. **의미상 클래스 수와 다를 수 있다** — softmax head 는 배경을 한 칸 + # 더 쓰므로 `cls_out_channels == num_classes + 1` 이다(detr_head.py:114-118). + # 디코드는 둘을 다 알아야 한다: 채널 폭은 인덱싱에, 클래스 수는 배경 제외에 쓴다. ncls = int(getattr(bh, "cls_out_channels", getattr(bh, "num_classes", 80))) + ncls_semantic = int(getattr(bh, "num_classes", ncls)) + + # 활성: 대부분 `use_sigmoid_cls` 를 갖지만 **DETR 계열은 없다** — 그쪽은 + # `loss_cls.use_sigmoid` 가 정본이다(고전 DETR=softmax, Deformable/DINO=sigmoid). + # 기본값 True 로 두면 고전 DETR 이 조용히 sigmoid 로 디코드된다. + _use_sig = getattr(bh, "use_sigmoid_cls", None) + if _use_sig is None: + _use_sig = getattr(getattr(bh, "loss_cls", None), "use_sigmoid", True) # stride 는 prior_generator 가 없으면 `build()` 가 실제 feature 크기에서 채운다. strides = ([s[0] if isinstance(s, (tuple, list)) else int(s) for s in pg.strides] if pg is not None else []) @@ -469,7 +480,9 @@ def _tc(key, default): "img_std": img_std, "to_rgb": to_rgb, **thresholds, - "use_sigmoid": bool(getattr(bh, "use_sigmoid_cls", True)), + "use_sigmoid": bool(_use_sig), + # 배경 제외 클래스 수. softmax head 에서 `num_classes`(=채널폭)와 갈린다. + "num_classes_semantic": ncls_semantic, "num_classes": ncls, "strides": [float(s) for s in strides], "octave_base_scale": obs, diff --git a/tools/verify/backbone/run_mmdet.cpp b/tools/verify/backbone/run_mmdet.cpp index 600409e..c30bf06 100755 --- a/tools/verify/backbone/run_mmdet.cpp +++ b/tools/verify/backbone/run_mmdet.cpp @@ -323,17 +323,26 @@ int main(int argc, char** argv) { // 이 러너의 몫이고, 그 뒤를 억지로 detect_anchor 에 넣으면 의미 없는 박스가 나온다. if (hc.kind == head_kind::cornernet) return 0; + // DETR 계열은 출력 인덱스가 **decoder 층**이라 레벨 수와 무관하다 — 아래 레벨 검사와 + // 레벨별 수집을 건너뛴다(head.h:172-175). + const bool is_detr = hc.kind == head_kind::detr || + hc.kind == head_kind::conditional_detr || + hc.kind == head_kind::dab_detr || + hc.kind == head_kind::deformable_detr || + hc.kind == head_kind::dino; + // 조립기가 레벨 수를 못 채우면 아래 인덱싱이 널을 읽는다. 여기서 말한다. - if ((int)cls_t.size() < L || (int)box_t.size() < L) { + if (!is_detr && ((int)cls_t.size() < L || (int)box_t.size() < L)) { fprintf(stderr, "head 출력이 %d 레벨에 못 미친다 (cls %zu, box %zu)\n", L, cls_t.size(), box_t.size()); return 4; } - // 6) raw cls/box(+ctr) → detect_anchor (decode + NMS) - std::vector> cls_v(L), box_v(L), ctr_v; - std::vector> feat_hw(L); - for (int l = 0; l < L; ++l) { + // 6) raw cls/box(+ctr) → 계열별 디코드 + const int NL = is_detr ? 0 : L; + std::vector> cls_v(NL), box_v(NL), ctr_v; + std::vector> feat_hw(NL); + for (int l = 0; l < NL; ++l) { feat_hw[l] = { (int)cls_t[l]->ne[2], (int)cls_t[l]->ne[1] }; // (fh, fw) cls_v[l] = to_vec(cls_t[l]); box_v[l] = to_vec(box_t[l]); @@ -357,7 +366,32 @@ int main(int argc, char** argv) { hc.kind == head_kind::vfnet || hc.bbox_mul_stride; std::vector dets; - if (hc.kind == head_kind::tood) { + if (is_detr) { + // ⚠️ DETR 계열은 out.cls/out.box 의 인덱스가 **FPN 레벨이 아니라 decoder 층**이다 + // (head.h:172-175). mmdet 도 `all_layers_*[-1]` 만 쓴다 — 앞 층을 쓰면 shape 가 + // 맞아 안 죽고 **더 거친 박스**가 나온다. 그림만 보면 알 수 없다. + if (ho.cls.empty() || ho.box.empty()) { + fprintf(stderr, "DETR head 출력이 비었다\n"); + return 4; + } + std::vector cls_last = to_vec(ho.cls.back()); + std::vector box_last = to_vec(ho.box.back()); + detr_params qp; + qp.num_queries = hc.num_queries; + qp.num_classes = dp.num_classes; // 배경을 뺀 수 (프론트엔드가 그렇게 싣는다) + qp.use_sigmoid = dp.use_sigmoid; // 고전 DETR=softmax, Deformable/DINO=sigmoid + qp.max_per_img = dp.max_per_img; + qp.input_w = dp.input_w; + qp.input_h = dp.input_h; + // query 수는 실제 텐서에서 다시 확인한다 — 설정과 어긋나면 조용히 잘못 읽는다. + const int q_actual = (int)ho.box.back()->ne[1]; + if (q_actual > 0 && q_actual != qp.num_queries) { + fprintf(stderr, "query 수가 설정(%d)과 텐서(%d)에서 다르다 — 텐서를 따른다\n", + qp.num_queries, q_actual); + qp.num_queries = q_actual; + } + dets = detect_detr(cls_last.data(), box_last.data(), qp); + } else if (hc.kind == head_kind::tood) { // TOOD 는 조립기가 이미 `distance2bbox` 까지 그래프로 펴서 **격자 좌표계 xyxy** 를 // 낸다(head.cpp:1260) — stride 만 곱하면 픽셀이다. // ⚠️ cls 는 `sqrt(σ(logits)·σ(align))` 라 **이미 확률**이다(head.cpp:1218). diff --git a/tools/verify/dense_head/verify_postproc.py b/tools/verify/dense_head/verify_postproc.py index c015777..d6ffdc5 100644 --- a/tools/verify/dense_head/verify_postproc.py +++ b/tools/verify/dense_head/verify_postproc.py @@ -87,11 +87,22 @@ def mmdet_boxes(cfg, ckpt, image, size, thr, to_rgb): x = (np.ascontiguousarray(x) - mean) / std t = torch.from_numpy(x).permute(2, 0, 1).unsqueeze(0) + meta = {"img_shape": (size, size), "ori_shape": (size, size), + "scale_factor": (1.0, 1.0), "batch_input_shape": (size, size)} + with torch.no_grad(): - outs = det.bbox_head(det.extract_feat(t)) - meta = [{"img_shape": (size, size), "ori_shape": (size, size), - "scale_factor": (1.0, 1.0), "batch_input_shape": (size, size)}] - res = det.bbox_head.predict_by_feat(*outs, batch_img_metas=meta, rescale=False)[0] + try: + outs = det.bbox_head(det.extract_feat(t)) + res = det.bbox_head.predict_by_feat(*outs, batch_img_metas=[meta], + rescale=False)[0] + except TypeError: + # DETR 계열은 transformer 가 **head 가 아니라 detector** 에 달려 있어 + # `bbox_head(feats)` 로는 못 부른다(mmdet_wrap.py:151-157). 그런 계열은 + # detector 의 predict 를 그대로 탄다 — 그게 이 계열의 디코드 정본이다. + from mmdet.structures import DetDataSample + ds = DetDataSample() + ds.set_metainfo(meta) + res = det.predict(t, [ds], rescale=False)[0].pred_instances keep = res.scores.numpy() >= thr return np.concatenate([res.bboxes.numpy()[keep], @@ -111,10 +122,22 @@ def match(ref, got): if len(ref) == 0 or len(got) == 0: return None rows = [] + used = set() for r in ref: - d = np.abs(got[:, :4] - r[:4]).max(1) - j = int(d.argmin()) - rows.append((r, got[j], d[j])) + # **라벨이 같은 것 중에서** 가장 가까운 것을 고른다. 박스 거리만 보면, 한 물체에 + # 두 클래스가 겹쳐 나온 경우(DETR 이 흔하다) 두 ref 가 같은 got 에 붙어 + # "라벨 불일치" 로 잘못 보고된다 — 실제로 DINO 에서 그렇게 오탐이 났다. + same = np.flatnonzero(got[:, 5] == r[5]) + pool = same if len(same) else np.arange(len(got)) + d = np.abs(got[pool][:, :4] - r[:4]).max(1) + order = np.argsort(d) + j = int(pool[order[0]]) + for o in order: # 이미 쓴 것은 뒤로 미룬다(1:1 에 가깝게) + if int(pool[o]) not in used: + j = int(pool[o]) + break + used.add(j) + rows.append((r, got[j], float(np.abs(got[j][:4] - r[:4]).max()))) return rows From 388298cc1909360e0fa2fd29f1b7a80fbb76c8ef Mon Sep 17 00:00:00 2001 From: eunchae Date: Fri, 14 Aug 2026 14:58:49 +0900 Subject: [PATCH 22/89] =?UTF-8?q?feat(verify):=20two-stage=20=EB=9F=AC?= =?UTF-8?q?=EB=84=88=EA=B0=80=20=EC=B5=9C=EC=A2=85=20=EB=B0=95=EC=8A=A4?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=20=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_frcnn 이 SubB 의 원시 텐서만 덤프하고 detect_roi 를 안 불렀다. RoI head 출력을 softmax 로 확률화한 뒤(detect_roi 는 softmax 완료본을 기대한다 — postproc.h:150; SubB 는 로짓이다, 행 합 -0.41 로 실측) 클래스별 delta 디코드와 NMS 를 태운다. 파라미터는 frcnn.json 에 이미 다 있다(rcnn_means/stds/ rcnn_score_thr/rcnn_nms_thr/rcnn_max/class_agnostic) — 프론트엔드 변경 없음. 출력 규약은 run_mmdet 과 같게 맞춘다: .boxes.bin 은 박스당 6값 (x1,y1,x2,y2,score,class) + 좌표 표 출력. 실측(faster-rcnn_r50_fpn_1x_coco, cat-and-hat.jpg 800, mmdet predict 와 같은 픽셀): 박스 0.10px · 점수 0.0008 · 라벨 불일치 0 · 개수차 0 (4/4건) Co-Authored-By: Claude Opus 5 (1M context) --- tools/verify/backbone/run_frcnn.cpp | 52 +++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tools/verify/backbone/run_frcnn.cpp b/tools/verify/backbone/run_frcnn.cpp index 4541c37..b416163 100644 --- a/tools/verify/backbone/run_frcnn.cpp +++ b/tools/verify/backbone/run_frcnn.cpp @@ -313,6 +313,41 @@ int main(int argc, char** argv) { } } + // ── 최종 디코드: 마지막 단계의 cls/box → 박스 ─────────────────────────── + // `detect_roi` 는 **softmax 를 마친** 점수를 기대한다(postproc.h). SubB 가 내는 것은 + // 로짓이므로(실측: 한 행의 합이 -0.41) 여기서 건다. 안 걸면 크래시 없이 점수만 틀린다. + std::vector dets; + if (!cls_st.empty() && !box_st.empty()) { + const int last = (int)cls_st.size() - 1; + const int NCLS = (int)(cls_st[last].size() / M) - 1; // 배경 제외 + std::vector prob(cls_st[last].size()); + for (int i = 0; i < M; ++i) { + float const* row = cls_st[last].data() + (size_t)i * (NCLS + 1); + float* dst = prob.data() + (size_t)i * (NCLS + 1); + float mx = row[0]; + for (int c = 1; c <= NCLS; ++c) mx = std::max(mx, row[c]); + float sum = 0.0f; + for (int c = 0; c <= NCLS; ++c) { dst[c] = std::exp(row[c] - mx); sum += dst[c]; } + for (int c = 0; c <= NCLS; ++c) dst[c] /= sum; + } + roi_params rp2; + const std::vector rm = J.arr("rcnn_means"), rs = J.arr("rcnn_stds"); + for (int k = 0; k < 4; ++k) { + if (rm.size() >= 4) rp2.means[k] = rm[k]; + if (rs.size() >= 4) rp2.stds[k] = rs[k]; + } + rp2.num_classes = NCLS; + rp2.class_agnostic = J.num("class_agnostic", 0.0f) != 0.0f; + rp2.score_thr = J.num("rcnn_score_thr", 0.05f); + rp2.nms_thr = J.num("rcnn_nms_thr", 0.5f); + rp2.max_per_img = (int)J.num("rcnn_max", 100.0f); + rp2.input_w = SZ; + rp2.input_h = SZ; + // 마지막 단계의 박스는 그 단계에 **들어간** RoI 기준이다. 캐스케이드에서 `rois` 는 + // 이미 다음 단계용으로 갱신되지 않으므로(마지막 단계는 정제를 건너뛴다) 그대로 쓴다. + dets = detect_roi(prob.data(), box_st[last].data(), rois.data(), M, rp2); + } + // ── 덤프 (torch 대조용) ───────────────────────────────────────────────── dump_bin(pref + ".props.bin", props); dump_bin(pref + ".roi.bin", roi); @@ -328,6 +363,23 @@ int main(int argc, char** argv) { dump_bin(pref + ".rpncls." + std::to_string(l) + ".bin", rpn_cls[l]); dump_bin(pref + ".rpnbox." + std::to_string(l) + ".bin", rpn_box[l]); } + // 최종 박스도 낸다 — 여기까지 와서 개수만 알려주면 결과를 수치로 확인할 방법이 없다. + // `run_mmdet` 과 같은 규약: `.boxes.bin` 에 박스당 6값(x1,y1,x2,y2,score,label). + { + std::vector flat; + flat.reserve(dets.size() * 6); + for (detection const& d : dets) { + flat.insert(flat.end(), {d.x1, d.y1, d.x2, d.y2, d.score, (float)d.label}); + } + dump_bin(pref + ".boxes.bin", flat); + } printf("- frcnn: 2패스 (proposal %d · roi %dx%d) → %s.*.bin\n", M, O, O, pref.c_str()); + printf(" %4s %9s %9s %9s %9s %8s class\n", "#", "x1", "y1", "x2", "y2", "score"); + for (size_t i = 0; i < dets.size() && i < 10; ++i) { + detection const& d = dets[i]; + printf(" %4zu %9.2f %9.2f %9.2f %9.2f %8.4f %d\n", i, d.x1, d.y1, d.x2, d.y2, + d.score, d.label); + } + if (dets.size() > 10) printf(" ... (%zu more)\n", dets.size() - 10); return 0; } From 22e91ba99149c975b5b419563c3587607eb65f17 Mon Sep 17 00:00:00 2001 From: eunchae Date: Fri, 14 Aug 2026 15:08:58 +0900 Subject: [PATCH 23/89] =?UTF-8?q?feat(decode):=20YOLOX=20=EB=A5=BC=20detec?= =?UTF-8?q?t=5Fyolox=20=EB=A1=9C=20=EB=B3=B4=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YOLOXHead 는 타워 조립이 anchor 와 같아 head_kind::anchor 로 실렸는데, 디코드는 전혀 다르다 — 박스 코더가 없고 격자 단위 (dx,dy,log w,log h) 를 내며 점수는 cls×obj 다. anchor 로 두면 Delta 디코더로 떨어지고, 앵커 파라미터가 안 실린 계열이라 후보 0개가 나왔다. 조립축과 디코드축을 가른다: head_kind::yolox 를 새로 두되 조립은 anchor 와 같은 tower_head_forward 를 탄다. objectness 는 out.ctr 로 온다(프론트엔드가 conv_obj 를 centerness_head 로 싣는다) — 레벨 수가 모자라면 조용히 넘기지 않고 멈춘다. verify_postproc.py: **정규화를 안 하는 계열**을 다룬다. YOLOX 의 DetDataPreprocessor 는 mean/std 자체가 없어 AttributeError 로 죽었다. 없으면 0/1 로 본다 — 파라미터 헤더가 싣는 값과 같아야 양쪽이 같은 픽셀을 본다. 실측(yolox_s_8xb8-300e_coco, cat-and-hat.jpg 512, mmdet predict_by_feat 대조): 박스 0.41px · 점수 0.002 · 라벨 불일치 0 · 개수차 0 Co-Authored-By: Claude Opus 5 (1M context) --- tools/detect/head.cpp | 1 + tools/detect/head.h | 4 ++++ tools/frontend/mmdet/mmdet_wrap.py | 2 +- tools/verify/backbone/run_mmdet.cpp | 20 ++++++++++++++++++++ tools/verify/dense_head/verify_postproc.py | 6 ++++-- 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/tools/detect/head.cpp b/tools/detect/head.cpp index 13cc3bc..63dccaa 100755 --- a/tools/detect/head.cpp +++ b/tools/detect/head.cpp @@ -1303,6 +1303,7 @@ void mmdet_head_forward(model_ref m, std::vector const& feats, anchor_head_cfg const& c, tensor dcn_base, head_outputs& out) { switch (c.kind) { case head_kind::anchor: + case head_kind::yolox: // 조립은 anchor 와 같다(디코드만 다르다) case head_kind::fcos: case head_kind::gfl: tower_head_forward(m, feats, c, out); diff --git a/tools/detect/head.h b/tools/detect/head.h index 84bda4f..4735ef4 100755 --- a/tools/detect/head.h +++ b/tools/detect/head.h @@ -15,6 +15,10 @@ namespace visp { // 어느 조립기를 쓸지. 계열이 늘어도 러너는 안 바뀐다 — mmdet_head_forward 가 갈라준다. enum class head_kind { anchor, // RetinaNet · ATSS · PAA — cls/reg(+centerness) 타워, Delta 디코드 + // 타워 조립은 anchor 와 같다(레벨별 타워 + obj 갈래). **디코드가 다르다** — 코더가 + // 없고 격자 단위 (dx,dy,log w,log h) 를 내며 점수는 cls×obj 다. 조립만 보고 anchor 로 + // 두면 Delta 디코더로 떨어져 박스가 통째로 틀린다. + yolox, fcos, // + bbox 에 scale·clamp·stride (anchor-free 거리 디코드) gfl, // + DFL(분포 → 거리 기댓값). cls 가 품질까지 겸한다 vfnet, // star deformable refine (전용 함수) diff --git a/tools/frontend/mmdet/mmdet_wrap.py b/tools/frontend/mmdet/mmdet_wrap.py index 78a241f..5577f01 100755 --- a/tools/frontend/mmdet/mmdet_wrap.py +++ b/tools/frontend/mmdet/mmdet_wrap.py @@ -223,7 +223,7 @@ def postproc_cfg(det): "ATSSHead": "anchor", "PAAHead": "anchor", "RetinaHead": "anchor", "AnchorHead": "anchor", "AnchorFreeHead": "fcos", "CenterNetHead": "centernet", "YOLOFHead": "yolof", - "YOLOXHead": "anchor", "YOLOV3Head": "yolo", + "YOLOXHead": "yolox", "YOLOV3Head": "yolo", "CornerHead": "cornernet", "CentripetalHead": "cornernet", "DeformableDETRHead": "deformable_detr", "DABDETRHead": "dab_detr", "ConditionalDETRHead": "conditional_detr", "DETRHead": "detr", diff --git a/tools/verify/backbone/run_mmdet.cpp b/tools/verify/backbone/run_mmdet.cpp index c30bf06..e27243e 100755 --- a/tools/verify/backbone/run_mmdet.cpp +++ b/tools/verify/backbone/run_mmdet.cpp @@ -431,6 +431,26 @@ int main(int argc, char** argv) { std::sort(dets.begin(), dets.end(), [](detection const& a, detection const& b) { return a.score > b.score; }); if ((int)dets.size() > dp.max_per_img) dets.resize(dp.max_per_img); + } else if (hc.kind == head_kind::yolox) { + // 격자 단위 (dx,dy,log w,log h) + 별도 objectness. 코더가 없어 `c.det` 의 앵커 + // 파라미터가 안 실리고, 그대로 두면 detect_anchor 가 후보 0개를 낸다. + // ⚠️ obj 는 `out.ctr` 로 온다(프론트엔드가 conv_obj 를 centerness_head 로 싣는다). + // 비면 점수가 cls 만 남아 **박스는 맞고 점수만** 높게 나온다. + yolox_params yp; + yp.strides = dp.strides; + yp.prior_offset = hc.center_offset; // YOLOX 의 MlvlPointGenerator 는 offset=0 + yp.num_classes = dp.num_classes; + yp.score_thr = dp.score_thr; + yp.nms_thr = dp.nms_thr; + yp.max_per_img = dp.max_per_img; + yp.input_w = dp.input_w; + yp.input_h = dp.input_h; + if ((int)ctr_v.size() < L) { + fprintf(stderr, "YOLOX 인데 objectness 갈래가 %zu 레벨뿐이다 (필요 %d)\n", + ctr_v.size(), L); + return 4; + } + dets = detect_yolox(cls_v, box_v, ctr_v, feat_hw, yp); } else if (distance_box) { fcos_params fp; fp.strides = dp.strides; diff --git a/tools/verify/dense_head/verify_postproc.py b/tools/verify/dense_head/verify_postproc.py index d6ffdc5..04e6b06 100644 --- a/tools/verify/dense_head/verify_postproc.py +++ b/tools/verify/dense_head/verify_postproc.py @@ -78,8 +78,10 @@ def mmdet_boxes(cfg, ckpt, image, size, thr, to_rgb): det = init_detector(cfg, ckpt, device="cpu") det.eval() dp = det.data_preprocessor - mean = dp.mean.view(3).numpy() - std = dp.std.view(3).numpy() + # ⚠️ **정규화를 안 하는 계열이 있다** — YOLOX 는 `mean`/`std` 자체를 안 갖는다. + # 그때 파라미터 헤더도 mean 0 / std 1 을 싣는다(둘이 같아야 같은 픽셀이 된다). + mean = dp.mean.view(3).numpy() if hasattr(dp, "mean") else np.zeros(3, np.float32) + std = dp.std.view(3).numpy() if hasattr(dp, "std") else np.ones(3, np.float32) im = np.asarray(Image.open(image).convert("RGB").resize((size, size), Image.BILINEAR), dtype=np.float32) From f9c1e07d7232d930716fe48085e8e26896dd9f46 Mon Sep 17 00:00:00 2001 From: eunchae Date: Fri, 14 Aug 2026 15:37:37 +0900 Subject: [PATCH 24/89] =?UTF-8?q?feat(decode):=20RPN=20=EC=9D=84=20?= =?UTF-8?q?=EB=A0=88=EB=B2=A8=EB=B3=84=20NMS=20=EA=B2=BD=EB=A1=9C=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=B4=EB=82=B4=EA=B3=A0,=20=EB=8C=80=EC=A1=B0=20?= =?UTF-8?q?=ED=95=98=EB=84=A4=EC=8A=A4=EC=9D=98=20=EC=A7=9D=EC=A7=93?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20=EA=B3=A0=EC=B9=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RPN 은 조립이 anchor 와 같아 detect_anchor 로 떨어졌는데 디코드 규칙이 다르다: 클래스가 없고 objectness 하나이며, mmdet 은 `batched_nms(level_ids)` 로 **레벨별** NMS 를 걸고 pre-NMS 점수 임계값을 두지 않는다(rpn_head.py:284). 그 계산은 `rpn_proposals` 에 이미 있었고 두 번째 단계만 쓰고 있었다 — 점수까지 돌려주는 `detect_rpn` 으로 갈라 두 쪽이 같은 코드를 쓰게 했다. verify_postproc.py 의 짝짓기: ref 순서대로 가까운 것을 집으면 앞 ref 가 뒤 ref 의 짝을 가져가 그 뒤가 통째로 밀린다. 라벨이 하나뿐인 RPN 제안 186개에서 그 때문에 **0.1px 로 맞는 집합이 213px 로 보고**됐다. 전역으로 가까운 쌍부터 1:1 확정한다. 박스가 20개를 넘으면 분포(중앙값·95%tile·1px 이내 건수)도 같이 낸다 — 최대값만 보면 임계값 언저리 한둘 때문에 전체가 틀린 것처럼 보인다. run_mmdet: MMDET_DUMP_HEAD 가 neck 출력(feat)도 낸다. head 출력만 보면 "조립이 틀렸다" 와 "백본·neck 이 이미 틀렸다" 를 못 가른다. 실측(rpn_r50-caffe_fpn_1x_coco, 512): mmdet 186 · C++ 185 제안, 중앙값 0.09px · 95%tile 0.24px · 1px 이내 182/185 (남은 3건은 점수가 임계값 0.30 언저리에 몰려 집합이 갈린 자리다) Co-Authored-By: Claude Opus 5 (1M context) --- src/visp/postproc.cpp | 10 ++++- src/visp/postproc.h | 8 +++- tools/detect/head.cpp | 1 + tools/detect/head.h | 4 ++ tools/frontend/mmdet/mmdet_to_pt.py | 3 +- tools/frontend/mmdet/mmdet_wrap.py | 1 + tools/verify/backbone/run_mmdet.cpp | 21 ++++++++++ tools/verify/dense_head/verify_postproc.py | 49 +++++++++++++++------- 8 files changed, 80 insertions(+), 17 deletions(-) diff --git a/src/visp/postproc.cpp b/src/visp/postproc.cpp index 4e285bd..fda232c 100755 --- a/src/visp/postproc.cpp +++ b/src/visp/postproc.cpp @@ -433,7 +433,7 @@ std::vector detect_roi(float const* scores, float const* bbox_deltas, } // ── RPN proposal 생성 (mmdet RPNHead.predict_by_feat) ──────────────────────── -std::vector rpn_proposals( +std::vector detect_rpn( std::vector> const& rpn_cls, std::vector> const& rpn_bbox, std::vector> const& feat_hw, rpn_params const& p) { @@ -481,6 +481,14 @@ std::vector rpn_proposals( } std::sort(kept.begin(), kept.end(), [](detection const& a, detection const& b) { return a.score > b.score; }); if (p.max_per_img > 0 && (int)kept.size() > p.max_per_img) kept.resize(p.max_per_img); + return kept; +} + +std::vector rpn_proposals( + std::vector> const& rpn_cls, + std::vector> const& rpn_bbox, + std::vector> const& feat_hw, rpn_params const& p) { + std::vector kept = detect_rpn(rpn_cls, rpn_bbox, feat_hw, p); std::vector out; out.reserve(kept.size() * 4); for (auto const& d : kept) { out.push_back(d.x1); out.push_back(d.y1); out.push_back(d.x2); out.push_back(d.y2); } diff --git a/src/visp/postproc.h b/src/visp/postproc.h index a428efa..6c4c354 100755 --- a/src/visp/postproc.h +++ b/src/visp/postproc.h @@ -177,7 +177,13 @@ struct rpn_params { int input_w = 0, input_h = 0; }; // rpn_cls[l] = [num_base*1, W, H] objectness(CWHN flat), rpn_bbox[l] = [num_base*4, W, H]. -// 반환 proposals [M*4] (x1,y1,x2,y2), M ≤ max_per_img. +// 점수까지 필요한 쪽(단독 RPN 계열 검증)이 쓴다. `label` 은 클래스가 아니라 **레벨 번호**다 +// — mmdet 이 `batched_nms(level_ids)` 로 레벨별 NMS 를 하기 때문이고, 그게 이 계열의 정본이다. +std::vector detect_rpn( + std::vector> const& rpn_cls, + std::vector> const& rpn_bbox, + std::vector> const& feat_hw, rpn_params const& p); +// 같은 계산에서 박스만 평평하게 뽑는다(two-stage 의 proposal 입력). M ≤ max_per_img. std::vector rpn_proposals( std::vector> const& rpn_cls, std::vector> const& rpn_bbox, diff --git a/tools/detect/head.cpp b/tools/detect/head.cpp index 63dccaa..7f07986 100755 --- a/tools/detect/head.cpp +++ b/tools/detect/head.cpp @@ -1304,6 +1304,7 @@ void mmdet_head_forward(model_ref m, std::vector const& feats, switch (c.kind) { case head_kind::anchor: case head_kind::yolox: // 조립은 anchor 와 같다(디코드만 다르다) + case head_kind::rpn: // 〃 (`pre_conv` 로 rpn_conv 가 붙는다) case head_kind::fcos: case head_kind::gfl: tower_head_forward(m, feats, c, out); diff --git a/tools/detect/head.h b/tools/detect/head.h index 4735ef4..29c4ad7 100755 --- a/tools/detect/head.h +++ b/tools/detect/head.h @@ -19,6 +19,10 @@ enum class head_kind { // 없고 격자 단위 (dx,dy,log w,log h) 를 내며 점수는 cls×obj 다. 조립만 보고 anchor 로 // 두면 Delta 디코더로 떨어져 박스가 통째로 틀린다. yolox, + // 역시 조립은 anchor 와 같다(다만 `rpn_conv` 하나가 앞에 붙는다). 디코드가 다르다 — + // 클래스가 없고 objectness 하나이며, NMS 를 **레벨별로** 건다(batched_nms(level_ids)). + // 클래스별 NMS 로 두면 레벨끼리 겹친 제안이 서로를 지워 제안 수가 조용히 줄어든다. + rpn, fcos, // + bbox 에 scale·clamp·stride (anchor-free 거리 디코드) gfl, // + DFL(분포 → 거리 기댓값). cls 가 품질까지 겸한다 vfnet, // star deformable refine (전용 함수) diff --git a/tools/frontend/mmdet/mmdet_to_pt.py b/tools/frontend/mmdet/mmdet_to_pt.py index f238ac0..25356c6 100755 --- a/tools/frontend/mmdet/mmdet_to_pt.py +++ b/tools/frontend/mmdet/mmdet_to_pt.py @@ -101,7 +101,8 @@ def emit_params(cfg, config_name): # anchor(Delta) 디코드가 되는 계열만 c.det 의 **앵커 파라미터**를 채운다. 조립은 # 되는데 코더가 다른 계열(FSAF 의 TBLRBBoxCoder 등)은 head 원시 출력까지만 낸다. - if h != "anchor" or not cfg.get("can_decode", True): + # rpn 도 **앵커 파라미터를 그대로 쓴다**(디코드만 레벨별 NMS 로 다르다) — 같이 채운다. + if h not in ("anchor", "rpn") or not cfg.get("can_decode", True): out += [ " // Decoding for this family is the caller's: the head above emits raw\n", " // per-level tensors, and only the anchor(Delta) path fills c.det here.\n", diff --git a/tools/frontend/mmdet/mmdet_wrap.py b/tools/frontend/mmdet/mmdet_wrap.py index 5577f01..36aa77c 100755 --- a/tools/frontend/mmdet/mmdet_wrap.py +++ b/tools/frontend/mmdet/mmdet_wrap.py @@ -220,6 +220,7 @@ def postproc_cfg(det): HEADS = { "VFNetHead": "vfnet", "RepPointsHead": "reppoints", "TOODHead": "tood", "GFLHead": "gfl", "FCOSHead": "fcos", + "RPNHead": "rpn", "ATSSHead": "anchor", "PAAHead": "anchor", "RetinaHead": "anchor", "AnchorHead": "anchor", "AnchorFreeHead": "fcos", "CenterNetHead": "centernet", "YOLOFHead": "yolof", diff --git a/tools/verify/backbone/run_mmdet.cpp b/tools/verify/backbone/run_mmdet.cpp index e27243e..70accd6 100755 --- a/tools/verify/backbone/run_mmdet.cpp +++ b/tools/verify/backbone/run_mmdet.cpp @@ -309,6 +309,10 @@ int main(int argc, char** argv) { fclose(f); } }; + // ⚠️ **head 입력(neck 출력)도 같이 낸다.** head 출력만 보면 "조립이 틀렸다" 와 + // "백본·neck 이 이미 틀렸다" 를 못 가른다 — head 를 고쳐도 안 낫는 쪽인지 + // 여기서 바로 갈린다(efficientnet·nas_fpn 이 실제로 neck 쪽이었다). + for (size_t l = 0; l < feats.size(); ++l) dump("feat", l, feats[l]); for (size_t l = 0; l < cls_t.size(); ++l) dump("cls", l, cls_t[l]); for (size_t l = 0; l < box_t.size(); ++l) dump("box", l, box_t[l]); for (size_t l = 0; l < ho.ctr.size(); ++l) dump("ctr", l, ho.ctr[l]); @@ -431,6 +435,23 @@ int main(int argc, char** argv) { std::sort(dets.begin(), dets.end(), [](detection const& a, detection const& b) { return a.score > b.score; }); if ((int)dets.size() > dp.max_per_img) dets.resize(dp.max_per_img); + } else if (hc.kind == head_kind::rpn) { + // 제안 생성. mmdet 은 클래스별이 아니라 **레벨별**로 NMS 를 걸고(level_ids), + // pre-NMS 점수 임계값이 없다 — `detect_anchor` 로 두면 둘 다 어긋난다. + rpn_params rp; + rp.strides = dp.strides; + rp.octave_base_scale = dp.octave_base_scale; + rp.octave_scales = dp.octave_scales; + rp.ratios = dp.ratios; + for (int k = 0; k < 4; ++k) { rp.means[k] = dp.means[k]; rp.stds[k] = dp.stds[k]; } + rp.nms_pre = dp.nms_pre; + rp.nms_thr = dp.nms_thr; + rp.max_per_img = dp.max_per_img; + rp.input_w = dp.input_w; + rp.input_h = dp.input_h; + dets = detect_rpn(cls_v, box_v, feat_hw, rp); + // `label` 은 레벨 번호로 돌아온다. 밖에서는 클래스 자리라 0(유일한 클래스)으로 둔다. + for (auto& d : dets) d.label = 0; } else if (hc.kind == head_kind::yolox) { // 격자 단위 (dx,dy,log w,log h) + 별도 objectness. 코더가 없어 `c.det` 의 앵커 // 파라미터가 안 실리고, 그대로 두면 detect_anchor 가 후보 0개를 낸다. diff --git a/tools/verify/dense_head/verify_postproc.py b/tools/verify/dense_head/verify_postproc.py index 04e6b06..2830eba 100644 --- a/tools/verify/dense_head/verify_postproc.py +++ b/tools/verify/dense_head/verify_postproc.py @@ -123,23 +123,37 @@ def match(ref, got): """ if len(ref) == 0 or len(got) == 0: return None - rows = [] - used = set() - for r in ref: - # **라벨이 같은 것 중에서** 가장 가까운 것을 고른다. 박스 거리만 보면, 한 물체에 - # 두 클래스가 겹쳐 나온 경우(DETR 이 흔하다) 두 ref 가 같은 got 에 붙어 - # "라벨 불일치" 로 잘못 보고된다 — 실제로 DINO 에서 그렇게 오탐이 났다. + + # **라벨이 같은 쌍만** 후보로 둔다. 박스 거리만 보면, 한 물체에 두 클래스가 겹쳐 나온 + # 경우(DETR 이 흔하다) 두 ref 가 같은 got 에 붙어 "라벨 불일치" 로 잘못 보고된다 — + # 실제로 DINO 에서 그렇게 오탐이 났다. + # + # ⚠️ **ref 순서대로 가까운 것을 집으면 안 된다.** 앞쪽 ref 가 뒤쪽 ref 의 짝을 먼저 + # 가져가면 그 뒤가 통째로 밀려, 사실은 0.1px 로 맞는 집합이 수백 px 로 보고된다 + # (RPN 제안 1000개처럼 라벨이 하나뿐이고 개수가 한 개 어긋날 때 그랬다). + # 전역으로 **가까운 쌍부터** 1:1 로 확정한다. + pairs = [] + for i, r in enumerate(ref): same = np.flatnonzero(got[:, 5] == r[5]) pool = same if len(same) else np.arange(len(got)) d = np.abs(got[pool][:, :4] - r[:4]).max(1) - order = np.argsort(d) - j = int(pool[order[0]]) - for o in order: # 이미 쓴 것은 뒤로 미룬다(1:1 에 가깝게) - if int(pool[o]) not in used: - j = int(pool[o]) - break - used.add(j) - rows.append((r, got[j], float(np.abs(got[j][:4] - r[:4]).max()))) + for j, dist in zip(pool, d): + pairs.append((float(dist), i, int(j))) + pairs.sort() + + take_r, take_g = {}, set() + for dist, i, j in pairs: + if i in take_r or j in take_g: + continue + take_r[i] = (j, dist) + take_g.add(j) + + rows = [] + for i, r in enumerate(ref): + if i not in take_r: # 짝이 없는 ref(개수차) — 호출부가 개수로 잡는다 + continue + j, dist = take_r[i] + rows.append((r, got[j], dist)) return rows @@ -181,6 +195,13 @@ def main(): n_gap = abs(len(ref) - len(got)) print(f"\n최대: 박스 {worst_b:.2f}px · 점수 {worst_s:.3f} · 라벨 불일치 {bad_label}건" f" · 개수차 {n_gap}건") + # 박스가 수백 개인 계열(RPN 제안 186개)은 **최대값만 보면 오해한다** — 임계값 언저리에 + # 몰린 후보 한둘이 집합에서 밀리면 그 자리만 크게 벌어지고, 나머지는 0.1px 로 맞는다. + # 분포를 같이 낸다: 어디가 어긋났는지가 아니라 **몇 개가** 어긋났는지가 보인다. + if len(rows) >= 20: + db = np.array([d for _, _, d in rows]) + print(f" 분포: 중앙값 {np.median(db):.2f}px · 95%tile {np.percentile(db, 95):.2f}px" + f" · 1px 이내 {int((db < 1.0).sum())}/{len(db)}건") if n_gap: print(" ⚠️ 개수가 다르다 — 임계값(score_thr/nms_thr/max_per_img)이 mmdet 과 어긋났을 때" " 나는 증상이다. 짝지은 것만 보면 조용히 통과한다.") From a0d1be0bf4217104b1c1b162c85868e800a6cc46 Mon Sep 17 00:00:00 2001 From: eunchae Date: Fri, 14 Aug 2026 15:44:12 +0900 Subject: [PATCH 25/89] =?UTF-8?q?docs:=20=EC=96=B4=EB=96=A4=20=EA=B3=84?= =?UTF-8?q?=EC=97=B4=EC=9D=B4=20=EB=B0=95=EC=8A=A4=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=EB=82=98=EC=98=A4=EB=8A=94=EC=A7=80=20=EC=8B=A4=EC=B8=A1?= =?UTF-8?q?=ED=91=9C=EB=A1=9C=20=EC=A0=81=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문서가 두 가지에서 낡았다. two-stage 러너는 원시 텐서만 낸다고 적혀 있었는데 이제 최종 박스(`out.boxes.bin`)를 낸다. 그리고 "어떤 계열이 실제로 박스를 내나" 가 아무 데도 없었다 — 조립이 되는 것과 디코드가 되는 것이 다른데 문서만 보면 구분이 안 된다. `## What decodes to boxes` 를 새로 둔다: 계열별 디코더와 mmdet `predict_by_feat` 대비 실측치(박스 px · 점수), 그리고 **안 되는 계열을 이유별로** 셋으로 가른다 — 조립이 이미 어긋난 쪽 / 계열 전용 후처리가 필요한 쪽 / 사전분포·코더가 다른 쪽. 이유가 다르면 다음에 할 일이 다르다. postproc API 설명도 실제 계약에 맞춘다: detect_anchor 의 score_factors(임계값·top-k 뒤에 곱한다), detect_fcos 의 빈 centerness 와 point_offset, 새 detect_rpn(레벨별 NMS). Co-Authored-By: Claude Opus 5 (1M context) --- docs/mmdet-detectors.md | 87 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 78 insertions(+), 9 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index b6225c1..4ca886e 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -30,6 +30,7 @@ objects. - [Prerequisites](#prerequisites) - [Pipeline](#pipeline) - [Output](#output) +- [What decodes to boxes](#what-decodes-to-boxes) - [Detection heads](#detection-heads) - [Post-processing API](#post-processing-api) - [Two-stage detectors](#two-stage-detectors) @@ -285,6 +286,57 @@ above 0.25 agree in class and score (cat 0.918, couch 0.771, tv 0.681) with box within 0.1 px, and the pre-decode tensors are at relative L1 1.7e-03 on the boxes and 4.7e-04 on the class logits. +## What decodes to boxes + +Assembling a head and decoding its output are separate steps, and a family can pass the first +and fail the second. The runner picks a decoder from what the box prediction *is* — a delta +against an anchor, a distance from a grid point, a normalised `cxcywh` query — not from the +shape of the tower that produced it. YOLOX and RPN build the same tower as RetinaNet and decode +nothing like it. + +Each row below was measured against MMDetection's own `predict_by_feat` on the same pixels at +512 (`cat-and-hat.jpg`), with the trained checkpoint the family's `metafile.yml` names. +The harness is `tools/verify/dense_head/verify_postproc.py`; it passes at 2 px, 0.05 score, +no label mismatch and no difference in how many boxes survive. + +| Family | Decoder | Worst box | Worst score | +| :--- | :--- | ---: | ---: | +| `retinanet` | `detect_anchor` | 0.15 px | 0.006 | +| `atss` | `detect_anchor` | 0.14 px | 0.000 | +| `ddod` | `detect_anchor` | 0.41 px | 0.001 | +| `ghm` | `detect_anchor` | 0.46 px | 0.005 | +| `pisa` | `detect_anchor` | 0.18 px | 0.005 | +| `pvt` | `detect_anchor` | 1.93 px | 0.003 | +| `fcos` | `detect_fcos` | 0.13 px | 0.000 | +| `gfl` | `detect_fcos` | 0.23 px | 0.004 | +| `vfnet` | `detect_fcos` | 0.46 px | 0.003 | +| `yolox` | `detect_yolox` | 0.41 px | 0.002 | +| `detr` | `detect_detr` | 1.85 px | 0.044 | +| `conditional_detr` | `detect_detr` | 0.27 px | 0.002 | +| `dab_detr` | `detect_detr` | 0.28 px | 0.001 | +| `dino` | `detect_detr` | 0.41 px | 0.030 | +| `faster_rcnn` | `detect_roi` (at 800) | 0.10 px | 0.0008 | + +`rpn` decodes proposals rather than detections, so a worst case over the whole set says little: +of 185 proposals the median is 0.09 px and 182 are within 1 px, with one proposal of 186 +falling on the other side of the score cut. `free_anchor` agrees to 0.35 px and 0.025 with one +box likewise on the boundary. + +Three groups do not decode, and they fail for different reasons: + +- **The head or neck already disagrees with torch**, so there is nothing to judge the decoder + against: `tood`, `rtmdet`, `deformable_detr`, `dyhead`, `efficientnet`, `nas_fpn`. Dumping + with `MMDET_DUMP_HEAD` shows the neck output matching at 1e-3 while the head output does not. +- **The family post-processes its own way**: `paa` and `lad` combine class score and IoU as + `sqrt(cls * iou)` and then re-average boxes by score voting; `yolact` uses fast NMS and mask + coefficients. Their boxes already agree — `paa` to 9 px — but far fewer survive the threshold. +- **The priors or the coder are outside `det_params`**: `ssd` uses a different number of anchors + per level, `fsaf` a TBLR coder, and `cornernet`, `centripetalnet`, `centernet` and `yolov3` + decode from heatmaps or corner pairs. + +A family in the first group is not silently wrong: without anchor parameters `detect_anchor` +generates no candidates and the runner reports zero boxes. + ## Detection heads Head components take FPN features and produce the raw per-level tensors that the decoders @@ -379,12 +431,19 @@ struct detection { ### Dense heads -`std::vector detect_anchor(cls_scores, bbox_preds, feat_hw, det_params const& p)` +`std::vector detect_anchor(cls_scores, bbox_preds, feat_hw, det_params const& p, score_factors = nullptr)` : Anchor-based decoding: anchor generation, delta decoding, per-level top-k, score - thresholding and NMS. Used by RetinaNet, ATSS, GFL and RPN-style heads. + thresholding and NMS. Used by RetinaNet, ATSS, PAA and other delta-coded heads. + `score_factors` is the optional centerness/IoU branch. MMDetection thresholds and takes + top-k on the class score **alone** and multiplies the factor in afterwards, so passing it + here rather than folding it into `cls_scores` is what keeps the surviving set the same. `std::vector detect_fcos(cls_scores, bbox_preds, centerness, feat_hw, fcos_params const& p)` -: Anchor-free distance decoding with centerness weighting. +: Anchor-free distance decoding. `centerness` may be empty — GFL and VFNet fold quality into + the class score and have no such branch. `bbox_preds` are already pixel distances: the head + component applies the DFL integral, the stride multiply and the exponent, so this function + must not apply a stride again. `point_offset` is 0.5 for FCOS and 0 for the heads built on + an `AnchorGenerator`. `std::vector detect_yolox(cls, box, obj, feat_hw, yolox_params const& p)` : Grid-based decoding with an objectness branch; score is `sigmoid(cls) * sigmoid(obj)`. @@ -400,9 +459,14 @@ image. ### Two-stage components +`std::vector detect_rpn(rpn_cls, rpn_bbox, feat_hw, rpn_params const& p)` +: Region proposals with their objectness scores. NMS runs **per level**, not per class — + MMDetection passes level ids to `batched_nms` — and there is no pre-NMS score threshold. + The returned `label` is the level the proposal came from. + `std::vector rpn_proposals(rpn_cls, rpn_bbox, feat_hw, rpn_params const& p)` -: Region proposals from RPN outputs: anchor decode, per-level top-k, NMS across levels. - Returns `M × 4` boxes in image coordinates, `M ≤ max_per_img`. +: The same computation with the scores dropped: `M × 4` boxes in image coordinates, + `M ≤ max_per_img`, which is the form the RoIAlign stage takes. `std::vector roi_align(feats, feat_hw, float const* rois, int m, roi_align_params const& p)` : MMCV-compatible RoIAlign (`aligned = true`, adaptive `sampling_ratio`). Level assignment @@ -471,10 +535,15 @@ x = (np.asarray(img.resize((800, 800)), dtype=np.float32) - mean) / std np.ascontiguousarray(x).tofile("input.bin") # HWC memory order == CWHN ``` -`out` is a prefix: the runner writes `out.cls.0.bin` (`rpn_max × num_classes+1` logits), -`out.box.0.bin`, the proposals, and the per-level RPN tensors — raw `float32`, for comparing -against the reference. Run on a trained Faster R-CNN R50 at 800, the highest-scoring RoI on -`cat-and-hat.jpg` is class 15 at 0.89 after softmax. +`out` is a prefix. `out.boxes.bin` holds the final detections in the same six-`float32` +layout `run_mmdet` writes (`x1 y1 x2 y2 score label`), and the runner prints the +highest-scoring rows. Beside it go the raw stages — `out.cls.0.bin` (`rpn_max × +num_classes+1` logits), `out.box.0.bin`, the proposals and the per-level RPN tensors — which +is what comparing against the reference at a single stage needs. + +Measured on a trained Faster R-CNN R50 at 800, `cat-and-hat.jpg`, against MMDetection's own +`predict` on the same pixels: four detections above 0.30 on both sides, box coordinates within +0.10 px and scores within 0.0008 (top row: class 15 at 0.893). `run_roi_verify` and `run_rpn_verify` check the two host stages in isolation against dumps from the reference implementation. From 2e26afe6cf0d3ade325b1b707b1683c295d2975e Mon Sep 17 00:00:00 2001 From: eunchae Date: Fri, 14 Aug 2026 16:06:10 +0900 Subject: [PATCH 26/89] =?UTF-8?q?fix(frontend):=20=EA=B3=B5=EC=9C=A0=20con?= =?UTF-8?q?v=20=EC=97=90=20BN=20=EC=9D=84=20=EB=91=90=20=EB=B2=88=20?= =?UTF-8?q?=EC=A0=91=EA=B3=A0=20=EC=9E=88=EC=97=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fold_head_bn` 은 레벨끼리 conv 를 공유하는 head(RTMDetSepBNHead·RetinaSepBNHead)를 알고 있었고 두 번째부터 사본을 만들었다. 그런데 **사본을 뜨는 시점이 늦었다** — 그때 그 conv 에는 이미 0번 레벨의 BN 이 접혀 있어서, 사본에 자기 BN 을 또 접으면 두 번 접힌다. 레벨이 올라갈수록 어긋남이 커지는 것이 그 증상이다. 접기 전 값을 `pristine` 에 따로 들고 매번 거기서 접는다. 이게 "조립이 틀렸다" 로 보이던 것의 정체였다. C++ 조립은 내보낸 .pt 와 전 레벨 1e-4 로 맞고 있었다 — 어긋난 건 .pt 쪽이었다(레벨0 1.2e-4 → 레벨4 3.81). 실측(512, mmdet predict_by_feat 대조) — 셋 다 박스가 안 나오거나 틀렸던 계열이다: efficientnet 박스 0.15px · 점수 0.005 · 개수차 0 nas_fpn 박스 0.34px · 점수 0.004 · 개수차 0 rtmdet 박스 0.66px · 점수 0.007 · 개수차 0 Co-Authored-By: Claude Opus 5 (1M context) --- tools/frontend/mmdet/mmdet_wrap.py | 31 +++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/tools/frontend/mmdet/mmdet_wrap.py b/tools/frontend/mmdet/mmdet_wrap.py index 36aa77c..b02a6e9 100755 --- a/tools/frontend/mmdet/mmdet_wrap.py +++ b/tools/frontend/mmdet/mmdet_wrap.py @@ -80,27 +80,36 @@ def fold_head_bn(bh): """ import copy import torch - folded, seen = 0, set() + folded = 0 + # ⚠️ **conv 를 레벨끼리 공유하는 head 가 있다**(RTMDetSepBNHead(share_conv=True), + # RetinaSepBNHead: conv 는 하나인데 BN 만 레벨별로 따로다). 그대로 접으면 같은 + # conv 에 BN 을 여러 번 겹쳐 접는다. + # + # ⚠️ **사본을 만드는 것만으로는 모자란다.** 두 번째 레벨에서 복사하는 그 conv 는 + # **이미 0번 BN 이 접힌** 값이다 — 사본에 1번 BN 을 또 접으면 두 번 접힌다. + # 레벨이 올라갈수록 어긋남이 커지는 것이 그 증상이다(efficientnet 실측: + # 레벨0 1e-4 → 레벨4 3.8). 그래서 **접기 전 값을 따로 들고** 매번 거기서 접는다. + pristine = {} # id(conv) -> 접기 전 (weight, bias) for mod in bh.modules(): bn = getattr(mod, "bn", None) conv = getattr(mod, "conv", None) if not isinstance(bn, nn.modules.batchnorm._BatchNorm) or conv is None: continue - # ⚠️ **conv 를 레벨끼리 공유하는 head 가 있다**(RTMDetSepBNHead: share_conv=True 라 - # conv 는 하나인데 BN 만 레벨별로 따로다). 그대로 접으면 같은 conv 에 BN 을 - # 여러 번 겹쳐 접어 **전 레벨이 다 틀린다**(실측 cls 배율 0.89). - # 두 번째부터는 그 ConvModule 전용 사본을 만들어 접는다. - if id(conv) in seen: - conv = copy.deepcopy(conv) + key = id(conv) + if key not in pristine: + pristine[key] = (conv.weight.detach().clone(), + None if conv.bias is None else conv.bias.detach().clone()) + else: + conv = copy.deepcopy(conv) # 이 ConvModule 전용 사본(값은 아래에서 덮는다) mod.conv = conv - seen.add(id(conv)) + w0, b0 = pristine[key] with torch.no_grad(): std = (bn.running_var + bn.eps).sqrt() scale = bn.weight / std - w = conv.weight * scale.reshape(-1, 1, 1, 1) + w = w0 * scale.reshape(-1, 1, 1, 1) b = bn.bias - bn.running_mean * scale - if conv.bias is not None: - b = b + conv.bias * scale + if b0 is not None: + b = b + b0 * scale conv.weight.copy_(w) if conv.bias is None: conv.bias = nn.Parameter(b) From 7db6c37520a5bb5490f23d190497edb22463d27f Mon Sep 17 00:00:00 2001 From: eunchae Date: Fri, 14 Aug 2026 16:09:05 +0900 Subject: [PATCH 27/89] =?UTF-8?q?docs:=20=EC=8B=A4=EC=B8=A1=ED=91=9C?= =?UTF-8?q?=EB=A5=BC=2018=EA=B3=84=EC=97=B4=EB=A1=9C=20=EA=B0=B1=EC=8B=A0?= =?UTF-8?q?=ED=95=98=EA=B3=A0,=20=EC=95=88=20=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EC=9D=B4=EC=9C=A0=EB=A5=BC=20=EB=8B=A4=EC=8B=9C=20=EA=B0=80?= =?UTF-8?q?=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BN fold 를 고쳐 efficientnet · nas_fpn · rtmdet 이 열렸다. "조립이 틀렸다" 로 묶여 있던 계열 중 셋은 사실 내보내기가 틀렸던 것이고, 남은 셋은 각각 다른 곳이다 — tood 는 box 갈래만, dyhead 는 아예 neck 부터다. 한 줄로 묶어 두면 다음에 어디를 볼지 알 수 없어 갈라 적는다. Co-Authored-By: Claude Opus 5 (1M context) --- docs/mmdet-detectors.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 4ca886e..21606f9 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -307,9 +307,12 @@ no label mismatch and no difference in how many boxes survive. | `ghm` | `detect_anchor` | 0.46 px | 0.005 | | `pisa` | `detect_anchor` | 0.18 px | 0.005 | | `pvt` | `detect_anchor` | 1.93 px | 0.003 | +| `efficientnet` | `detect_anchor` | 0.15 px | 0.005 | +| `nas_fpn` | `detect_anchor` | 0.34 px | 0.004 | | `fcos` | `detect_fcos` | 0.13 px | 0.000 | | `gfl` | `detect_fcos` | 0.23 px | 0.004 | | `vfnet` | `detect_fcos` | 0.46 px | 0.003 | +| `rtmdet` | `detect_fcos` | 0.66 px | 0.007 | | `yolox` | `detect_yolox` | 0.41 px | 0.002 | | `detr` | `detect_detr` | 1.85 px | 0.044 | | `conditional_detr` | `detect_detr` | 0.27 px | 0.002 | @@ -324,9 +327,12 @@ box likewise on the boundary. Three groups do not decode, and they fail for different reasons: -- **The head or neck already disagrees with torch**, so there is nothing to judge the decoder - against: `tood`, `rtmdet`, `deformable_detr`, `dyhead`, `efficientnet`, `nas_fpn`. Dumping - with `MMDET_DUMP_HEAD` shows the neck output matching at 1e-3 while the head output does not. +- **Something before the decoder already disagrees with torch**, so there is nothing to judge + the decoder against: `tood` (the box branch blows up while the class branch matches at + 2e-3), `deformable_detr`, and `dyhead` (its neck is already at 0.7 relative L1, so the head + never had a chance). `MMDET_DUMP_HEAD` writes the neck output beside the head output for + exactly this split — a family whose `feat` dumps match and whose `cls`/`box` dumps do not is + a head problem, and the reverse is a compiler problem. - **The family post-processes its own way**: `paa` and `lad` combine class score and IoU as `sqrt(cls * iou)` and then re-average boxes by score voting; `yolact` uses fast NMS and mask coefficients. Their boxes already agree — `paa` to 9 px — but far fewer survive the threshold. From c189dc96c1f87775870990c8c331d5c10bae075e Mon Sep 17 00:00:00 2001 From: eunchae Date: Fri, 14 Aug 2026 17:09:47 +0900 Subject: [PATCH 28/89] =?UTF-8?q?feat(verify):=20two-stage=20=EA=B3=84?= =?UTF-8?q?=EC=97=B4=EC=9D=98=20=EC=B5=9C=EC=A2=85=20=EB=B0=95=EC=8A=A4?= =?UTF-8?q?=EB=A5=BC=20mmdet=20=EA=B3=BC=20=EB=8C=80=EC=A1=B0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit faster_rcnn 하나만 재고 "나머지 34계열도 같은 러너를 타니 열려 있다" 로 두었던 자리를 실제로 쟀다. 34 중 15 가 통과한다(0.04~0.39px · 점수 0.0001~0.0079). `verify_postproc_roi.py` 는 dense head 쪽 `verify_postproc.py` 의 two-stage 판이다. 계열 하나당 export → g2c ×2 → 러너 빌드 → 실행 → mmdet `predict` 대조를 40~90초에 끝낸다. 판정 기준·짝짓기는 dense head 와 **같게** 뒀다 — 다르면 기존 18계열 숫자와 나란히 못 놓는다. 같이 고친 것 둘. 둘 다 계열이 못 하는 게 아니라 **하네스가 못 재던 것**이었다: - SyncBN config 가 build 중에 죽었다(`init_process_group` 요구). `revert_sync_batchnorm` 은 늦다 — `mmcv.ops.SyncBatchNorm.__init__` 이 생성 시점에 `dist.get_world_size()` 를 부른다. config 의 norm 을 만들기 전에 BN 으로 바꾼다(추론 수치는 같다). simple_copy_paste 가 이걸로 열렸다. - PanopticFPN 은 `predict` 가 `pred_instances` 를 안 남긴다(파놉틱 융합만 한다). 박스 경로의 정본인 `roi_head.predict` 로 떨어뜨린다. 실패 19건은 한 칸이 아니라 네 갈래다 — 표준 앵커 RPN 아님 5 · 캐스케이드 4(점수를 단계별로 평균하는 규약이 빠졌다) · 계열 전용 후처리 3 · 넥/정규화 수치 8. 문서에 갈라 적었다. resnest 가 gn 과 **같은** Shared4Conv1FCBBoxHead 로 통과하므로 head 모양은 무죄다. Co-Authored-By: Claude Opus 5 (1M context) --- docs/mmdet-detectors.md | 56 +++- tools/frontend/mmdet/frcnn_to_pt.py | 31 ++- tools/verify/roi/verify_postproc_roi.py | 339 ++++++++++++++++++++++++ 3 files changed, 424 insertions(+), 2 deletions(-) create mode 100644 tools/verify/roi/verify_postproc_roi.py diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 21606f9..27c528b 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -318,7 +318,61 @@ no label mismatch and no difference in how many boxes survive. | `conditional_detr` | `detect_detr` | 0.27 px | 0.002 | | `dab_detr` | `detect_detr` | 0.28 px | 0.001 | | `dino` | `detect_detr` | 0.41 px | 0.030 | -| `faster_rcnn` | `detect_roi` (at 800) | 0.10 px | 0.0008 | + +Two-stage families are measured separately, at 800 and against the detector's own `predict` +rather than a head's `predict_by_feat`, because the boxes do not exist until RPN proposals, +RoIAlign and the RoI head have run. The harness is `tools/verify/roi/verify_postproc_roi.py` +and the thresholds are the same. Fifteen of the thirty-four families with a `roi_head` agree: + +| Family | Decoder | Worst box | Worst score | +| :--- | :--- | ---: | ---: | +| `panoptic_fpn` | `detect_roi` | 0.04 px | 0.0001 | +| `dcnv2` | `detect_roi` | 0.05 px | 0.0006 | +| `hrnet` | `detect_roi` | 0.06 px | 0.0002 | +| `mask_rcnn` | `detect_roi` | 0.06 px | 0.0009 | +| `empirical_attention` | `detect_roi` | 0.09 px | 0.0003 | +| `faster_rcnn` | `detect_roi` | 0.10 px | 0.0008 | +| `resnest` | `detect_roi` | 0.12 px | 0.0002 | +| `albu_example` | `detect_roi` | 0.14 px | 0.0008 | +| `point_rend` | `detect_roi` | 0.15 px | 0.0012 | +| `regnet` | `detect_roi` | 0.16 px | 0.0012 | +| `simple_copy_paste` | `detect_roi` | 0.19 px | 0.0007 | +| `resnet_strikes_back` | `detect_roi` | 0.24 px | 0.0023 | +| `fpg` | `detect_roi` (at 1024) | 0.28 px | 0.0036 | +| `dcn` | `detect_roi` | 0.37 px | 0.0002 | +| `instaboost` | `detect_roi` | 0.39 px | 0.0079 | + +`fpg` is measured at 1024 rather than 800, and the reason is worth stating: it builds levels +below P5, where 800 stops dividing evenly — a 25-wide map meets a 26-wide one and the export +aborts. That is a property of the resolution, not of the family. + +The nineteen that do not agree split four ways, and the split matters more than the count: + +- **The RPN is not a standard anchor RPN**, so host `rpn_proposals` cannot lay down the priors: + `cascade_rpn` refines across stages, `guided_anchoring` predicts anchor shapes, and + `queryinst`/`sparse_rcnn` learn proposals outright. `groie` is refused for the neighbouring + reason — `GenericRoIExtractor` aggregates every level through per-level convolutions, which + host RoIAlign cannot express. All five stop at export with a stated reason rather than a + wrong number. +- **Cascade heads decode from the last stage only** — `cascade_rcnn`, `htc`, `detectors`, + `scnet`. The runner does walk all three stages and refines boxes between them, but + MMDetection averages the classification scores over stages before the final decode, so the + scores diverge and the boxes follow. This is a decode-convention gap, not missing plumbing. +- **The family post-processes its own way**: `crowddet` predicts two instances per proposal and + needs set-NMS (without it nothing is suppressed — 500 boxes against 1), `ms_rcnn` rescales + scores by a predicted mask IoU (its boxes are exact at 0.07 px; only the scores differ), and + `grid_rcnn` turns off box regression on the bbox head entirely and regresses in a grid head, + so there is no `bbox_pred` to decode. +- **Something before the decoder disagrees**, the same category as `dyhead` above: `carafe`, + `pafpn`, `libra_rcnn`, `dynamic_rcnn`, `res2net` and `gcnet` return the right *number* of + boxes with the right labels but coordinates 6–37 px out. Every one of them replaces or + augments the plain FPN. `gn` and `gn+ws` are the sharper version of the same point — they use + `Shared4Conv1FCBBoxHead`, but so does `resnest`, which passes at 0.12 px, so the head layout + is exonerated and GroupNorm/weight-standardisation is what is left. + +`swin` (the SubA gguf fails to load), `tridentnet` (the runner returns no boxes at all) and +`seesaw_loss` (an LVIS-class config, 3 reference boxes against 300) are not yet sorted into +those four. `rpn` decodes proposals rather than detections, so a worst case over the whole set says little: of 185 proposals the median is 0.09 px and 182 are within 1 px, with one proposal of 186 diff --git a/tools/frontend/mmdet/frcnn_to_pt.py b/tools/frontend/mmdet/frcnn_to_pt.py index 0aa8ae2..478a4ac 100755 --- a/tools/frontend/mmdet/frcnn_to_pt.py +++ b/tools/frontend/mmdet/frcnn_to_pt.py @@ -23,6 +23,35 @@ from frcnn_wrap import FRCNN_SubA, FRCNN_SubB, MaskRCNN_SubC, frcnn_cfg # noqa: E402,F401 (피클: frcnn_wrap) +def _desync_norm(cfg_path): + """config 의 `SyncBN`/`MMSyncBN` 을 `BN` 으로 바꾼 Config 를 돌려준다. + + ⚠️ **모델을 만든 뒤에 되돌리면 늦다.** `mmcv.ops.SyncBatchNorm.__init__` 이 생성 시점에 + `dist.get_world_size()` 를 부르므로, 단일 프로세스에서는 `init_process_group` 을 + 요구하며 **build 중에** 죽는다(`mmengine.revert_sync_batchnorm` 은 이미 만들어진 모듈만 + 바꾼다). simple_copy_paste 가 그렇게 "export 실패" 로 남았다 — 못 하는 것이 아니라 + **안 재본 것**이었다. + + 추론 수치는 BN 과 같다(둘 다 저장된 running stats 를 쓴다). 바꾸는 것은 학습 시 통계를 + 프로세스 간에 합치느냐뿐이다. + """ + from mmengine.config import Config + cfg = Config.fromfile(cfg_path) if isinstance(cfg_path, str) else cfg_path + + def walk(o): + if isinstance(o, dict): + if o.get("type") in ("SyncBN", "MMSyncBN"): + o["type"] = "BN" + for v in o.values(): + walk(v) + elif isinstance(o, (list, tuple)): + for v in o: + walk(v) + + walk(cfg._cfg_dict) + return cfg + + def main(argv=None): ap = argparse.ArgumentParser(prog="frcnn_to_pt") ap.add_argument("--config", required=True, help="Faster R-CNN config .py") @@ -32,7 +61,7 @@ def main(argv=None): a = ap.parse_args(argv) from mmdet.apis import init_detector # `.eval()` 체이닝 금지 — train() 을 오버라이드한 계열에서 None 이 돌아온다. - det = init_detector(a.config, a.checkpoint, device="cpu") + det = init_detector(_desync_norm(a.config), a.checkpoint, device="cpu") det.eval() os.makedirs(a.out, exist_ok=True) diff --git a/tools/verify/roi/verify_postproc_roi.py b/tools/verify/roi/verify_postproc_roi.py new file mode 100644 index 0000000..c5262ab --- /dev/null +++ b/tools/verify/roi/verify_postproc_roi.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""verify_postproc_roi.py — **two-stage 계열**의 최종 박스를 mmdet 과 대조한다. + +`dense_head/verify_postproc.py` 의 two-stage 판이다. 재는 지점이 같다: + + mmdet 자신의 `detector.predict` vs `run_frcnn` 이 낸 최종 박스 + +왜 별도 스크립트인가 +------------------- +one-stage 는 그래프 하나 + `run_mmdet` 하나로 끝난다. two-stage 는 RPN proposal(NMS)과 +RoIAlign 이 **실행 중에 개수·좌표가 정해지는** 동작이라 그래프를 둘로 쪼개고 사이에 호스트 +코드를 끼운다(`run_frcnn.cpp`). 그래서 준비 단계(export → g2c ×2 → 빌드)가 통째로 다르다. + +`verify_heads.py --two-stage` 는 **디코드 앞**(SubB 원시 텐서)에서 끊는다. 이 스크립트는 +그 뒤 — 앵커·proposal NMS·RoIAlign·delta 디코드·클래스별 NMS — 를 다 통과한 결과를 본다. + +⚠️ **양쪽에 같은 픽셀을 준다.** 정규화까지 마친 배열 하나를 만들어, 러너에는 `.bin` 으로 + torch 에는 그대로 텐서로 넣는다. 각자 리사이즈·정규화하게 두면 백엔드가 아니라 전처리 + 구현을 재게 된다(dense head 쪽에서 실제로 그렇게 0.836 → 0.757 로 어긋났다). + +사용: + python verify_postproc_roi.py <계열...> # 계열명 = configs/ 폴더명 + python verify_postproc_roi.py --all # two-stage 로 판정된 계열 전부 + python verify_postproc_roi.py faster_rcnn --keep # 중간 산출물 남기기 + +환경: `MMDET`(기본 ~/mmbuild/mmdetection) 아래에 `configs/` 와 `checkpoints/` 가 있어야 한다. +""" +import argparse +import json +import os +import subprocess +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +V = os.path.abspath(os.path.join(HERE, "..", "..", "..")) # vision.cpp (tools/verify/roi 에서 3단계 위) +FE = os.path.join(V, "tools", "frontend", "mmdet") +DH = os.path.join(V, "tools", "verify", "dense_head") +GGUF_PY = os.path.join(V, "depend", "llama", "gguf-py") +G2C = os.path.abspath(os.path.join(V, "..")) # vision.cpp 를 담은 컴파일러 루트 +MM = os.path.expanduser(os.environ.get("MMDET", "~/mmbuild/mmdetection")) +CFGS = os.path.join(MM, "configs") +CKPTS = os.path.join(MM, "checkpoints") +BUILD = os.environ.get("VISP_BUILD", os.path.join(V, "build")) +PY = sys.executable + +sys.path.insert(0, DH) +import mmdet_families as MF # noqa: E402 + +# mmpretrain 의 blip 이 이 조합에서 import 시 죽는다 — 하위 프로세스에도 같은 우회를 심는다. +STUB = '''import sys, types +for n in ("mmpretrain.models.multimodal.blip", "mmpretrain.models.multimodal.blip.language_model"): + m = types.ModuleType(n); m.__path__ = []; sys.modules[n] = m +''' + +# 판정 기준 — dense head 와 **같게 둔다**. 다르면 18계열 숫자와 나란히 못 놓는다. +BOX_TOL, SCORE_TOL, THR = 2.0, 0.05, 0.30 + + +def run(cmd, cwd, env_extra=None, timeout=3600): + env = dict(os.environ, OMP_NUM_THREADS="1") + env.update(env_extra or {}) + return subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True, timeout=timeout) + + +def last_error(text): + """스택트레이스에서 마지막 예외 줄만. 없으면 마지막 비어있지 않은 줄.""" + lines = [l for l in (text or "").splitlines() if l.strip()] + for l in reversed(lines): + if ("Error" in l or "error" in l or "Exception" in l) and not l.startswith(" "): + return l.strip() + return lines[-1].strip() if lines else "-" + + +# ── 기준값: mmdet 자신의 predict. 러너에 준 것과 **같은 배열**을 받는다. ───────── +REF = r''' +import _stub, os, sys, numpy as np, torch +cfg, ckpt, size, npy, out = sys.argv[1], sys.argv[2], int(sys.argv[3]), sys.argv[4], sys.argv[5] +sys.path.insert(0, "%(FE)s") +try: + import mmdet_wrap; mmdet_wrap.allow_mmengine_checkpoint_globals() +except Exception as e: + print(" ⚠️ 체크포인트 허용 목록 설정 실패: %%s: %%s" %% (type(e).__name__, e)) +from mmdet.apis import init_detector +# ⚠️ 기준값 쪽도 **같은 config 손질**을 거쳐야 한다. SyncBN 은 단일 프로세스에서 build 중에 +# 죽으므로(frcnn_to_pt._desync_norm 참고), 내보내기만 고치면 여기서 다시 막힌다. +from frcnn_to_pt import _desync_norm +from mmdet.structures import DetDataSample +det = init_detector(_desync_norm(cfg), ckpt, device="cpu"); det.eval() +x = np.load(npy) # HWC, 정규화까지 끝난 것 +t = torch.from_numpy(x).permute(2, 0, 1).unsqueeze(0).contiguous() +meta = {"img_shape": (size, size), "ori_shape": (size, size), + "scale_factor": (1.0, 1.0), "batch_input_shape": (size, size)} +ds = DetDataSample(); ds.set_metainfo(meta) +with torch.no_grad(): + try: + res = det.predict(t, [ds], rescale=False)[0].pred_instances + except AttributeError: + # ⚠️ **`predict` 가 박스를 안 내는 계열이 있다.** PanopticFPN 은 파놉틱 융합까지 하고 + # `pred_panoptic_seg` 만 남겨 `pred_instances` 가 없다. 그렇다고 "측정 불가" 로 + # 두면 안 된다 — 우리가 재려는 것은 **two-stage 박스 경로**이고, 그 정본은 + # detector 가 아니라 `roi_head.predict` 다. 같은 RPN·같은 RoI head 를 탄다. + feats = det.extract_feat(t) + rpn = det.rpn_head.predict(feats, [ds], rescale=False) + res = det.roi_head.predict(feats, rpn, [ds], rescale=False)[0] +np.save(out, np.concatenate([res.bboxes.numpy(), + res.scores.numpy()[:, None], + res.labels.numpy()[:, None].astype("float32")], 1)) +print("REF_OK", len(res.scores)) +''' + +# 전처리 상수는 detector 의 `data_preprocessor` 가 정본이다. 러너와 torch 에 **같은 배열**을 +# 주기 위해, 여기서 한 번만 만들어 `.npy`(torch용) 와 `.bin`(러너용, cwhn) 로 내보낸다. +PREP = r''' +import _stub, os, sys, numpy as np, torch +from PIL import Image +cfg, ckpt, size, img, npy, binp = sys.argv[1], sys.argv[2], int(sys.argv[3]), sys.argv[4], sys.argv[5], sys.argv[6] +sys.path.insert(0, "%(FE)s") +try: + import mmdet_wrap; mmdet_wrap.allow_mmengine_checkpoint_globals() +except Exception: + pass +from mmdet.apis import init_detector +# ⚠️ 기준값 쪽도 **같은 config 손질**을 거쳐야 한다. SyncBN 은 단일 프로세스에서 build 중에 +# 죽으므로(frcnn_to_pt._desync_norm 참고), 내보내기만 고치면 여기서 다시 막힌다. +from frcnn_to_pt import _desync_norm +det = init_detector(_desync_norm(cfg), ckpt, device="cpu"); det.eval() +dp = det.data_preprocessor +mean = dp.mean.view(3).numpy() if hasattr(dp, "mean") else np.zeros(3, "float32") +std = dp.std.view(3).numpy() if hasattr(dp, "std") else np.ones(3, "float32") +# ⚠️ `_channel_conversion` 이 True 면 **입력이 BGR 이라고 보고 RGB 로 뒤집는** 설정이다. +# 즉 네트워크가 원하는 것은 RGB. False 면 config 의 mean/std 가 BGR 순서다. +to_rgb = bool(getattr(dp, "_channel_conversion", False)) +im = np.asarray(Image.open(img).convert("RGB").resize((size, size), Image.BILINEAR), dtype="float32") +x = im if to_rgb else im[:, :, ::-1] # 네트워크가 먹는 채널 순서로 +x = (np.ascontiguousarray(x) - mean) / std +np.save(npy, x) +np.ascontiguousarray(x).tofile(binp) # HWC 연속 == 러너의 cwhn +print("PREP_OK mean=%%s std=%%s to_rgb=%%s" %% (list(mean), list(std), to_rgb)) +''' + + +def match(ref, got): + """`dense_head/verify_postproc.match` 와 **같은 짝짓기**. 두 도구의 숫자를 나란히 놓기 위해서다. + + ref 순서대로 가까운 것을 집으면 앞쪽이 뒤쪽의 짝을 가져가 집합이 통째로 밀린다. + 전역으로 가까운 쌍부터 1:1 로 확정한다. 라벨이 같은 쌍만 후보로 둔다. + """ + import numpy as np + if len(ref) == 0 or len(got) == 0: + return None + pairs = [] + for i, r in enumerate(ref): + same = np.flatnonzero(got[:, 5] == r[5]) + pool = same if len(same) else np.arange(len(got)) + d = np.abs(got[pool][:, :4] - r[:4]).max(1) + pairs.extend((float(dist), i, int(j)) for j, dist in zip(pool, d)) + pairs.sort() + take_r, take_g = {}, set() + for dist, i, j in pairs: + if i in take_r or j in take_g: + continue + take_r[i] = (j, dist) + take_g.add(j) + return [(ref[i], got[take_r[i][0]], take_r[i][1]) for i in range(len(ref)) if i in take_r] + + +def one(fam, size, image, workdir, keep, verbose): + import numpy as np + t0 = time.time() + d = os.path.join(workdir, fam) + os.makedirs(d, exist_ok=True) + open(os.path.join(d, "_stub.py"), "w").write(STUB) + + cfg_rel, ckpt_name, _ = MF.resolve(CFGS, fam) + if not cfg_rel: + return fam, "CONFIG_NONE", "-", None + cfg = os.path.join(CFGS, cfg_rel) + if not ckpt_name: + return fam, "CKPT_NONE", f"metafile 에 가중치 없음 ({cfg_rel})", None + ckpt = os.path.join(CKPTS, ckpt_name) + if not os.path.exists(ckpt): + return fam, "CKPT_MISSING", ckpt_name, None + + # ① export — two-stage 를 두 subgraph 로 가른다. 여기서 죽으면 "왜" 를 그대로 옮긴다. + fr = os.path.join(d, "frcnn") + r = run([PY, os.path.join(FE, "frcnn_to_pt.py"), "--config", cfg, "--checkpoint", ckpt, + "--out", fr, "--size", str(size)], MM, {"PYTHONPATH": f"{d}:{FE}"}) + if not os.path.exists(os.path.join(fr, "frcnn.json")): + err = last_error(r.stderr) + kind = "UNSUPPORTED" if "NotImplementedError" in (r.stderr or "") else "EXPORT_FAIL" + return fam, kind, err[:110], None + J = json.load(open(os.path.join(fr, "frcnn.json"))) + O, RC = int(J["roi_out"]), int(J.get("roi_channels", 256)) + MX = int(J["rpn_max"]) + # Double-Head 는 (cls용, reg용) 두 벌을 배치로 이어 넣는다 → 배치가 2배다. + if float(J.get("reg_roi_scale_factor", 0) or 0) > 0: + MX *= 2 + NS = int(J.get("num_bbox_stages", 1)) + subs = ["FRCNN_SubB"] if NS == 1 else [f"FRCNN_SubB{i}" for i in range(NS)] + + # ② g2c 컴파일 — SubA 는 이미지 해상도로, SubB 는 **proposal 상한 배치**로. + # batch=1 로 구우면 1000개를 넣을 때 reshape 이 안 맞아 죽는다. + jobs = [("FRCNN_SubA", "FRCNN_SubA", "out_FRCNN_SubA", f"1,3,{size},{size}")] + # 캐스케이드는 단계마다 가중치만 다르므로 그래프 이름을 subs[0] 으로 통일해 gguf 만 갈아 낀다. + jobs += [(s, subs[0], "out_" + s, f"{MX},{RC},{O},{O}") for s in subs] + for src, name, outdir, shape in jobs: + r = run([PY, "-c", f''' +import _stub, sys +sys.argv = ["g2c","--model","{src}.pt","--name","{name}","--output","{outdir}","--input-shape","{shape}"] +from shared.compile.pipeline import main; main() +'''], fr, {"PYTHONPATH": f"{d}:{fr}:{G2C}:{FE}:{GGUF_PY}"}) + if not os.path.exists(os.path.join(fr, outdir, f"{name}.gguf")): + return fam, "COMPILE_FAIL", f"{src}: " + last_error(r.stderr)[:100], None + + # ③ 러너 빌드 — 빌드 라인은 build_frcnn_cpp.sh / verify_heads.py 와 같아야 한다. + import shutil + for name, inc in (("FRCNN_SubA", "incA"), (subs[0], "incB")): + os.makedirs(os.path.join(fr, inc, "visp", "arch"), exist_ok=True) + shutil.copy(os.path.join(fr, "out_" + name, name + ".h"), + os.path.join(fr, inc, "visp", "arch")) + b = run(["g++", "-std=c++20", "-O1", "-DARCH_A=FRCNN_SubA", "-DARCH_B=" + subs[0], + '-DVISP_ARCH_HEADER_A="visp/arch/FRCNN_SubA.h"', + f'-DVISP_ARCH_HEADER_B="visp/arch/{subs[0]}.h"', + "-IincA", "-IincB", "-I" + V + "/include", "-I" + V + "/src", + "-I" + V + "/depend/llama/ggml/include", "-I" + V + "/depend/llama/vendor", + V + "/tools/verify/backbone/run_frcnn.cpp", + "out_FRCNN_SubA/FRCNN_SubA.cpp", f"out_{subs[0]}/{subs[0]}.cpp", + "-L" + BUILD + "/lib", "-lvisioncpp", "-lggml", "-lggml-base", "-lggml-cpu", + "-Wl,-rpath," + BUILD + "/lib", "-o", "run_frcnn"], fr) + if not os.path.exists(os.path.join(fr, "run_frcnn")): + return fam, "BUILD_FAIL", last_error(b.stderr)[:110], None + + # ④ 같은 픽셀 만들기 (러너용 .bin + torch 용 .npy) + open(os.path.join(d, "prep.py"), "w").write(PREP % {"FE": FE}) + npy, binp = os.path.join(d, "x.npy"), os.path.join(d, "in.bin") + p = run([PY, "prep.py", cfg, ckpt, str(size), image, npy, binp], d, {"PYTHONPATH": f"{d}:{FE}"}) + if "PREP_OK" not in (p.stdout or ""): + return fam, "PREP_FAIL", last_error(p.stderr)[:110], None + if verbose: + print(" ", p.stdout.strip().splitlines()[-1]) + + # ⑤ 러너 실행 + pref = os.path.join(d, "cpp") + rr = run([os.path.join(fr, "run_frcnn"), "out_FRCNN_SubA/FRCNN_SubA.gguf", + ",".join(f"out_{s}/{subs[0]}.gguf" for s in subs), + "frcnn.json", binp, pref, str(size)], fr, {"VISP_BACKEND": "cpu"}) + if not os.path.exists(pref + ".boxes.bin"): + return fam, "RUN_FAIL", last_error(rr.stderr)[:110], None + got = np.fromfile(pref + ".boxes.bin", dtype="float32").reshape(-1, 6) + got = got[got[:, 4] >= THR] + + # ⑥ mmdet 기준값 + open(os.path.join(d, "ref.py"), "w").write(REF % {"FE": FE}) + refnpy = os.path.join(d, "ref.npy") + q = run([PY, "ref.py", cfg, ckpt, str(size), npy, refnpy], d, {"PYTHONPATH": f"{d}:{FE}"}) + if "REF_OK" not in (q.stdout or ""): + return fam, "REF_FAIL", last_error(q.stderr)[:110], None + ref = np.load(refnpy) + ref = ref[ref[:, 4] >= THR] + + rows = match(ref, got) + dt = time.time() - t0 + if rows is None: + return fam, "EMPTY", f"mmdet {len(ref)}건 · C++ {len(got)}건 — 한쪽이 비었다", dt + worst_b = max((db for _, _, db in rows), default=0.0) + worst_s = max((abs(r[4] - g[4]) for r, g, _ in rows), default=0.0) + bad_label = sum(int(r[5] != g[5]) for r, g, _ in rows) + n_gap = abs(len(ref) - len(got)) + ok = worst_b < BOX_TOL and worst_s < SCORE_TOL and bad_label == 0 and n_gap == 0 + note = (f"박스 {worst_b:.2f}px · 점수 {worst_s:.4f} · 라벨 {bad_label} · 개수차 {n_gap}" + f" · {len(ref)}/{len(got)}건") + if verbose and rows: + for r, g, db in rows: + print(f" {int(r[5]):4d} [{r[0]:6.1f},{r[1]:6.1f},{r[2]:6.1f},{r[3]:6.1f}] {r[4]:.3f}" + f" vs [{g[0]:6.1f},{g[1]:6.1f},{g[2]:6.1f},{g[3]:6.1f}] {g[4]:.3f} {db:6.2f}px") + if not keep: + shutil.rmtree(fr, ignore_errors=True) + return fam, ("PASS" if ok else "FAIL"), note, dt + + +def two_stage_families(): + """config 로 판정한다 — 이름으로 짐작하지 않는다. roi_head 가 있으면 two-stage.""" + from mmengine.config import Config + out = [] + for fam, cfg_rel, _ in MF.families(CFGS): + try: + m = Config.fromfile(os.path.join(CFGS, cfg_rel)).get("model", {}) + except Exception: + continue + if m.get("roi_head"): + out.append(fam) + return out + + +def main(): + ap = argparse.ArgumentParser(prog="verify_postproc_roi") + ap.add_argument("families", nargs="*") + ap.add_argument("--all", action="store_true", help="two-stage 로 판정된 계열 전부") + ap.add_argument("--size", type=int, default=800) + ap.add_argument("--image", default=os.path.join(V, "tests", "input", "cat-and-hat.jpg")) + ap.add_argument("--workdir", default="/tmp/visp-postproc-roi") + ap.add_argument("--keep", action="store_true", help="중간 산출물(.pt·gguf·러너)을 남긴다") + ap.add_argument("-v", "--verbose", action="store_true") + a = ap.parse_args() + + fams = a.families or (two_stage_families() if a.all else []) + if not fams: + print(__doc__) + return 2 + os.makedirs(a.workdir, exist_ok=True) + print(f"size={a.size} · image={os.path.basename(a.image)} · thr={THR}" + f" · 판정: 박스<{BOX_TOL}px 점수<{SCORE_TOL} 라벨0 개수차0") + print(f"{len(fams)}계열: {' '.join(fams)}\n") + + res = [] + for i, fam in enumerate(fams, 1): + print(f"[{i}/{len(fams)}] {fam} …", flush=True) + try: + row = one(fam, a.size, a.image, a.workdir, a.keep, a.verbose) + except subprocess.TimeoutExpired: + row = (fam, "TIMEOUT", "-", None) + except Exception as e: # 한 계열이 죽어도 스윕은 계속한다 + row = (fam, "HARNESS_FAIL", f"{type(e).__name__}: {e}"[:110], None) + res.append(row) + print(f" {row[1]:14s} {row[2]}" + (f" ({row[3]:.0f}s)" if row[3] else ""), flush=True) + with open(os.path.join(a.workdir, "results.json"), "w") as f: + json.dump(res, f, indent=1, ensure_ascii=False) + + print("\n" + "=" * 78) + for fam, st, note, dt in res: + print(f" {fam:22s} {st:14s} {note}") + n_pass = sum(1 for _, s, _, _ in res if s == "PASS") + print(f"\nPASS {n_pass}/{len(res)}") + return 0 if n_pass == len(res) else 1 + + +if __name__ == "__main__": + sys.exit(main()) From a20d088e03cffd5414bb06eb8e3a5300f691f344 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 08:00:39 +0900 Subject: [PATCH 29/89] =?UTF-8?q?docs:=20=EC=8B=A4=ED=8C=A8=20=EC=9B=90?= =?UTF-8?q?=EC=9D=B8=20=EB=AC=B6=EC=9D=8C=EC=9D=84=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?config=20=EB=A1=9C=20=EB=8B=A4=EC=8B=9C=20=EA=B0=80=EB=A5=B8?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "박스만 밀리는 6계열은 전부 넥이 표준 FPN 이 아니다" 로 적었는데 셋은 평범한 FPN 이었다. 증상이 같다고 원인이 같지 않다. - 넥: carafe(FPN_CARAFE) · pafpn(PAFPN) · libra_rcnn(FPN+BFP) - 백본: res2net(계층적 스케일) · gcnet(ContextBlock 플러그인) - dynamic_rcnn: 둘 다 아니다. 테스트 아키텍처가 faster_rcnn(0.10px 통과)과 같고 RPN NMS 임계값만 0.85 vs 0.7 이다. 겹치는 후보가 대량으로 살아남아 proposal 집합이 점수 미세차에 민감해진다 — 먼저 진단할 계열이고, 여기서 나온 원인은 이미 통과한 15계열의 정확도 바닥에도 적용된다. seesaw_loss 는 "미확인" 이 아니라 전용 후처리 쪽이다 — NormedLinear(t=20) 분류기에 LVIS 1203 클래스, score_thr 0.0001. detect_roi 가 가정하는 점수 계산이 아니다. Co-Authored-By: Claude Opus 5 (1M context) --- docs/mmdet-detectors.md | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 27c528b..fdfaba3 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -365,14 +365,25 @@ The nineteen that do not agree split four ways, and the split matters more than so there is no `bbox_pred` to decode. - **Something before the decoder disagrees**, the same category as `dyhead` above: `carafe`, `pafpn`, `libra_rcnn`, `dynamic_rcnn`, `res2net` and `gcnet` return the right *number* of - boxes with the right labels but coordinates 6–37 px out. Every one of them replaces or - augments the plain FPN. `gn` and `gn+ws` are the sharper version of the same point — they use + boxes with the right labels but coordinates 6–37 px out. The shared symptom is tempting to + read as a shared cause; it is not one. Only three replace the neck (`FPN_CARAFE`, `PAFPN`, + and the `FPN`+`BFP` pair). `res2net` and `gcnet` run a plain FPN and differ in the backbone + (hierarchical scales, `ContextBlock` plugins). `dynamic_rcnn` differs in neither: plain + ResNet, plain FPN, `Shared2FCBBoxHead`, the same RCNN test config as `faster_rcnn`, which + passes at 0.10 px. Its one difference is an RPN NMS threshold of 0.85 against 0.7, which + keeps far more overlapping candidates and makes the proposal set correspondingly sensitive + to small score differences — so it is the family to diagnose first, and what it turns up + applies to the fifteen that already pass. + + `gn` and `gn+ws` are the sharper version of the same point — they use `Shared4Conv1FCBBoxHead`, but so does `resnest`, which passes at 0.12 px, so the head layout is exonerated and GroupNorm/weight-standardisation is what is left. -`swin` (the SubA gguf fails to load), `tridentnet` (the runner returns no boxes at all) and -`seesaw_loss` (an LVIS-class config, 3 reference boxes against 300) are not yet sorted into -those four. +`seesaw_loss` belongs with the third group rather than here: it classifies through a +`NormedLinear` layer at temperature 20 over 1203 LVIS classes with a score threshold of +0.0001, so its scores are not computed the way `detect_roi` assumes. `swin` (the SubA gguf +fails to load) and `tridentnet` (the runner returns no boxes at all) are the two that remain +unsorted. `rpn` decodes proposals rather than detections, so a worst case over the whole set says little: of 185 proposals the median is 0.09 px and 182 are within 1 px, with one proposal of 186 From 29d226694ee195218c900073d3900e26ea7442cb Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 08:29:20 +0900 Subject: [PATCH 30/89] =?UTF-8?q?fix(roi):=20=EB=A0=88=EB=B2=A8=EC=9D=80?= =?UTF-8?q?=20=ED=82=A4=EC=9A=B0=EA=B8=B0=20=EC=A0=84=20=EB=B0=95=EC=8A=A4?= =?UTF-8?q?=EB=A1=9C=20=EA=B3=A0=EB=A5=B8=EB=8B=A4=20+=20=EC=BA=90?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=9D=B4=EB=93=9C=EB=8A=94=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=EB=B3=84=20=EC=A0=90=EC=88=98=EB=A5=BC=20=ED=8F=89?= =?UTF-8?q?=EA=B7=A0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 가지를 고친다. 둘 다 크래시 없이 박스만 밀리는 부류다. 1) roi_align 레벨 배정 (double_heads) mmdet 은 `map_roi_levels` 를 `roi_rescale` **앞**에서 부른다 (single_level_roi_extractor.py:97-100). 즉 레벨은 **키우기 전** 박스로 정하고 샘플링만 키운 박스로 한다. 러너는 키운 박스를 roi_align 에 그대로 넘겨 레벨까지 그걸로 골랐다 — 경계에 있던 상자가 한 레벨 위에서 읽힌다. `roi_align` 에 `level_rois` 를 추가해 갈랐다. 실측(double_heads, fp32): 8.56px → **0.00px**. fp16 에서도 2.19px → 0.16px. ⚠️ 이 버그는 **fp16 잡음이 가리고 있었다.** fp16 으로는 2.19px 라 "임계값을 아슬하게 넘긴 근접 실패" 로 보였는데, fp32 로 재니 8.56px 로 커졌다. 정밀도를 올렸더니 오차가 커지면 그건 잡음이 아니라 **진짜 버그**다. 2) 캐스케이드 점수 평균 mmdet 은 `cascade_roi_head.py:517` 에서 단계별 cls 를 평균한 뒤 디코드한다. 러너는 마지막 단계만 썼다. **평균은 로짓에서 하고 softmax 는 그 뒤 한 번만** 건다(`bbox_head.py:526`) — 순서를 바꾸면 값이 달라진다(softmax 는 선형이 아니다). 박스는 평균하지 않는다(mmdet 도 마지막 단계 것을 쓴다). 실측: cascade_rcnn 607px → 365px, htc 94px → 50px. **아직 통과는 아니다** — fp32 로도 364.81px 라 정밀도와 무관한 격차가 더 있다. Co-Authored-By: Claude Opus 5 (1M context) --- src/visp/postproc.cpp | 8 ++++-- src/visp/postproc.h | 10 +++++++- tools/verify/backbone/run_frcnn.cpp | 40 ++++++++++++++++++++++++----- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/visp/postproc.cpp b/src/visp/postproc.cpp index fda232c..857267d 100755 --- a/src/visp/postproc.cpp +++ b/src/visp/postproc.cpp @@ -512,13 +512,17 @@ static float bilinear_cwhn(float const* feat, int C, int W, int H, int c, float std::vector roi_align( std::vector> const& feats, std::vector> const& feat_hw, - float const* rois, int m, roi_align_params const& p) { + float const* rois, int m, roi_align_params const& p, + float const* level_rois) { int C = p.channels, out = p.output_size, L = (int)feats.size(); std::vector res((size_t)m * C * out * out, 0.0f); for (int i = 0; i < m; ++i) { float const* roi = rois + (size_t)i * 4; - float rw = roi[2] - roi[0], rh = roi[3] - roi[1]; + // ⚠️ 레벨은 **키우기 전 박스**로 고른다(mmdet 은 map_roi_levels 를 roi_rescale 보다 + // 먼저 부른다). 안 그러면 경계 상자가 다른 레벨에서 읽혀 조용히 몇 px 밀린다. + float const* lroi = level_rois ? level_rois + (size_t)i * 4 : roi; + float rw = lroi[2] - lroi[0], rh = lroi[3] - lroi[1]; float scale = std::sqrt(std::max(rw, 0.0f) * std::max(rh, 0.0f)); int lvl = (int)std::floor(std::log2(scale / p.finest_scale + 1e-6f)); if (lvl < 0) lvl = 0; diff --git a/src/visp/postproc.h b/src/visp/postproc.h index 6c4c354..ccf66a7 100755 --- a/src/visp/postproc.h +++ b/src/visp/postproc.h @@ -202,10 +202,18 @@ struct roi_align_params { }; // feats[l] = [C, W, H] CWHN flat · rois[M*4] 이미지좌표. // 반환 roi_feat [M*C*out*out] = NCHW flat(idx=((m*C+c)*out+ph)*out+pw). (SubB 입력용으로 permute 는 러너가) +// +// `level_rois` — **피라미드 레벨을 고를 때만** 쓸 박스(없으면 `rois` 로 고른다). +// ⚠️ Double-Head 처럼 회귀용 RoI 를 키워서 다시 자르는 계열이 있는데, mmdet 은 +// **키우기 전 박스로 레벨을 정하고 키운 박스로 샘플링한다** +// (`single_level_roi_extractor.py:97-100` — `map_roi_levels` 가 `roi_rescale` 보다 먼저다). +// 키운 박스로 레벨까지 고르면 경계에 있던 상자가 한 레벨 위로 올라가 **다른 feature 를 +// 읽는다.** 크래시는 없고 박스만 몇 px 밀린다. std::vector roi_align( std::vector> const& feats, std::vector> const& feat_hw, - float const* rois, int m, roi_align_params const& p); + float const* rois, int m, roi_align_params const& p, + float const* level_rois = nullptr); // ── 전처리: 이미지(HWC u8) → 모델 입력 텐서(CWHN f32) ─────────────────────── // resize(size×size) + normalize((v-mean)/std). BGR/RGB·mean/std 는 인자. diff --git a/tools/verify/backbone/run_frcnn.cpp b/tools/verify/backbone/run_frcnn.cpp index b416163..279571c 100644 --- a/tools/verify/backbone/run_frcnn.cpp +++ b/tools/verify/backbone/run_frcnn.cpp @@ -223,7 +223,11 @@ int main(int argc, char** argv) { auto with_reg_half = [&](std::vector rf, std::vector const& boxes) { if (RSF <= 0.0f) return rf; std::vector bx = rescale_boxes(boxes); - std::vector rg = roi_align(feats, feat_hw, bx.data(), M, ap); + // ⚠️ **레벨은 키우기 전 박스(`boxes`)로 고른다.** mmdet 은 `map_roi_levels` 를 + // `roi_rescale` **앞**에서 부른다(single_level_roi_extractor.py:97-100). + // 키운 박스로 레벨까지 고르면 경계 상자가 한 레벨 위에서 읽혀 조용히 밀린다 + // (실측: double_heads fp32 8.56px — fp16 잡음이 이걸 가리고 있었다). + std::vector rg = roi_align(feats, feat_hw, bx.data(), M, ap, boxes.data()); rf.insert(rf.end(), rg.begin(), rg.end()); return rf; }; @@ -313,21 +317,45 @@ int main(int argc, char** argv) { } } - // ── 최종 디코드: 마지막 단계의 cls/box → 박스 ─────────────────────────── + // ── 최종 디코드: cls/box → 박스 ───────────────────────────────────────── // `detect_roi` 는 **softmax 를 마친** 점수를 기대한다(postproc.h). SubB 가 내는 것은 // 로짓이므로(실측: 한 행의 합이 -0.41) 여기서 건다. 안 걸면 크래시 없이 점수만 틀린다. + // + // ⚠️ **캐스케이드는 마지막 단계만 쓰면 안 된다.** mmdet 은 전 단계의 cls 를 모아 + // 평균한 뒤 디코드한다(`cascade_roi_head.py:517` + // `sum([score[i] for score in ms_scores]) / float(len(ms_scores))`). + // 마지막 단계만 쓰면 크래시 없이 점수가 틀리고, 점수가 틀리면 NMS 가 남기는 집합이 + // 달라져 박스까지 어긋난다(실측: cascade_rcnn 607px). + // + // ⚠️ **평균은 로짓에서 한다. softmax 를 먼저 걸면 안 된다.** mmdet 이 `ms_scores` 에 + // 담는 것은 `_bbox_forward` 의 **날 cls_score** 이고, softmax 는 그 평균 뒤 + // `bbox_head.py:526` 에서 한 번만 걸린다. 순서를 바꾸면 값이 달라진다 + // (softmax 는 선형이 아니다 — mean∘softmax ≠ softmax∘mean). + // + // 박스는 평균하지 않는다. mmdet 도 `bbox_preds` 는 마지막 단계 것을 그대로 쓴다. std::vector dets; if (!cls_st.empty() && !box_st.empty()) { const int last = (int)cls_st.size() - 1; const int NCLS = (int)(cls_st[last].size() / M) - 1; // 배경 제외 + // 캐스케이드 단계들만 평균한다. 다중 인스턴스(CrowdDet)는 단계가 아니라 **쌍**이라 + // 평균 대상이 아니다 — 위에서 (NS>1 && NPAIR>1) 을 막아 뒀으므로 여기서는 + // `NS > 1` 일 때만 여러 원소가 단계를 뜻한다. + const int NAVG = (NS > 1) ? (int)cls_st.size() : 1; std::vector prob(cls_st[last].size()); for (int i = 0; i < M; ++i) { - float const* row = cls_st[last].data() + (size_t)i * (NCLS + 1); float* dst = prob.data() + (size_t)i * (NCLS + 1); - float mx = row[0]; - for (int c = 1; c <= NCLS; ++c) mx = std::max(mx, row[c]); + // ① 단계별 로짓 평균 + for (int c = 0; c <= NCLS; ++c) { + float acc = 0.0f; + for (int s = 0; s < NAVG; ++s) + acc += cls_st[(NAVG == 1) ? last : s][(size_t)i * (NCLS + 1) + c]; + dst[c] = acc / (float)NAVG; + } + // ② 그 다음에 softmax + float mx = dst[0]; + for (int c = 1; c <= NCLS; ++c) mx = std::max(mx, dst[c]); float sum = 0.0f; - for (int c = 0; c <= NCLS; ++c) { dst[c] = std::exp(row[c] - mx); sum += dst[c]; } + for (int c = 0; c <= NCLS; ++c) { dst[c] = std::exp(dst[c] - mx); sum += dst[c]; } for (int c = 0; c <= NCLS; ++c) dst[c] /= sum; } roi_params rp2; From 23e6561f25bba90a73ebbcd6ba0e599554cc2987 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 08:49:35 +0900 Subject: [PATCH 31/89] =?UTF-8?q?fix(frcnn):=20=EC=A4=91=EC=B2=A9=20JSON?= =?UTF-8?q?=20=EB=B0=B0=EC=97=B4=EC=9D=84=20=EC=B2=AB=20=EC=95=88=EC=AA=BD?= =?UTF-8?q?=20=EB=8C=80=EA=B4=84=ED=98=B8=EC=97=90=EC=84=9C=20=EB=81=8A?= =?UTF-8?q?=EC=96=B4=20=EC=9D=BD=EA=B3=A0=20=EC=9E=88=EC=97=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tiny_json::arr` 이 닫는 대괄호를 `find(']')` 로 찾았다. `stage_stds` 는 단계마다 4개씩 담은 `[[...],[...],[...]]` 꼴이라 **첫 안쪽 배열 끝**에서 멈춰 4개만 읽혔다. 그러면 `st_stds.size() >= (st+1)*4` 가드가 0단계만 통과한다. 즉 **2단계부터 bbox 정규화 상수가 조용히 안 걸린다.** 크래시도 경고도 없다 — 가드가 "없으면 건너뛴다" 라 없는 것과 못 읽은 것이 구분되지 않았다. 실측이 정확히 그 모양이었다(cascade_rcnn, C++ proposal 을 torch 에 먹여 단계별 대조): stage0 cls 9.7e-04 · box 1.2e-03 ← 0→1 정제는 맞다(stds[0..3] 을 읽었다) stage1 cls 1.3e-03 · box 4.0e-03 stage2 cls 4.9e-01 · box 1.7e+00 ← 1→2 정제에서 stds 가 안 걸렸다 괄호 깊이를 세어 짝이 맞는 곳까지 읽는다. cascade_rcnn 607px → 365px(점수평균) → **0.09px PASS** htc·detectors·scnet 은 같이 좋아졌지만(56→29.8 · 75→28.9, 개수는 이제 맞는다) 아직 통과가 아니다 — 마스크·시맨틱 갈래가 더 있다. 같이: 단계간 정제에도 mmdet 과 같은 `wh_ratio_clip` 클램프(±|log(16/1000)|)를 넣었다. 라이브러리 `delta2bbox` 는 이미 하고 있었고 러너의 인라인 정제만 빠져 있었다. **이번 케이스에서는 수치가 안 바뀌었다**(델타가 이미 범위 안이었다) — 정합성 목적이다. Co-Authored-By: Claude Opus 5 (1M context) --- tools/verify/backbone/run_frcnn.cpp | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tools/verify/backbone/run_frcnn.cpp b/tools/verify/backbone/run_frcnn.cpp index 279571c..3e137c1 100644 --- a/tools/verify/backbone/run_frcnn.cpp +++ b/tools/verify/backbone/run_frcnn.cpp @@ -73,11 +73,23 @@ struct tiny_json { if (s.find("false", p) == p || s.find("false", p + 1) == p + 1) return 0.0f; return strtof(s.c_str() + p, nullptr); } + // ⚠️ **중첩 배열을 평탄화해서 읽는다.** `stage_stds` 는 단계마다 4개씩 담은 + // `[[...],[...],[...]]` 꼴이다. 닫는 대괄호를 `find(']')` 로 찾으면 **첫 안쪽 배열 + // 끝**에서 멈춰 4개만 읽힌다. 그러면 `st_stds.size() >= (st+1)*4` 가 st=0 만 + // 통과하고, **2단계부터는 stds 가 조용히 안 걸린다**(캐스케이드 실측: 0→1 정제는 + // 맞고 1→2 만 틀렸다 — 그래서 마지막 단계 텐서가 상대 L1 1.685 로 깨졌다). + // 괄호 깊이를 세어 **짝이 맞는** 닫는 괄호까지 읽는다. std::vector arr(const std::string& k) const { std::vector out; size_t p = find_key(k); if (p == std::string::npos) return out; - size_t l = s.find('[', p), r = s.find(']', l); + size_t l = s.find('[', p); + if (l == std::string::npos) return out; + size_t r = l; + for (int depth = 0; r < s.size(); ++r) { + if (s[r] == '[') ++depth; + else if (s[r] == ']' && --depth == 0) break; + } const char* c = s.c_str() + l + 1; while (c < s.c_str() + r) { char* e = nullptr; @@ -303,6 +315,12 @@ int main(int argc, char** argv) { if (has_s) d[k] *= st_stds[(size_t)st * 4 + k]; if (has_m) d[k] += st_means[(size_t)st * 4 + k]; } + // ⚠️ mmdet 은 dw/dh 를 **±|log(wh_ratio_clip)|** 로 자른다 + // (delta_xywh_bbox_coder.py:345-350, 기본 16/1000 → 4.135). + // 안 자르면 `exp(dw)` 가 폭주해 박스가 수백 px 로 튄다. + const float MAXR = 4.13516655f; // |log(16/1000)| + d[2] = std::min(std::max(d[2], -MAXR), MAXR); + d[3] = std::min(std::max(d[3], -MAXR), MAXR); const float x1 = rois[(size_t)i * 4 + 0], y1 = rois[(size_t)i * 4 + 1]; const float x2 = rois[(size_t)i * 4 + 2], y2 = rois[(size_t)i * 4 + 3]; const float pw = x2 - x1, ph = y2 - y1; From 55819be2fa7d4b7d35360e6bcb657d148051556b Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 09:03:37 +0900 Subject: [PATCH 32/89] =?UTF-8?q?feat(verify):=20--skip-pass=20=EB=A1=9C?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=20=ED=86=B5=EA=B3=BC=ED=95=9C=20=EA=B3=84?= =?UTF-8?q?=EC=97=B4=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20=EA=B5=BD=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계열 하나가 40~90초이고 그중 42% 가 g2c 컴파일이다. 40계열 전체 스윕은 35분인데 대개 고친 것은 두세 계열뿐이라 나머지는 같은 답을 다시 계산하는 데 30분을 쓴다. 건너뛴 계열은 **출력에 찍는다** — 조용히 줄이면 다음 사람이 '전부 쟀다' 로 읽는다. Co-Authored-By: Claude Opus 5 (1M context) --- tools/verify/roi/verify_postproc_roi.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tools/verify/roi/verify_postproc_roi.py b/tools/verify/roi/verify_postproc_roi.py index c5262ab..af4547f 100644 --- a/tools/verify/roi/verify_postproc_roi.py +++ b/tools/verify/roi/verify_postproc_roi.py @@ -301,6 +301,12 @@ def main(): ap.add_argument("--image", default=os.path.join(V, "tests", "input", "cat-and-hat.jpg")) ap.add_argument("--workdir", default="/tmp/visp-postproc-roi") ap.add_argument("--keep", action="store_true", help="중간 산출물(.pt·gguf·러너)을 남긴다") + # ⚠️ **이미 통과한 계열을 다시 굽지 마라.** 계열 하나가 40~90초이고 그중 42% 가 g2c + # 컴파일이다. 40계열 전체 스윕은 35분인데, 대개 고친 것은 두세 계열뿐이라 + # 나머지는 같은 답을 다시 계산하는 데 30분을 쓴다. + # 수정이 특정 조건에서만 도는 코드면(예: `NS>1`·`RSF>0`) 영향 계열만 골라 재라. + ap.add_argument("--skip-pass", metavar="results.json", + help="이전 결과에서 PASS 였던 계열은 건너뛴다") ap.add_argument("-v", "--verbose", action="store_true") a = ap.parse_args() @@ -308,6 +314,12 @@ def main(): if not fams: print(__doc__) return 2 + if a.skip_pass: + prev = {r[0]: r[1] for r in json.load(open(a.skip_pass))} + done = [f for f in fams if prev.get(f) == "PASS"] + fams = [f for f in fams if prev.get(f) != "PASS"] + # 건너뛴 것을 **말한다.** 조용히 줄이면 다음 사람이 "전부 쟀다" 로 읽는다. + print(f"이전 PASS {len(done)}계열 건너뜀: {' '.join(done)}\n") os.makedirs(a.workdir, exist_ok=True) print(f"size={a.size} · image={os.path.basename(a.image)} · thr={THR}" f" · 판정: 박스<{BOX_TOL}px 점수<{SCORE_TOL} 라벨0 개수차0") From 61ab60149525d5d5f9ae5ccdc0d15b36e21db948 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 09:04:30 +0900 Subject: [PATCH 33/89] =?UTF-8?q?docs:=20two-stage=20=EC=8B=A4=EC=B8=A1?= =?UTF-8?q?=ED=91=9C=EB=A5=BC=2018=EA=B3=84=EC=97=B4=EB=A1=9C=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=ED=95=98=EA=B3=A0=20=EC=8B=A4=ED=8C=A8=EB=A5=BC=20?= =?UTF-8?q?=EB=8B=A4=EC=84=AF=20=EA=B0=88=EB=9E=98=EB=A1=9C=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EA=B0=80=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gn·gn+ws(GroupNorm affine)·cascade_rcnn(중첩 JSON stds) 이 열려 15 → 18. 실패 22건을 다섯으로 가른다. 새로 갈라진 것 둘: - **fp16 가중치 한계**(dynamic_rcnn·pafpn·res2net) — fp32 로 구우면 셋 다 0.00px. 버그가 아니라 배포 정밀도다. fp32 숫자로 대체하지 말 것. - **연산이 빠졌거나 근사됨**(carafe=pixel_shuffle 항등 통과 · libra_rcnn=비정수 adaptive_max_pool 근사). 둘 다 생성 .cpp 에 TODO 로 자백돼 있다. Co-Authored-By: Claude Opus 5 (1M context) --- docs/mmdet-detectors.md | 57 ++++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index fdfaba3..71ca8c6 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -322,7 +322,7 @@ no label mismatch and no difference in how many boxes survive. Two-stage families are measured separately, at 800 and against the detector's own `predict` rather than a head's `predict_by_feat`, because the boxes do not exist until RPN proposals, RoIAlign and the RoI head have run. The harness is `tools/verify/roi/verify_postproc_roi.py` -and the thresholds are the same. Fifteen of the thirty-four families with a `roi_head` agree: +and the thresholds are the same. Eighteen of the forty families with a `roi_head` agree: | Family | Decoder | Worst box | Worst score | | :--- | :--- | ---: | ---: | @@ -330,6 +330,9 @@ and the thresholds are the same. Fifteen of the thirty-four families with a `roi | `dcnv2` | `detect_roi` | 0.05 px | 0.0006 | | `hrnet` | `detect_roi` | 0.06 px | 0.0002 | | `mask_rcnn` | `detect_roi` | 0.06 px | 0.0009 | +| `gn+ws` | `detect_roi` | 0.08 px | 0.0008 | +| `cascade_rcnn` | `detect_roi` (3 stages) | 0.09 px | 0.0025 | +| `gn` | `detect_roi` | 0.09 px | 0.0003 | | `empirical_attention` | `detect_roi` | 0.09 px | 0.0003 | | `faster_rcnn` | `detect_roi` | 0.10 px | 0.0008 | | `resnest` | `detect_roi` | 0.12 px | 0.0002 | @@ -346,7 +349,7 @@ and the thresholds are the same. Fifteen of the thirty-four families with a `roi below P5, where 800 stops dividing evenly — a 25-wide map meets a 26-wide one and the export aborts. That is a property of the resolution, not of the family. -The nineteen that do not agree split four ways, and the split matters more than the count: +The twenty-two that do not agree split five ways, and the split matters more than the count: - **The RPN is not a standard anchor RPN**, so host `rpn_proposals` cannot lay down the priors: `cascade_rpn` refines across stages, `guided_anchoring` predicts anchor shapes, and @@ -354,36 +357,32 @@ The nineteen that do not agree split four ways, and the split matters more than reason — `GenericRoIExtractor` aggregates every level through per-level convolutions, which host RoIAlign cannot express. All five stop at export with a stated reason rather than a wrong number. -- **Cascade heads decode from the last stage only** — `cascade_rcnn`, `htc`, `detectors`, - `scnet`. The runner does walk all three stages and refines boxes between them, but - MMDetection averages the classification scores over stages before the final decode, so the - scores diverge and the boxes follow. This is a decode-convention gap, not missing plumbing. +- **FP16 weights, not a defect.** `dynamic_rcnn` (10.24 px), `pafpn` (8.77 px) and `res2net` + (6.81 px) return the right count and the right labels with the coordinates several pixels + out. Recompiling with fp32 weights makes all three exact at **0.00 px**, so the gap is the + half-precision the compiler deliberately stores — the NPU this targets is FP16-native. + Isolating it took swapping one tensor at a time: substituting the C++ RPN *class* scores + into torch changed nothing, while substituting the *box deltas* reproduced the full 10.25 px + from a maximum delta error of 0.0023. Do not replace these numbers with their fp32 twins; + they are what the deployment precision produces. +- **Cascade stages beyond the first are still incomplete**: `htc` (94 px), `detectors` (30 px) + and `scnet` (29 px). `cascade_rcnn` itself now agrees at 0.09 px, so the shared three-stage + path is right; what remains is each family's mask or semantic branch. - **The family post-processes its own way**: `crowddet` predicts two instances per proposal and needs set-NMS (without it nothing is suppressed — 500 boxes against 1), `ms_rcnn` rescales - scores by a predicted mask IoU (its boxes are exact at 0.07 px; only the scores differ), and + scores by a predicted mask IoU (its boxes are exact at 0.07 px; only the scores differ), `grid_rcnn` turns off box regression on the bbox head entirely and regresses in a grid head, - so there is no `bbox_pred` to decode. -- **Something before the decoder disagrees**, the same category as `dyhead` above: `carafe`, - `pafpn`, `libra_rcnn`, `dynamic_rcnn`, `res2net` and `gcnet` return the right *number* of - boxes with the right labels but coordinates 6–37 px out. The shared symptom is tempting to - read as a shared cause; it is not one. Only three replace the neck (`FPN_CARAFE`, `PAFPN`, - and the `FPN`+`BFP` pair). `res2net` and `gcnet` run a plain FPN and differ in the backbone - (hierarchical scales, `ContextBlock` plugins). `dynamic_rcnn` differs in neither: plain - ResNet, plain FPN, `Shared2FCBBoxHead`, the same RCNN test config as `faster_rcnn`, which - passes at 0.10 px. Its one difference is an RPN NMS threshold of 0.85 against 0.7, which - keeps far more overlapping candidates and makes the proposal set correspondingly sensitive - to small score differences — so it is the family to diagnose first, and what it turns up - applies to the fifteen that already pass. - - `gn` and `gn+ws` are the sharper version of the same point — they use - `Shared4Conv1FCBBoxHead`, but so does `resnest`, which passes at 0.12 px, so the head layout - is exonerated and GroupNorm/weight-standardisation is what is left. - -`seesaw_loss` belongs with the third group rather than here: it classifies through a -`NormedLinear` layer at temperature 20 over 1203 LVIS classes with a score threshold of -0.0001, so its scores are not computed the way `detect_roi` assumes. `swin` (the SubA gguf -fails to load) and `tridentnet` (the runner returns no boxes at all) are the two that remain -unsorted. + and `seesaw_loss` classifies through a `NormedLinear` layer at temperature 20 over 1203 LVIS + classes. +- **An operator is missing or approximated in the generated graph.** `carafe` (29 px) renders + `pixel_shuffle` as a pass-through identity, so CARAFE's upsampling is skipped outright. + `libra_rcnn` (12 px) approximates the non-integer `adaptive_max_pool2d` that BFP uses to + scatter back to P6 (50 → 13, variable 3–4 wide windows) with a fixed kernel. Both announce + themselves in the generated `.cpp` as `TODO` comments — grep for those first. `gcnet` (37 px) + is in this group by elimination; its `ContextBlock` emits no TODO and has not been isolated. + +`swin` (the SubA gguf fails to load) and `tridentnet` (the runner returns no boxes at all) are +the two that remain unsorted. `rpn` decodes proposals rather than detections, so a worst case over the whole set says little: of 185 proposals the median is 0.09 px and 182 are within 1 px, with one proposal of 186 From 431455c515b668cc783a77bd0f78a90786e86b82 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 09:15:21 +0900 Subject: [PATCH 34/89] =?UTF-8?q?docs:=20two-stage=2020=EA=B3=84=EC=97=B4?= =?UTF-8?q?=20(carafe=C2=B7libra=5Frcnn=20=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mmdet-detectors.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 71ca8c6..e6f7c48 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -322,12 +322,13 @@ no label mismatch and no difference in how many boxes survive. Two-stage families are measured separately, at 800 and against the detector's own `predict` rather than a head's `predict_by_feat`, because the boxes do not exist until RPN proposals, RoIAlign and the RoI head have run. The harness is `tools/verify/roi/verify_postproc_roi.py` -and the thresholds are the same. Eighteen of the forty families with a `roi_head` agree: +and the thresholds are the same. Twenty of the forty families with a `roi_head` agree: | Family | Decoder | Worst box | Worst score | | :--- | :--- | ---: | ---: | | `panoptic_fpn` | `detect_roi` | 0.04 px | 0.0001 | | `dcnv2` | `detect_roi` | 0.05 px | 0.0006 | +| `carafe` | `detect_roi` | 0.06 px | 0.0007 | | `hrnet` | `detect_roi` | 0.06 px | 0.0002 | | `mask_rcnn` | `detect_roi` | 0.06 px | 0.0009 | | `gn+ws` | `detect_roi` | 0.08 px | 0.0008 | @@ -335,6 +336,7 @@ and the thresholds are the same. Eighteen of the forty families with a `roi_head | `gn` | `detect_roi` | 0.09 px | 0.0003 | | `empirical_attention` | `detect_roi` | 0.09 px | 0.0003 | | `faster_rcnn` | `detect_roi` | 0.10 px | 0.0008 | +| `libra_rcnn` | `detect_roi` | 0.11 px | 0.0007 | | `resnest` | `detect_roi` | 0.12 px | 0.0002 | | `albu_example` | `detect_roi` | 0.14 px | 0.0008 | | `point_rend` | `detect_roi` | 0.15 px | 0.0012 | @@ -349,7 +351,7 @@ and the thresholds are the same. Eighteen of the forty families with a `roi_head below P5, where 800 stops dividing evenly — a 25-wide map meets a 26-wide one and the export aborts. That is a property of the resolution, not of the family. -The twenty-two that do not agree split five ways, and the split matters more than the count: +The twenty that do not agree split five ways, and the split matters more than the count: - **The RPN is not a standard anchor RPN**, so host `rpn_proposals` cannot lay down the priors: `cascade_rpn` refines across stages, `guided_anchoring` predicts anchor shapes, and @@ -374,12 +376,15 @@ The twenty-two that do not agree split five ways, and the split matters more tha `grid_rcnn` turns off box regression on the bbox head entirely and regresses in a grid head, and `seesaw_loss` classifies through a `NormedLinear` layer at temperature 20 over 1203 LVIS classes. -- **An operator is missing or approximated in the generated graph.** `carafe` (29 px) renders - `pixel_shuffle` as a pass-through identity, so CARAFE's upsampling is skipped outright. - `libra_rcnn` (12 px) approximates the non-integer `adaptive_max_pool2d` that BFP uses to - scatter back to P6 (50 → 13, variable 3–4 wide windows) with a fixed kernel. Both announce - themselves in the generated `.cpp` as `TODO` comments — grep for those first. `gcnet` (37 px) - is in this group by elimination; its `ContextBlock` emits no TODO and has not been isolated. +- **An operator is missing or approximated in the generated graph.** `gcnet` (37 px) is the one + left here, and it is here by elimination: its `ContextBlock` emits no `TODO` and has not been + isolated. The two that were in this group are now fixed — `carafe` rendered `pixel_shuffle` + as a pass-through identity, skipping CARAFE's upsampling outright (29 px → 0.06 px), and + `libra_rcnn` approximated the non-integer `adaptive_max_pool2d` that BFP uses to scatter back + to P6 with a fixed kernel (12 px → 0.11 px). Both announced themselves in the generated + `.cpp` as `TODO` comments, so grep for those before reading anything else: a renderer that + cannot express an operation still emits shape-correct code, which passes compilation and + every shape assertion while returning wrong values. `swin` (the SubA gguf fails to load) and `tridentnet` (the runner returns no boxes at all) are the two that remain unsorted. From 92e3e9a490a1c517c75f4f87edad9ac4f8ac38a3 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 09:40:37 +0900 Subject: [PATCH 35/89] =?UTF-8?q?fix(verify):=20one-stage=20=ED=95=98?= =?UTF-8?q?=EB=84=A4=EC=8A=A4=EA=B0=80=20=EC=A0=84=20=EA=B3=84=EC=97=B4?= =?UTF-8?q?=EC=9D=84=20WEIGHTS=5FFAIL=20=EB=A1=9C=20=EB=96=A8=EC=96=B4?= =?UTF-8?q?=EB=9C=A8=EB=A6=AC=EA=B3=A0=20=EC=9E=88=EC=97=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 개가 겹쳐 있었다. 둘 다 **계열 탓이 아니라 하네스 탓**이다. 1) 성공 판정을 **출력 문구 매칭**으로 하고 있었다. `verify_heads.py` 가 stdout 에서 `"추가"` 를 찾는데 `append_head_weights.py` 는 영어로 `"appended N weights…"` 를 찍는다. 문구가 바뀐 걸 아무도 못 봐서 **이 브랜치의 one-stage 계열이 전부 WEIGHTS_FAIL 로 나오고 있었다.** 종료코드로 판정한다. 2) `GGUFWriter(path, arch=...)` 가 이미 쓰는 `general.architecture` 를 `add_architecture()` 가 또 썼다. 최신 gguf-py 가 stderr 로 `Duplicated key name` 을 뱉고, 그 줄이 위 하네스의 "실패 이유" 로 잡혀 원인을 오도했다. 산출물 자체는 멀쩡했다. 고치고 재니 GN 쓰는 5계열이 전부 텐서 통과했다: ddq 5.2e-04 · deformable_detr 1.0e-03 · dino 1.5e-02 · nas_fcos 1.4e-03 · reppoints 4.3e-04 Co-Authored-By: Claude Opus 5 (1M context) --- tools/frontend/mmdet/append_head_weights.py | 5 ++++- tools/verify/dense_head/verify_heads.py | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tools/frontend/mmdet/append_head_weights.py b/tools/frontend/mmdet/append_head_weights.py index b764794..a370010 100644 --- a/tools/frontend/mmdet/append_head_weights.py +++ b/tools/frontend/mmdet/append_head_weights.py @@ -96,8 +96,11 @@ def append(pt_path: str, gguf_path: str, prefixes=None) -> int: return 0 tmp = gguf_path + ".tmp" + # ⚠️ `GGUFWriter(path, arch=...)` 가 이미 `general.architecture` 를 쓴다. 여기서 + # `add_architecture()` 를 또 부르면 최신 gguf-py 가 stderr 로 + # `Duplicated key name 'general.architecture'` 를 뱉는다. 값이 같아 산출물은 멀쩡한데 + # 그 줄이 상위 하네스의 "실패 이유" 로 잡혀 원인을 오도한다. writer = gguf.GGUFWriter(tmp, arch=arch) - writer.add_architecture() for name, arr in existing + extra: writer.add_tensor(name, arr) writer.write_header_to_file() diff --git a/tools/verify/dense_head/verify_heads.py b/tools/verify/dense_head/verify_heads.py index 0fa29a5..783a4ae 100644 --- a/tools/verify/dense_head/verify_heads.py +++ b/tools/verify/dense_head/verify_heads.py @@ -574,7 +574,11 @@ def one(fam, rel, ckpt): r = run([PY, FE + "/append_head_weights.py", os.path.join(d, "bb.pt"), os.path.join(d, "out", "Fam.gguf")], d, {"PYTHONPATH": f"{d}:{FE}"}, phase="2b_weights") - if "추가" not in (r.stdout or ""): + # ⚠️ **성공 판정을 출력 문구로 하지 마라.** 여기서 `"추가"` 를 찾고 있었는데 스크립트는 + # 영어로 `"appended N weights…"` 를 찍는다. 문구가 바뀐 걸 아무도 못 봐서 **one-stage + # 계열 전부가 WEIGHTS_FAIL 로 떨어지고 있었다** — 계열 탓처럼 보이지만 하네스 탓이다. + # 종료코드로 판정하고, 문구는 참고로만 쓴다. + if r.returncode != 0: return fam, "WEIGHTS_FAIL", _last_error(r.stderr)[:70] # 3) run_mmdet 빌드 (백본 .cpp + head.cpp 를 함께 컴파일) From 21f34f6a24fe4ecf5a77795a29495288944ce0dc Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 10:04:02 +0900 Subject: [PATCH 36/89] =?UTF-8?q?docs:=20one-stage=20=EC=8B=A4=EC=B8=A1?= =?UTF-8?q?=ED=91=9C=20=EC=9E=AC=EC=B8=A1=EC=A0=95=EB=B3=B8=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20(nas=5Ffcos=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=C2=B7=20pvt=20=EC=A0=9C=EC=99=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 31계열을 한 번에 다시 쟀다. 기록 17 중 16이 재현되고 pvt 만 1.93px → 15.74px 로 어긋난다(원래도 임계값 2.0px 을 겨우 넘긴 경계값이었다). nas_fcos 0.19px 추가. ⚠️ **config↔체크포인트 짝을 하네스와 같게 맞춰야 한다.** verify_heads 는 손으로 고른 목록을 metafile 보다 우선하는데, 대조할 때 metafile 로 다시 고르면 **컴파일한 가중치와 다른 체크포인트**로 재게 된다. 13계열이 그렇게 어긋나 retinanet 이 20px, dino 가 회귀한 것처럼 보였다 — 둘 다 짝을 맞추니 0.27px·0.28px 로 정상이다. Co-Authored-By: Claude Opus 5 (1M context) --- docs/mmdet-detectors.md | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index e6f7c48..2d37c92 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -295,29 +295,40 @@ shape of the tower that produced it. YOLOX and RPN build the same tower as Retin nothing like it. Each row below was measured against MMDetection's own `predict_by_feat` on the same pixels at -512 (`cat-and-hat.jpg`), with the trained checkpoint the family's `metafile.yml` names. +512 (`cat-and-hat.jpg`), with the trained checkpoint the harness pairs with that config. The harness is `tools/verify/dense_head/verify_postproc.py`; it passes at 2 px, 0.05 score, no label mismatch and no difference in how many boxes survive. | Family | Decoder | Worst box | Worst score | | :--- | :--- | ---: | ---: | -| `retinanet` | `detect_anchor` | 0.15 px | 0.006 | | `atss` | `detect_anchor` | 0.14 px | 0.000 | +| `efficientnet` | `detect_anchor` | 0.15 px | 0.005 | +| `pisa` | `detect_anchor` | 0.18 px | 0.005 | +| `retinanet` | `detect_anchor` | 0.27 px | 0.000 | | `ddod` | `detect_anchor` | 0.41 px | 0.001 | | `ghm` | `detect_anchor` | 0.46 px | 0.005 | -| `pisa` | `detect_anchor` | 0.18 px | 0.005 | -| `pvt` | `detect_anchor` | 1.93 px | 0.003 | -| `efficientnet` | `detect_anchor` | 0.15 px | 0.005 | -| `nas_fpn` | `detect_anchor` | 0.34 px | 0.004 | -| `fcos` | `detect_fcos` | 0.13 px | 0.000 | +| `nas_fpn` | `detect_anchor` | 1.04 px | 0.004 | +| `nas_fcos` | `detect_fcos` | 0.19 px | 0.001 | | `gfl` | `detect_fcos` | 0.23 px | 0.004 | +| `fcos` | `detect_fcos` | 0.32 px | 0.004 | | `vfnet` | `detect_fcos` | 0.46 px | 0.003 | -| `rtmdet` | `detect_fcos` | 0.66 px | 0.007 | +| `rtmdet` | `detect_fcos` | 0.68 px | 0.002 | | `yolox` | `detect_yolox` | 0.41 px | 0.002 | -| `detr` | `detect_detr` | 1.85 px | 0.044 | | `conditional_detr` | `detect_detr` | 0.27 px | 0.002 | | `dab_detr` | `detect_detr` | 0.28 px | 0.001 | -| `dino` | `detect_detr` | 0.41 px | 0.030 | +| `dino` | `detect_detr` | 0.28 px | 0.004 | +| `detr` | `detect_detr` | 1.85 px | 0.044 | + +Every row above was re-measured together in one run, so the numbers are comparable with each +other. That matters more than it sounds: the harness picks a representative config per family +from a hand-written override list, and comparing against the config `metafile.yml` would have +chosen instead silently pairs a compiled graph with someone else's checkpoint. Thirteen +families differ between those two choices, and the mismatch reads as a decode failure — +`retinanet` looked 20 px out and `dino` looked like a regression until the pairing was fixed. + +`pvt` was in this table at 1.93 px and is no longer here: re-measuring puts it at 15.74 px +with three boxes too many. Its old number sat just under the 2 px threshold, so it was always +a marginal pass. The cause has not been established. Two-stage families are measured separately, at 800 and against the detector's own `predict` rather than a head's `predict_by_feat`, because the boxes do not exist until RPN proposals, From 7489ee4dbac408f10bf190e465b1a4f47830e244 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 10:19:35 +0900 Subject: [PATCH 37/89] =?UTF-8?q?docs:=20pvt(PVT-Tiny)=200.55px=20=C2=B7?= =?UTF-8?q?=20ld=200.30px=20=EC=B6=94=EA=B0=80=20=E2=80=94=20one-stage=201?= =?UTF-8?q?9=EA=B3=84=EC=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pvt 는 회귀가 아니었다. metafile 대표는 PVTv2-B5(다른 아키텍처)이고 표가 쭉 써 온 것은 PVT-Tiny 다. 변종 이름을 안 적으면 같은 계열이 서로 다른 답을 낸다. ld 는 증류 config 가 teacher 를 CWD 상대경로로 적어 실행 위치가 문제였다. --- docs/mmdet-detectors.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 2d37c92..f13398f 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -308,11 +308,13 @@ no label mismatch and no difference in how many boxes survive. | `ddod` | `detect_anchor` | 0.41 px | 0.001 | | `ghm` | `detect_anchor` | 0.46 px | 0.005 | | `nas_fpn` | `detect_anchor` | 1.04 px | 0.004 | +| `pvt` | `detect_anchor` (PVT-Tiny) | 0.55 px | 0.005 | | `nas_fcos` | `detect_fcos` | 0.19 px | 0.001 | | `gfl` | `detect_fcos` | 0.23 px | 0.004 | | `fcos` | `detect_fcos` | 0.32 px | 0.004 | | `vfnet` | `detect_fcos` | 0.46 px | 0.003 | | `rtmdet` | `detect_fcos` | 0.68 px | 0.002 | +| `ld` | `detect_fcos` | 0.30 px | 0.006 | | `yolox` | `detect_yolox` | 0.41 px | 0.002 | | `conditional_detr` | `detect_detr` | 0.27 px | 0.002 | | `dab_detr` | `detect_detr` | 0.28 px | 0.001 | @@ -326,9 +328,15 @@ chosen instead silently pairs a compiled graph with someone else's checkpoint. T families differ between those two choices, and the mismatch reads as a decode failure — `retinanet` looked 20 px out and `dino` looked like a regression until the pairing was fixed. -`pvt` was in this table at 1.93 px and is no longer here: re-measuring puts it at 15.74 px -with three boxes too many. Its old number sat just under the 2 px threshold, so it was always -a marginal pass. The cause has not been established. +`pvt` is measured on PVT-Tiny, the variant the table has always used. Selecting the family's +representative config from `metafile.yml` instead picks PVTv2-B5, a different architecture +(overlapping patch embedding, linear spatial reduction), which lands at 15.74 px and is not +covered. Two variants of one family can disagree completely; naming the variant is not +optional. + +`ld` needs its command run from the MMDetection root. Distillation configs name the teacher as +`teacher_config='configs/gfl/...'`, relative to the working directory rather than to the config +file, so running from anywhere else fails to find it and the family looks broken. Two-stage families are measured separately, at 800 and against the detector's own `predict` rather than a head's `predict_by_feat`, because the boxes do not exist until RPN proposals, From 3ff0e40354b3d47ab49ed3f8848437d79f988f9d Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 10:26:43 +0900 Subject: [PATCH 38/89] =?UTF-8?q?docs:=20gcnet=200.14px=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=E2=80=94=20two-stage=2021=EA=B3=84=EC=97=B4,=20?= =?UTF-8?q?=EB=AF=B8=ED=99=95=EC=9D=B8=20=EC=97=86=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mmdet-detectors.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index f13398f..f89a2dc 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -341,7 +341,7 @@ file, so running from anywhere else fails to find it and the family looks broken Two-stage families are measured separately, at 800 and against the detector's own `predict` rather than a head's `predict_by_feat`, because the boxes do not exist until RPN proposals, RoIAlign and the RoI head have run. The harness is `tools/verify/roi/verify_postproc_roi.py` -and the thresholds are the same. Twenty of the forty families with a `roi_head` agree: +and the thresholds are the same. Twenty-one of the forty families with a `roi_head` agree: | Family | Decoder | Worst box | Worst score | | :--- | :--- | ---: | ---: | @@ -349,6 +349,7 @@ and the thresholds are the same. Twenty of the forty families with a `roi_head` | `dcnv2` | `detect_roi` | 0.05 px | 0.0006 | | `carafe` | `detect_roi` | 0.06 px | 0.0007 | | `hrnet` | `detect_roi` | 0.06 px | 0.0002 | +| `gcnet` | `detect_roi` | 0.14 px | 0.0010 | | `mask_rcnn` | `detect_roi` | 0.06 px | 0.0009 | | `gn+ws` | `detect_roi` | 0.08 px | 0.0008 | | `cascade_rcnn` | `detect_roi` (3 stages) | 0.09 px | 0.0025 | @@ -395,15 +396,21 @@ The twenty that do not agree split five ways, and the split matters more than th `grid_rcnn` turns off box regression on the bbox head entirely and regresses in a grid head, and `seesaw_loss` classifies through a `NormedLinear` layer at temperature 20 over 1203 LVIS classes. -- **An operator is missing or approximated in the generated graph.** `gcnet` (37 px) is the one - left here, and it is here by elimination: its `ContextBlock` emits no `TODO` and has not been - isolated. The two that were in this group are now fixed — `carafe` rendered `pixel_shuffle` - as a pass-through identity, skipping CARAFE's upsampling outright (29 px → 0.06 px), and - `libra_rcnn` approximated the non-integer `adaptive_max_pool2d` that BFP uses to scatter back - to P6 with a fixed kernel (12 px → 0.11 px). Both announced themselves in the generated - `.cpp` as `TODO` comments, so grep for those before reading anything else: a renderer that - cannot express an operation still emits shape-correct code, which passes compilation and - every shape assertion while returning wrong values. +- **An operator was missing, approximated, or silently reduced along the wrong axis.** This + group is now empty, and how each was found is worth keeping. `carafe` rendered + `pixel_shuffle` as a pass-through identity, skipping CARAFE's upsampling outright + (29 px → 0.06 px). `libra_rcnn` approximated with a fixed kernel the non-integer + `adaptive_max_pool2d` that BFP uses to scatter back to P6 (12 px → 0.11 px). Both announced + themselves as `TODO` comments in the generated `.cpp`, so grep for those before reading + anything else. + + `gcnet` (37 px → 0.14 px) had no such marker. `ContextBlock` uses + `nn.LayerNorm([planes, 1, 1])` — three normalised axes — but the renderer always emitted + `ggml_norm`, which reduces `ne0` alone. At that point the tensor is `ne [1, 1, C, N]`, so + `ne0` is 1: normalising a single element gives `x - mean(x) = 0`, and the whole channel + branch collapses to a constant bias. A renderer that cannot express an operation still emits + shape-correct code, which passes compilation and every shape assertion while returning wrong + values. `swin` (the SubA gguf fails to load) and `tridentnet` (the runner returns no boxes at all) are the two that remain unsorted. From 21043460ec226a70c165f103686ac6bdf8469428 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 10:37:54 +0900 Subject: [PATCH 39/89] =?UTF-8?q?feat(detect):=20FoveaBox=20=EB=94=94?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20(212px=20=E2=86=92=200.40px)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FoveaHead` 가 `FCOSHead` 로 조립돼 **그럴듯하게 돌고 값만 틀리고** 있었다. 디코드가 다르다: x1 = x - base_len·exp(pred[0]) (mmdet fovea_head.py:496-507) 두 가지가 어긋나 있었다. - `exp` 가 아예 안 걸렸다(`bbox_exp=false`). - 곱하는 값이 `base_edge_list`(16,32,64,128,256) 인데 stride(8,16,…) 는 그 절반이다. **변환을 조립기가 아니라 디코더에 넣는다.** 이게 핵심이다 — `verify_heads` 는 C++ 조립기 출력을 torch `bbox_head(feats)` **원시 출력**과 대조하는데, `FCOSHead.forward` 는 exp 를 자기가 걸지만 `FoveaHead.forward` 는 **안 건다**(`_bbox_decode` 에서 건다). 조립기에 넣으면 텐서 검증이 깨진다(실측: 조립기 L1 8.7e+02 → 디코더 1.1e-03). 프론트엔드는 이름이 아니라 **구조적 표시**(`base_edge_list` 속성 유무)로 가른다 — 저장소 관례를 따랐다. 비어 있으면 기존 계열은 경로가 그대로다. 실측: foveabox 텐서 1.08e-03 · 박스 **0.40px PASS**. Co-Authored-By: Claude Opus 5 (1M context) --- src/visp/postproc.cpp | 11 ++++++++++- src/visp/postproc.h | 9 +++++++++ tools/frontend/mmdet/mmdet_to_pt.py | 4 ++++ tools/frontend/mmdet/mmdet_wrap.py | 11 +++++++++++ tools/verify/backbone/run_mmdet.cpp | 1 + 5 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/visp/postproc.cpp b/src/visp/postproc.cpp index 857267d..01d1d9b 100755 --- a/src/visp/postproc.cpp +++ b/src/visp/postproc.cpp @@ -249,7 +249,16 @@ std::vector detect_fcos( // bbox_pred 는 조립기(head.cpp)가 이미 scale·relu·exp·×stride 를 적용한 // **최종 픽셀 거리**다 → 여기서 다시 곱하지 않는다. 곱하면 레벨마다 // 8~128배 커진다. - for (int k = 0; k < 4; ++k) dist[k] = box[(size_t)pos * 4 + k]; + // + // 예외는 FoveaBox 뿐이다. `FoveaHead.forward` 는 exp 를 안 걸고 + // `_bbox_decode` 에서 `base_len·exp(pred)` 로 만든다. 조립기가 그걸 하면 + // torch 의 원시 head 출력과 안 맞아 텐서 검증이 깨지므로 **여기서** 한다. + const bool fovea = !p.base_edge.empty(); + const float be = fovea ? (l < (int)p.base_edge.size() ? p.base_edge[l] : 1.0f) : 1.0f; + for (int k = 0; k < 4; ++k) { + float v = box[(size_t)pos * 4 + k]; + dist[k] = fovea ? be * std::exp(v) : v; + } float outb[4]; distance2bbox(pts.data() + (size_t)pos * 2, dist, 1, outb, p.input_w, p.input_h); float sc = std::get<0>(t); diff --git a/src/visp/postproc.h b/src/visp/postproc.h index ccf66a7..9829e06 100755 --- a/src/visp/postproc.h +++ b/src/visp/postproc.h @@ -43,6 +43,8 @@ struct det_params { int nms_pre = 1000; // 레벨별 topk int max_per_img = 100; int input_w = 0, input_h = 0; + // FoveaBox 의 `base_edge_list`(레벨별). 비면 안 쓴다 — `fcos_params::base_edge` 로 넘어간다. + std::vector base_edge; }; // per-level 원시출력: cls_scores[level] = [num_base*num_classes, feat_w, feat_h](CWHN flat), @@ -78,6 +80,13 @@ struct fcos_params { int nms_pre = 1000; int max_per_img = 100; int input_w = 0, input_h = 0; + // FoveaBox 는 거리를 **여기서** 만든다: `dist = base_edge[l] · exp(pred)`. + // ⚠️ 조립기에 넣으면 안 된다 — `FCOSHead.forward` 는 `exp` 를 자기가 걸지만 + // `FoveaHead.forward` 는 **안 건다**(`_bbox_decode` 에서 건다). 조립기에 넣으면 + // 텐서 검증이 torch 의 원시 출력과 안 맞는다(실측 상대 L1 8.7e+02). + // 그리고 곱하는 값은 stride 가 아니라 `base_edge_list` 다 — 기본값이 stride 의 + // 2배(16,32,… vs 8,16,…)라 stride 로 두면 박스가 레벨마다 정확히 절반이 된다. + std::vector base_edge; }; // cls_scores[l]=[nc,W,H]HWC · bbox_preds[l]=[4,W,H]HWC. // bbox_preds 는 조립기가 이미 픽셀 거리로 만든 값이다 — 여기서 stride 를 곱하지 않는다. diff --git a/tools/frontend/mmdet/mmdet_to_pt.py b/tools/frontend/mmdet/mmdet_to_pt.py index 25356c6..85291d6 100755 --- a/tools/frontend/mmdet/mmdet_to_pt.py +++ b/tools/frontend/mmdet/mmdet_to_pt.py @@ -108,6 +108,10 @@ def emit_params(cfg, config_name): " // per-level tensors, and only the anchor(Delta) path fills c.det here.\n", ] out.append(" c.det.strides = {" + ", ".join(_f(v) for v in cfg["strides"]) + "};\n") + # FoveaBox 만 채운다. 비어 있으면 디코더가 조립기가 낸 값을 그대로 거리로 쓴다. + if cfg.get("bbox_base_edge"): + out.append(" c.det.base_edge = {" + + ", ".join(_f(v) for v in cfg["bbox_base_edge"]) + "};\n") # ⚠️ `c.det.num_classes` 는 **배경을 뺀** 클래스 수다. softmax head(고전 DETR)는 # 채널이 하나 더 많으므로(`cls_out_channels = num_classes + 1`), 채널 폭은 # `c.head.num_classes` 를 쓰고 여기엔 의미상 값을 싣는다. 뒤바꾸면 배경을 diff --git a/tools/frontend/mmdet/mmdet_wrap.py b/tools/frontend/mmdet/mmdet_wrap.py index b02a6e9..758d299 100755 --- a/tools/frontend/mmdet/mmdet_wrap.py +++ b/tools/frontend/mmdet/mmdet_wrap.py @@ -452,6 +452,16 @@ def _last_conv(seq): is_autoassign = hasattr(bh, "center_prior") if is_autoassign: bbox_exp, bbox_clamp_stride = False, True + # FoveaBox 는 `x1 = x - base_len·exp(pred)` 로 디코드한다. **stride 가 아니라 + # `base_edge_list`** 를 곱하는데 기본값이 (16,32,64,128,256) 으로 stride 의 2배다. + # 구조적 표시는 `base_edge_list` — 이 계열에만 있는 속성이라 이름으로 안 가른다. + # 안 실으면 FCOSHead 로 조립돼 **그럴듯하게 돌고 값만 틀린다**(실측 212px). + bbox_base_edge = [float(v) for v in (getattr(bh, "base_edge_list", None) or [])] + if bbox_base_edge: + # ⚠️ **조립기는 아무것도 안 한다.** `FCOSHead.forward` 는 exp 를 자기가 걸지만 + # `FoveaHead.forward` 는 안 건다(`_bbox_decode` 에서 건다). 조립기에 넣으면 + # 텐서 검증이 torch 원시 출력과 안 맞는다(실측 L1 8.7e+02 → 3.9). 디코더가 한다. + bbox_exp, bbox_clamp_stride, bbox_mul_stride = False, False, False # ── 전처리(pre) 메타: mmdet data_preprocessor(모델 안 서브모듈)에서 추출 ── # normalize mean/std(픽셀스케일 0-255) + 채널변환. vision.cpp preprocess() 가 소비. @@ -532,6 +542,7 @@ def _tc(key, default): "bbox_exp": bbox_exp, "bbox_clamp_stride": bbox_clamp_stride, "bbox_mul_stride": bbox_mul_stride, + "bbox_base_edge": bbox_base_edge, "head_silu": head_silu, "reg_max": reg_max, # VFNet 의 레벨별 정규화 범위. stride 에서 유도하면 안 된다 — 마지막 레벨만 두 배다. diff --git a/tools/verify/backbone/run_mmdet.cpp b/tools/verify/backbone/run_mmdet.cpp index 70accd6..c8059f6 100755 --- a/tools/verify/backbone/run_mmdet.cpp +++ b/tools/verify/backbone/run_mmdet.cpp @@ -482,6 +482,7 @@ int main(int argc, char** argv) { fp.max_per_img = dp.max_per_img; fp.input_w = dp.input_w; fp.input_h = dp.input_h; + fp.base_edge = dp.base_edge; // FoveaBox 만 채워져 온다 // FCOS 만 MlvlPointGenerator(0.5) 다. 나머지는 AnchorGenerator(center_offset=0). fp.point_offset = hc.kind == head_kind::fcos ? 0.5f : hc.center_offset; dets = detect_fcos(cls_v, box_v, ctr_v, feat_hw, fp); From dcd36ad06474dc2aaf2433b7b9e3aeaf468a1d09 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 10:42:46 +0900 Subject: [PATCH 40/89] =?UTF-8?q?feat(detect):=20YOLOF=20=EB=94=94?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20(=EB=B0=95=EC=8A=A4=200=EA=B1=B4=20?= =?UTF-8?q?=E2=86=92=200.12px)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 박스가 0건이던 것은 **안전 정지**였다. 앵커 파라미터 방출 게이트가 `anchor`/`rpn` 만 허용해서 `head_kind::yolof` 는 `c.det` 이 비었고, 후보가 하나도 안 생겼다. YOLOF 는 조립만 다르고 디코드는 **AnchorGenerator + DeltaXYWHBBoxCoder** 다 (단일 레벨 stride 32, scales 1·2·4·8·16). 게이트에 넣는다. 다만 그 코더가 `add_ctr_clamp=True, ctr_clamp=32` 를 쓴다 — 기본 경로와 **식이 다르다**: add_ctr_clamp: 중심 이동을 **픽셀 단위**로 ±ctr_clamp 로 자르고 dw/dh 는 **상한만** 자름 기본: dw/dh 를 상·하한 모두 자름 (delta_xywh_bbox_coder.py:346-350). `delta2bbox` 에 인자로 넣고 0 이면 기존 경로 그대로다. 실측: yolof 텐서 1.56e-03 · 박스 **0.12px PASS**. 회귀: retinanet·atss·ddod·ghm·pisa·rpn·free_anchor 텐서 전부 통과(ctr_clamp=0 경로). Co-Authored-By: Claude Opus 5 (1M context) --- src/visp/postproc.cpp | 20 +++++++++++++++----- src/visp/postproc.h | 8 +++++++- tools/frontend/mmdet/mmdet_to_pt.py | 5 +++-- tools/frontend/mmdet/mmdet_wrap.py | 4 ++++ 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/visp/postproc.cpp b/src/visp/postproc.cpp index 01d1d9b..a0c0252 100755 --- a/src/visp/postproc.cpp +++ b/src/visp/postproc.cpp @@ -41,7 +41,8 @@ std::vector gen_anchors(int feat_h, int feat_w, float stride, float base_ // ── delta2bbox (mmdet DeltaXYWHBBoxCoder) ──────────────────────────────────── void delta2bbox(float const* anchors, float const* deltas, int n, float* out, - float const means[4], float const stds[4], int max_w, int max_h) { + float const means[4], float const stds[4], int max_w, int max_h, + float ctr_clamp) { float max_ratio = std::fabs(std::log(16.0f / 1000.0f)); for (int i = 0; i < n; ++i) { float dx = deltas[i * 4 + 0] * stds[0] + means[0]; @@ -52,9 +53,18 @@ void delta2bbox(float const* anchors, float const* deltas, int n, float* out, float ax2 = anchors[i * 4 + 2], ay2 = anchors[i * 4 + 3]; float pxc = (ax1 + ax2) * 0.5f, pyc = (ay1 + ay2) * 0.5f; float pw = ax2 - ax1, ph = ay2 - ay1; - dw = std::min(std::max(dw, -max_ratio), max_ratio); - dh = std::min(std::max(dh, -max_ratio), max_ratio); - float gxc = pxc + pw * dx, gyc = pyc + ph * dy; + float mx = pw * dx, my = ph * dy; + if (ctr_clamp > 0.0f) { + // add_ctr_clamp: 중심 이동은 **픽셀 단위**로 자르고 dw/dh 는 상한만 자른다. + mx = std::min(std::max(mx, -ctr_clamp), ctr_clamp); + my = std::min(std::max(my, -ctr_clamp), ctr_clamp); + dw = std::min(dw, max_ratio); + dh = std::min(dh, max_ratio); + } else { + dw = std::min(std::max(dw, -max_ratio), max_ratio); + dh = std::min(std::max(dh, -max_ratio), max_ratio); + } + float gxc = pxc + mx, gyc = pyc + my; float gw = pw * std::exp(dw), gh = ph * std::exp(dh); float x1 = gxc - gw * 0.5f, y1 = gyc - gh * 0.5f; float x2 = gxc + gw * 0.5f, y2 = gyc + gh * 0.5f; @@ -154,7 +164,7 @@ std::vector detect_anchor( for (int k = 0; k < 4; ++k) delta[k] = bp[k]; float outb[4]; delta2bbox(anchors.data() + (size_t)aidx * 4, delta, 1, outb, - p.means, p.stds, p.input_w, p.input_h); + p.means, p.stds, p.input_w, p.input_h, p.ctr_clamp); float sc = std::get<0>(t); if (sf) sc *= sigmoidf(sf[(size_t)pos * num_base + b]); cand.push_back({outb[0], outb[1], outb[2], outb[3], sc, std::get<1>(t)}); diff --git a/src/visp/postproc.h b/src/visp/postproc.h index 9829e06..d910e86 100755 --- a/src/visp/postproc.h +++ b/src/visp/postproc.h @@ -21,8 +21,12 @@ std::vector gen_anchors(int feat_h, int feat_w, float stride, float base_ // ── bbox decode (mmdet DeltaXYWHBBoxCoder.delta2bbox) ──────────────────────── // anchors[N*4], deltas[N*4] → out[N*4]. denorm(mean/std) + exp(dwh) + clamp + clip. +// `ctr_clamp` > 0 이면 mmdet 의 **add_ctr_clamp** 경로다(YOLOF). 중심 이동량을 픽셀 단위로 +// ±ctr_clamp 로 자르고, dw/dh 는 **상한만** 자른다 — 기본 경로(상·하한 모두)와 다르다 +// (delta_xywh_bbox_coder.py:346-350). void delta2bbox(float const* anchors, float const* deltas, int n, float* out, - float const means[4], float const stds[4], int max_w, int max_h); + float const means[4], float const stds[4], int max_w, int max_h, + float ctr_clamp = 0.0f); // ── NMS (mmcv nms, IoU) ───────────────────────────────────────────────────── std::vector nms(std::vector const& dets, float iou_thr); @@ -45,6 +49,8 @@ struct det_params { int input_w = 0, input_h = 0; // FoveaBox 의 `base_edge_list`(레벨별). 비면 안 쓴다 — `fcos_params::base_edge` 로 넘어간다. std::vector base_edge; + // mmdet DeltaXYWHBBoxCoder 의 `add_ctr_clamp`(YOLOF). 0 이면 기본 경로. + float ctr_clamp = 0.0f; }; // per-level 원시출력: cls_scores[level] = [num_base*num_classes, feat_w, feat_h](CWHN flat), diff --git a/tools/frontend/mmdet/mmdet_to_pt.py b/tools/frontend/mmdet/mmdet_to_pt.py index 85291d6..3130bbe 100755 --- a/tools/frontend/mmdet/mmdet_to_pt.py +++ b/tools/frontend/mmdet/mmdet_to_pt.py @@ -102,7 +102,7 @@ def emit_params(cfg, config_name): # anchor(Delta) 디코드가 되는 계열만 c.det 의 **앵커 파라미터**를 채운다. 조립은 # 되는데 코더가 다른 계열(FSAF 의 TBLRBBoxCoder 등)은 head 원시 출력까지만 낸다. # rpn 도 **앵커 파라미터를 그대로 쓴다**(디코드만 레벨별 NMS 로 다르다) — 같이 채운다. - if h not in ("anchor", "rpn") or not cfg.get("can_decode", True): + if h not in ("anchor", "rpn", "yolof") or not cfg.get("can_decode", True): out += [ " // Decoding for this family is the caller's: the head above emits raw\n", " // per-level tensors, and only the anchor(Delta) path fills c.det here.\n", @@ -137,7 +137,8 @@ def emit_params(cfg, config_name): for i, v in enumerate(cfg.get("stds", [1.0] * 4)): out.append(f" c.det.stds[{i}] = {_f(v)};\n") out.append(f" c.det.num_classes = {int(cfg.get('num_classes', 80))};\n") - out.append(f" c.det.use_sigmoid = {str(bool(cfg.get('use_sigmoid', True))).lower()};\n\n") + out.append(f" c.det.use_sigmoid = {str(bool(cfg.get('use_sigmoid', True))).lower()};\n") + out.append(f" c.det.ctr_clamp = {_f(cfg.get('ctr_clamp', 0.0))};\n\n") for i, v in enumerate(cfg.get("img_mean", [0.0] * 3)): out.append(f" c.img_mean[{i}] = {_f(v)};\n") diff --git a/tools/frontend/mmdet/mmdet_wrap.py b/tools/frontend/mmdet/mmdet_wrap.py index 758d299..d4463ea 100755 --- a/tools/frontend/mmdet/mmdet_wrap.py +++ b/tools/frontend/mmdet/mmdet_wrap.py @@ -268,6 +268,9 @@ def postproc_cfg(det): # 디코드 지원은 **별개 판단**이다. head 조립은 되는데 박스 코더가 다른 계열이 있다 # (FSAF 는 RetinaHead 인데 TBLRBBoxCoder 를 쓴다). 하나로 묶으면 조립까지 같이 막힌다. can_decode = bc is not None and "Delta" in type(bc).__name__ + # mmdet DeltaXYWHBBoxCoder 의 `add_ctr_clamp`(YOLOF). 켜지면 중심 이동을 픽셀 단위로 + # 자르고 dw/dh 는 상한만 자른다 — 기본 경로와 결과가 다르다. 0 이면 기본 경로. + ctr_clamp = float(getattr(bc, "ctr_clamp", 0)) if getattr(bc, "add_ctr_clamp", False) else 0.0 # (레벨별 anchor 수가 다르면 뒤에서 취소한다 — num_base 하나로는 못 푼다) # cls 출력 채널 수. **의미상 클래스 수와 다를 수 있다** — softmax head 는 배경을 한 칸 @@ -515,6 +518,7 @@ def _tc(key, default): "means": [float(v) for v in getattr(bc, "means", [0.0] * 4)], "stds": [float(v) for v in getattr(bc, "stds", [1.0] * 4)], "can_decode": can_decode and uniform_priors, + "ctr_clamp": ctr_clamp, # ── C++ head 부품(anchor_head_forward)용 구조 ── "num_base": num_base, "stacked_convs": stacked, From ff04d19a1ea133433169044cf59363b24d7bef1e Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 10:42:57 +0900 Subject: [PATCH 41/89] =?UTF-8?q?docs:=20foveabox=200.40px=20=C2=B7=20yolo?= =?UTF-8?q?f=200.12px=20=EC=B6=94=EA=B0=80=20=E2=80=94=20one-stage=2021?= =?UTF-8?q?=EA=B3=84=EC=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mmdet-detectors.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index f89a2dc..2ec7303 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -308,8 +308,10 @@ no label mismatch and no difference in how many boxes survive. | `ddod` | `detect_anchor` | 0.41 px | 0.001 | | `ghm` | `detect_anchor` | 0.46 px | 0.005 | | `nas_fpn` | `detect_anchor` | 1.04 px | 0.004 | +| `yolof` | `detect_anchor` (ctr_clamp) | 0.12 px | 0.002 | | `pvt` | `detect_anchor` (PVT-Tiny) | 0.55 px | 0.005 | | `nas_fcos` | `detect_fcos` | 0.19 px | 0.001 | +| `foveabox` | `detect_fcos` (base_edge) | 0.40 px | 0.002 | | `gfl` | `detect_fcos` | 0.23 px | 0.004 | | `fcos` | `detect_fcos` | 0.32 px | 0.004 | | `vfnet` | `detect_fcos` | 0.46 px | 0.003 | From 1ad0a7e2230d7ad1a29aca87e7652c7648e1ba92 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 10:49:28 +0900 Subject: [PATCH 42/89] =?UTF-8?q?feat(detect):=20RepPoints=20=EB=94=94?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20(=EB=B0=95=EC=8A=A4=200=EA=B1=B4=20?= =?UTF-8?q?=E2=86=92=200.25px)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 조립기(`reppoints_head_forward`)는 이미 `points2bbox_moment` 로 4채널 박스를 내고 있었는데 **디코더가 없어** 박스가 0건이었다. 그 값은 거리가 아니다 — **중심 기준 xyxy 오프셋**이고 단위가 격자다. mmdet 은 `bboxes = pred·stride + [cx,cy,cx,cy]` 로 픽셀화한다(reppoints_head.py `_predict_by_feat_single`). 거리 디코드(`[px-l, py-t, px+r, py+b]`)로 두면 l·t 를 빼서 박스가 중심 반대편으로 뒤집힌다. 격자점 생성·top-k·NMS 는 `detect_fcos` 와 같으므로 그 경로를 재사용하고 **박스 해석만** `box_xyxy_offset` 으로 가른다. 기본값 false 라 기존 계열은 경로가 그대로다. 실측: reppoints 텐서 4.27e-04 · 박스 **0.25px PASS**. 회귀: fcos·foveabox·gfl·nas_fcos·vfnet 텐서 전부 통과. Co-Authored-By: Claude Opus 5 (1M context) --- src/visp/postproc.cpp | 16 +++++++++++++++- src/visp/postproc.h | 5 +++++ tools/verify/backbone/run_mmdet.cpp | 6 +++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/visp/postproc.cpp b/src/visp/postproc.cpp index a0c0252..0e8fb3c 100755 --- a/src/visp/postproc.cpp +++ b/src/visp/postproc.cpp @@ -270,7 +270,21 @@ std::vector detect_fcos( dist[k] = fovea ? be * std::exp(v) : v; } float outb[4]; - distance2bbox(pts.data() + (size_t)pos * 2, dist, 1, outb, p.input_w, p.input_h); + if (p.box_xyxy_offset) { + // RepPoints: 네 값 모두 중심에 **더한다**(격자 단위 → ×stride). + const float px = pts[(size_t)pos * 2], py = pts[(size_t)pos * 2 + 1]; + const float st = l < (int)p.strides.size() ? p.strides[l] : 1.0f; + outb[0] = px + dist[0] * st; outb[1] = py + dist[1] * st; + outb[2] = px + dist[2] * st; outb[3] = py + dist[3] * st; + if (p.input_w > 0) { + outb[0] = std::min(std::max(outb[0], 0.0f), (float)p.input_w); + outb[2] = std::min(std::max(outb[2], 0.0f), (float)p.input_w); + outb[1] = std::min(std::max(outb[1], 0.0f), (float)p.input_h); + outb[3] = std::min(std::max(outb[3], 0.0f), (float)p.input_h); + } + } else { + distance2bbox(pts.data() + (size_t)pos * 2, dist, 1, outb, p.input_w, p.input_h); + } float sc = std::get<0>(t); if (ctr) sc *= sigmoidf(ctr[pos]); cand.push_back({outb[0], outb[1], outb[2], outb[3], sc, std::get<1>(t)}); diff --git a/src/visp/postproc.h b/src/visp/postproc.h index d910e86..64b0c8c 100755 --- a/src/visp/postproc.h +++ b/src/visp/postproc.h @@ -93,6 +93,11 @@ struct fcos_params { // 그리고 곱하는 값은 stride 가 아니라 `base_edge_list` 다 — 기본값이 stride 의 // 2배(16,32,… vs 8,16,…)라 stride 로 두면 박스가 레벨마다 정확히 절반이 된다. std::vector base_edge; + // RepPoints: 조립기가 `points2bbox` 로 만든 값은 **거리가 아니라 중심 기준 xyxy + // 오프셋**이고 단위가 격자다. mmdet 은 `bboxes = pred·stride + [cx,cy,cx,cy]` 로 + // 픽셀화한다(reppoints_head.py `_predict_by_feat_single`). 거리 디코드처럼 + // l·t 를 빼면 박스가 중심 반대편으로 뒤집힌다. + bool box_xyxy_offset = false; }; // cls_scores[l]=[nc,W,H]HWC · bbox_preds[l]=[4,W,H]HWC. // bbox_preds 는 조립기가 이미 픽셀 거리로 만든 값이다 — 여기서 stride 를 곱하지 않는다. diff --git a/tools/verify/backbone/run_mmdet.cpp b/tools/verify/backbone/run_mmdet.cpp index c8059f6..6a3bf39 100755 --- a/tools/verify/backbone/run_mmdet.cpp +++ b/tools/verify/backbone/run_mmdet.cpp @@ -366,8 +366,11 @@ int main(int argc, char** argv) { // · anchor 인데 거리 예측 → 같은 거리 경로 (RTMDet 의 bbox_mul_stride 가 그 표시) // 앵커 파라미터가 안 실린 계열은 `octave_scales` 가 비어 있어 detect_anchor 가 // 후보 0개를 낸다 — 그건 "조용히 틀린 박스" 가 아니라 안전한 정지다. + // RepPoints 도 이 경로를 탄다 — 격자점을 깔고 top-k·NMS 하는 부분이 같다. + // 박스 해석만 다르다(`box_xyxy_offset`). const bool distance_box = hc.kind == head_kind::fcos || hc.kind == head_kind::gfl || - hc.kind == head_kind::vfnet || hc.bbox_mul_stride; + hc.kind == head_kind::vfnet || hc.kind == head_kind::reppoints || + hc.bbox_mul_stride; std::vector dets; if (is_detr) { @@ -485,6 +488,7 @@ int main(int argc, char** argv) { fp.base_edge = dp.base_edge; // FoveaBox 만 채워져 온다 // FCOS 만 MlvlPointGenerator(0.5) 다. 나머지는 AnchorGenerator(center_offset=0). fp.point_offset = hc.kind == head_kind::fcos ? 0.5f : hc.center_offset; + fp.box_xyxy_offset = hc.kind == head_kind::reppoints; dets = detect_fcos(cls_v, box_v, ctr_v, feat_hw, fp); } else { dets = detect_anchor(cls_v, box_v, feat_hw, dp, ctr_v.empty() ? nullptr : &ctr_v); From d9789cb1b94b4bf1ff19f8223abd2a332fa6f2ee Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 10:53:08 +0900 Subject: [PATCH 43/89] =?UTF-8?q?fix(detect):=20=EA=B2=A9=EC=9E=90=20?= =?UTF-8?q?=EC=98=A4=ED=94=84=EC=85=8B=EC=9D=84=20head=20kind=20=EB=A1=9C?= =?UTF-8?q?=20=EB=B0=95=EC=A7=80=20=EB=A7=90=EA=B3=A0=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C=20=EA=B0=92=EC=9D=84=20=EC=93=B4?= =?UTF-8?q?=EB=8B=A4=20(autoassign=2064px=20=E2=86=92=200.30px)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 러너가 `kind == fcos` 면 `point_offset = 0.5` 로 박고 있었다. **같은 kind 라도 다르다** — FCOS·NAS-FCOS·FoveaBox 는 `MlvlPointGenerator` 기본값 0.5 지만 **AutoAssign 은 `MlvlPointGenerator(strides, offset=0)`** 으로 만든다(autoassign_head.py:171). 밀리는 양이 0.5·stride 라 최대 stride 128 에서 **64px** 이다. 실측 64.30px 과 맞는다. 점수는 0.006 으로 멀쩡한데 박스만 틀리는 것이 그 증상이었다. 프론트엔드는 생성기에서 이미 읽어 싣고 있었다(`_center_offset`, 속성 이름이 `center_offset`/`offset` 으로 갈리는 것까지 처리한다) — 러너가 그걸 덮고 있었을 뿐이다. 하드코딩을 지우고 실린 값을 쓴다. 실측: autoassign 64.30px → **0.30px PASS**. 회귀: fcos 0.32px · nas_fcos 0.19px · foveabox 0.40px 모두 그대로(셋 다 0.5 를 싣는다). Co-Authored-By: Claude Opus 5 (1M context) --- tools/verify/backbone/run_mmdet.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/verify/backbone/run_mmdet.cpp b/tools/verify/backbone/run_mmdet.cpp index 6a3bf39..e5c56a2 100755 --- a/tools/verify/backbone/run_mmdet.cpp +++ b/tools/verify/backbone/run_mmdet.cpp @@ -487,7 +487,12 @@ int main(int argc, char** argv) { fp.input_h = dp.input_h; fp.base_edge = dp.base_edge; // FoveaBox 만 채워져 온다 // FCOS 만 MlvlPointGenerator(0.5) 다. 나머지는 AnchorGenerator(center_offset=0). - fp.point_offset = hc.kind == head_kind::fcos ? 0.5f : hc.center_offset; + // ⚠️ **kind 로 0.5 를 박으면 안 된다.** 같은 `fcos` kind 라도 격자 오프셋이 다르다 — + // FCOS·NAS-FCOS·FoveaBox 는 `MlvlPointGenerator` 기본값 0.5 지만 **AutoAssign 은 + // `offset=0`** 으로 만든다(autoassign_head.py:171). 프론트엔드가 생성기에서 읽어 + // 실어 주므로(`_center_offset`) 그 값을 그대로 쓴다. + // 박으면 0.5·stride 만큼 밀리는데, 최대 stride 128 에서 **64px** 이다(실측 64.30px). + fp.point_offset = hc.center_offset; fp.box_xyxy_offset = hc.kind == head_kind::reppoints; dets = detect_fcos(cls_v, box_v, ctr_v, feat_hw, fp); } else { From 30eaf25f345a2693e916a0beb189f1c5fa654cf9 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 10:53:25 +0900 Subject: [PATCH 44/89] =?UTF-8?q?docs:=20reppoints=200.25px=20=C2=B7=20aut?= =?UTF-8?q?oassign=200.30px=20=EC=B6=94=EA=B0=80=20=E2=80=94=20one-stage?= =?UTF-8?q?=2023=EA=B3=84=EC=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mmdet-detectors.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 2ec7303..2ee4d9c 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -311,6 +311,8 @@ no label mismatch and no difference in how many boxes survive. | `yolof` | `detect_anchor` (ctr_clamp) | 0.12 px | 0.002 | | `pvt` | `detect_anchor` (PVT-Tiny) | 0.55 px | 0.005 | | `nas_fcos` | `detect_fcos` | 0.19 px | 0.001 | +| `reppoints` | `detect_fcos` (xyxy offset) | 0.25 px | 0.006 | +| `autoassign` | `detect_fcos` | 0.30 px | 0.006 | | `foveabox` | `detect_fcos` (base_edge) | 0.40 px | 0.002 | | `gfl` | `detect_fcos` | 0.23 px | 0.004 | | `fcos` | `detect_fcos` | 0.32 px | 0.004 | From 7df5107d248eefbea5a22ed8b8363740f3f271f9 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 10:58:04 +0900 Subject: [PATCH 45/89] =?UTF-8?q?feat(verify):=20=EA=B0=9C=EC=88=98?= =?UTF-8?q?=EC=B0=A8=EA=B0=80=20=EC=9E=84=EA=B3=84=EA=B0=92=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EC=95=84=ED=8B=B0=ED=8C=A9=ED=8A=B8=EC=9D=B8?= =?UTF-8?q?=EC=A7=80=20=EA=B0=99=EC=9D=B4=20=EC=95=8C=EB=A0=A4=EC=A4=80?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `free_anchor` 가 "개수차 1" 로 계속 실패로 잡혔는데 **디코드 버그가 아니었다.** 문제의 박스는 mmdet 0.2954 · C++ 0.301 로, 하네스가 보기 좋으라고 거는 `thr=0.30` 을 가로지른 것뿐이다. 짝지은 6건은 전부 0.35px 이내였다. 양쪽 다 mmdet 의 `score_thr`(보통 0.05)로 박스를 내고 **우리가** 0.30 으로 한 번 더 자른다. fp16 가중치의 점수 오차(실측 0.006~0.025)면 그 선을 충분히 넘나든다. 그래서 개수차를 알릴 때 **임계값 바로 위 구간에 몇 개가 몰려 있는지** 같이 낸다. 안 내면 다음 사람이 여기를 판다 — 오늘 내가 그랬다. free_anchor: 임계값 0.30~0.35 구간: mmdet 0건 · C++ 2건 ← 경계 아티팩트 Co-Authored-By: Claude Opus 5 (1M context) --- tools/verify/dense_head/verify_postproc.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tools/verify/dense_head/verify_postproc.py b/tools/verify/dense_head/verify_postproc.py index 2830eba..27c7d8e 100644 --- a/tools/verify/dense_head/verify_postproc.py +++ b/tools/verify/dense_head/verify_postproc.py @@ -205,6 +205,17 @@ def main(): if n_gap: print(" ⚠️ 개수가 다르다 — 임계값(score_thr/nms_thr/max_per_img)이 mmdet 과 어긋났을 때" " 나는 증상이다. 짝지은 것만 보면 조용히 통과한다.") + # ⚠️ **그런데 이 임계값은 우리가 건 것이다.** 양쪽 다 mmdet 의 score_thr(보통 0.05)로 + # 많은 박스를 내고, 여기서 보기 좋으라고 `thr`(0.30)로 한 번 더 자른다. 그래서 + # 점수가 그 언저리인 박스 하나가 **한쪽에서만 살아남아** 개수차로 잡힌다. + # fp16 가중치의 점수 오차(실측 0.006~0.025)면 충분히 넘나든다 — + # free_anchor 가 그랬다(mmdet 0.2954 vs C++ 0.301). 디코드 버그가 아니다. + # 그래서 **경계에 몰린 게 몇 개인지 같이 낸다** — 안 내면 다음 사람이 여기를 판다. + band = 0.05 + near_ref = int(((ref[:, 4] >= thr) & (ref[:, 4] < thr + band)).sum()) + near_got = int(((got[:, 4] >= thr) & (got[:, 4] < thr + band)).sum()) + print(f" 임계값 {thr:.2f}~{thr + band:.2f} 구간: mmdet {near_ref}건 · C++ {near_got}건" + f"{' ← 경계 아티팩트일 수 있다(디코드 아님)' if (near_ref or near_got) else ''}") return 0 if (worst_b < 2.0 and worst_s < 0.05 and bad_label == 0 and n_gap == 0) else 1 From 1be1573c3e58ab3af4b2542aa6bfb47a85c9f083 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 11:06:42 +0900 Subject: [PATCH 46/89] =?UTF-8?q?feat(detect):=20YOLOv3=20=EB=94=94?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20(=EB=B0=95=EC=8A=A4=200=EA=B1=B4=20?= =?UTF-8?q?=E2=86=92=200.23px)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 레벨당 출력이 **한 갈래**라 러너가 "레벨에 못 미친다(cls 3, box 0)" 로 멈추고 있었다. YOLOv3 는 `na×(5+nc)` 한 텐서에 tx,ty,tw,th,obj,cls 를 다 담는다. 전용 디코더를 넣는다. 다른 계열과 셋이 다르다: ① 한 갈래 출력을 채널에서 가른다(ch = a*(5+nc)+k). ② 앵커가 scales×ratios 가 아니라 **(w,h) 쌍 목록**(base_sizes)이다. ③ objectness 로 **먼저 거른다**(conf_thr=0.005). 안 걸면 후보가 수만 개다. 디코드: 중심 = 앵커중심 + (sigmoid(t)−0.5)·stride, **반폭** = 앵커반폭·exp(t). ⚠️ stride 가 **내림차순**이다(32,16,8). 정렬하면 레벨마다 4배씩 어긋난다 — config 순서 유지. ★ 회귀 하나를 만들었다가 잡았다. `AnchorGenerator` 도 `base_sizes` 를 갖는데 그건 **정수 리스트**(32,64,…)라 (w,h) 쌍으로 순회하면 `TypeError` 로 **retinanet export 가 통째로 죽는다**. 안쪽이 순회 가능할 때만 YOLO 쌍으로 본다 — 이름이 같다고 뜻이 같지 않다. 같이: 기준값 전처리를 float32 로 못 박는다. mean/std 가 float64 로 오는 계열이 있어 conv2d 가 `expected scalar type Double but found Float` 로 죽었다(계열 탓이 아니라 dtype). 실측: yolo 텐서 5.87e-04 · 박스 **0.23px PASS**. 회귀: retinanet·atss·yolox·fcos·rtmdet 텐서 전부 통과. Co-Authored-By: Claude Opus 5 (1M context) --- src/visp/postproc.cpp | 76 ++++++++++++++++++++++ src/visp/postproc.h | 26 ++++++++ tools/frontend/mmdet/mmdet_to_pt.py | 6 ++ tools/frontend/mmdet/mmdet_wrap.py | 26 ++++++++ tools/verify/backbone/run_mmdet.cpp | 25 ++++++- tools/verify/dense_head/verify_postproc.py | 6 +- 6 files changed, 162 insertions(+), 3 deletions(-) diff --git a/src/visp/postproc.cpp b/src/visp/postproc.cpp index 0e8fb3c..96cd696 100755 --- a/src/visp/postproc.cpp +++ b/src/visp/postproc.cpp @@ -697,4 +697,80 @@ std::vector detect_yolo_dense( return dets; } +// ── YOLOv3 ─────────────────────────────────────────────────────────────────── +// mmdet `YOLOV3Head.predict_by_feat` + `YOLOBBoxCoder.decode`. +// +// 다른 계열과 다른 점 셋: +// ① 레벨당 출력이 **한 갈래**다 — na×(5+nc) 채널에 tx,ty,tw,th,obj,cls 가 붙어 있다. +// ② 앵커가 scales×ratios 가 아니라 **(w,h) 쌍 목록**이다(base_sizes). +// ③ objectness 로 **먼저 거른다**(conf_thr). 안 걸면 후보가 수만 개로 불어난다. +std::vector detect_yolov3( + std::vector> const& pred, + std::vector> const& feat_hw, yolov3_params const& p) { + + const int nc = p.num_classes, attrib = 5 + nc; + const int nlev = (int)feat_hw.size(); + std::vector cand; + for (int l = 0; l < nlev; ++l) { + if (l >= (int)p.base_sizes.size() || l >= (int)p.strides.size()) break; + const int fh = feat_hw[l].first, fw = feat_hw[l].second; + const float stride = p.strides[l]; + std::vector const& bs = p.base_sizes[l]; + const int na = (int)(bs.size() / 2); + const int C = na * attrib; + float const* pm = pred[l].data(); + + std::vector> lvl; // score, label, pos, anchor + for (int pos = 0; pos < fh * fw; ++pos) { + for (int a = 0; a < na; ++a) { + float const* q = pm + (size_t)pos * C + (size_t)a * attrib; + const float conf = sigmoidf(q[4]); + if (conf < p.conf_thr) continue; // ③ objectness 선필터 + for (int j = 0; j < nc; ++j) { + const float sc = conf * sigmoidf(q[5 + j]); + if (sc > p.score_thr) lvl.emplace_back(sc, j, pos, a); + } + } + } + if (p.nms_pre > 0 && (int)lvl.size() > p.nms_pre) { + std::nth_element(lvl.begin(), lvl.begin() + p.nms_pre, lvl.end(), + [](auto const& x, auto const& y) { + return std::get<0>(x) > std::get<0>(y); + }); + lvl.resize(p.nms_pre); + } + for (auto const& t : lvl) { + const int pos = std::get<2>(t), a = std::get<3>(t); + const int gy = pos / fw, gx = pos % fw; + float const* q = pm + (size_t)pos * C + (size_t)a * attrib; + // 앵커 중심은 격자 **중심**이다(YOLOAnchorGenerator 의 centers = stride/2). + const float acx = (gx + 0.5f) * stride, acy = (gy + 0.5f) * stride; + const float hw = bs[a * 2] * 0.5f, hh = bs[a * 2 + 1] * 0.5f; + const float cx = acx + (sigmoidf(q[0]) - 0.5f) * stride; + const float cy = acy + (sigmoidf(q[1]) - 0.5f) * stride; + const float w2 = hw * std::exp(q[2]), h2 = hh * std::exp(q[3]); + float x1 = cx - w2, y1 = cy - h2, x2 = cx + w2, y2 = cy + h2; + if (p.input_w > 0) { + x1 = std::min(std::max(x1, 0.0f), (float)p.input_w); + x2 = std::min(std::max(x2, 0.0f), (float)p.input_w); + y1 = std::min(std::max(y1, 0.0f), (float)p.input_h); + y2 = std::min(std::max(y2, 0.0f), (float)p.input_h); + } + cand.push_back({x1, y1, x2, y2, std::get<0>(t), std::get<1>(t)}); + } + } + // 클래스별 NMS + std::vector kept; + for (int j = 0; j < nc; ++j) { + std::vector per; + for (auto const& d : cand) if (d.label == j) per.push_back(d); + if (per.empty()) continue; + for (int k : nms(per, p.nms_thr)) kept.push_back(per[k]); + } + std::sort(kept.begin(), kept.end(), + [](detection const& a, detection const& b) { return a.score > b.score; }); + if (p.max_per_img > 0 && (int)kept.size() > p.max_per_img) kept.resize(p.max_per_img); + return kept; +} + } // namespace visp diff --git a/src/visp/postproc.h b/src/visp/postproc.h index 64b0c8c..12c540c 100755 --- a/src/visp/postproc.h +++ b/src/visp/postproc.h @@ -51,6 +51,9 @@ struct det_params { std::vector base_edge; // mmdet DeltaXYWHBBoxCoder 의 `add_ctr_clamp`(YOLOF). 0 이면 기본 경로. float ctr_clamp = 0.0f; + // YOLOv3 의 (w,h) 앵커(레벨별)와 objectness 선필터. 비면 안 쓴다. + std::vector> base_sizes; + float conf_thr = 0.0f; }; // per-level 원시출력: cls_scores[level] = [num_base*num_classes, feat_w, feat_h](CWHN flat), @@ -143,6 +146,29 @@ std::vector detect_yolo_dense( float const* box, float const* score, std::vector> const& feat_hw, yolo_dense_params const& p); +// ── YOLOv3 (레벨당 한 갈래: na×(5+nc) 채널) ───────────────────────────────── +struct yolov3_params { + // ⚠️ YOLOv3 는 stride 가 **내림차순**이다(32,16,8). FPN 순서와 반대라 뒤집어 쓰면 + // 레벨마다 4배씩 어긋난다. 프론트엔드가 config 순서 그대로 싣는다. + std::vector strides; + // 레벨별 앵커를 **(w,h) 쌍**으로 받는다 — scales×ratios 가 아니다. + // base_sizes[l] = {w0,h0, w1,h1, ...} (레벨당 num_base 쌍). + std::vector> base_sizes; + int num_classes = 80; + float conf_thr = 0.005f; // objectness 선필터(mmdet test_cfg.conf_thr) + float score_thr = 0.05f; + float nms_thr = 0.45f; + int nms_pre = 1000; + int max_per_img = 100; + int input_w = 0, input_h = 0; +}; +// pred[l] = [na*(5+nc), W, H] CWHN flat. 채널 = a*(5+nc) + {tx,ty,tw,th,obj,cls...}. +// 디코드(YOLOBBoxCoder): 중심 = 앵커중심 + (sigmoid(t)-0.5)·stride, +// 반폭 = 앵커반폭 · exp(t). 앵커중심은 격자 중심(=stride/2 오프셋). +std::vector detect_yolov3( + std::vector> const& pred, + std::vector> const& feat_hw, yolov3_params const& p); + // ── DETR (set prediction, NMS 없음) ───────────────────────────────────────── struct detr_params { int num_queries = 100; diff --git a/tools/frontend/mmdet/mmdet_to_pt.py b/tools/frontend/mmdet/mmdet_to_pt.py index 3130bbe..31d8252 100755 --- a/tools/frontend/mmdet/mmdet_to_pt.py +++ b/tools/frontend/mmdet/mmdet_to_pt.py @@ -108,6 +108,12 @@ def emit_params(cfg, config_name): " // per-level tensors, and only the anchor(Delta) path fills c.det here.\n", ] out.append(" c.det.strides = {" + ", ".join(_f(v) for v in cfg["strides"]) + "};\n") + # YOLOv3 의 (w,h) 앵커. 레벨별로 하나씩 싣는다 — 순서가 곧 레벨 순서다. + for i, lvl in enumerate(cfg.get("base_sizes") or []): + out.append(f" c.det.base_sizes.push_back({{" + + ", ".join(_f(v) for v in lvl) + "});\n") + if cfg.get("conf_thr"): + out.append(f" c.det.conf_thr = {_f(cfg['conf_thr'])};\n") # FoveaBox 만 채운다. 비어 있으면 디코더가 조립기가 낸 값을 그대로 거리로 쓴다. if cfg.get("bbox_base_edge"): out.append(" c.det.base_edge = {" diff --git a/tools/frontend/mmdet/mmdet_wrap.py b/tools/frontend/mmdet/mmdet_wrap.py index d4463ea..01c38d3 100755 --- a/tools/frontend/mmdet/mmdet_wrap.py +++ b/tools/frontend/mmdet/mmdet_wrap.py @@ -190,6 +190,22 @@ def _tolist(v): return [v] +def _yolo_base_sizes(pg): + """YOLOAnchorGenerator 의 레벨별 (w,h) 앵커만 평탄화해서 돌려준다. + + `AnchorGenerator` 도 같은 이름의 속성을 갖지만 그건 **정수 리스트**라 쌍이 아니다. + 안쪽 원소가 순회 가능할 때만 YOLO 쌍으로 본다 — 이름이 같다고 뜻이 같지 않다. + """ + out = [] + for lvl in (getattr(pg, "base_sizes", None) or []): + try: + flat = [float(v) for wh in lvl for v in wh] + except TypeError: + return [] # AnchorGenerator — YOLO 앵커가 아니다 + out.append(flat) + return out + + def _center_offset(pg): """격자 중심 오프셋. 생성기마다 **속성 이름이 다르다.** @@ -519,6 +535,16 @@ def _tc(key, default): "stds": [float(v) for v in getattr(bc, "stds", [1.0] * 4)], "can_decode": can_decode and uniform_priors, "ctr_clamp": ctr_clamp, + # YOLOv3: 앵커가 (w,h) 쌍 목록이다. `base_sizes[l]` = [(w,h), ...] → 평탄화. + # ⚠️ **레벨 순서를 건드리지 않는다** — YOLOv3 는 stride 가 내림차순(32,16,8)이고 + # 그 순서가 head 출력 순서와 같다. 정렬하면 레벨마다 4배씩 어긋난다. + # ⚠️ **`base_sizes` 는 생성기마다 뜻이 다르다.** `YOLOAnchorGenerator` 는 + # 레벨별 `[(w,h), ...]` 쌍 목록이지만 **`AnchorGenerator` 는 정수 리스트**다 + # (32,64,128,…). 구분 없이 쌍으로 순회하면 `TypeError: 'int' object is not + # iterable` 로 **retinanet 계열 export 가 통째로 죽는다**(실제로 그랬다). + # 안쪽이 순회 가능한 경우에만 YOLO 쌍으로 본다. + "base_sizes": _yolo_base_sizes(pg), + "conf_thr": float((getattr(det, "test_cfg", None) or {}).get("conf_thr", 0.0) or 0.0), # ── C++ head 부품(anchor_head_forward)용 구조 ── "num_base": num_base, "stacked_convs": stacked, diff --git a/tools/verify/backbone/run_mmdet.cpp b/tools/verify/backbone/run_mmdet.cpp index e5c56a2..b3cab3a 100755 --- a/tools/verify/backbone/run_mmdet.cpp +++ b/tools/verify/backbone/run_mmdet.cpp @@ -335,8 +335,13 @@ int main(int argc, char** argv) { hc.kind == head_kind::deformable_detr || hc.kind == head_kind::dino; + // YOLOv3 는 레벨당 **한 갈래**다(na×(5+nc) 한 텐서에 box·obj·cls 가 다 들어 있다). + // box 갈래가 없는 게 정상이라 아래 검사에서 빼야 한다 — 안 빼면 조립은 됐는데 + // "레벨에 못 미친다" 로 멈춘다. + const bool single_branch = hc.kind == head_kind::yolo; + // 조립기가 레벨 수를 못 채우면 아래 인덱싱이 널을 읽는다. 여기서 말한다. - if (!is_detr && ((int)cls_t.size() < L || (int)box_t.size() < L)) { + if (!is_detr && !single_branch && ((int)cls_t.size() < L || (int)box_t.size() < L)) { fprintf(stderr, "head 출력이 %d 레벨에 못 미친다 (cls %zu, box %zu)\n", L, cls_t.size(), box_t.size()); return 4; @@ -349,7 +354,8 @@ int main(int argc, char** argv) { for (int l = 0; l < NL; ++l) { feat_hw[l] = { (int)cls_t[l]->ne[2], (int)cls_t[l]->ne[1] }; // (fh, fw) cls_v[l] = to_vec(cls_t[l]); - box_v[l] = to_vec(box_t[l]); + // 한 갈래 계열(YOLOv3)은 box 텐서가 없다 — 읽으면 널 참조다. + if (!single_branch) box_v[l] = to_vec(box_t[l]); } // centerness 갈래가 있으면 score factor 로 넘긴다(ATSS·PAA·DDOD·FCOS…). mmdet 은 이걸 // top-k 뒤에 곱한다 — 안 넘기면 **박스는 맞고 점수만** 높게 나온다(실측 Δ0.071). @@ -455,6 +461,21 @@ int main(int argc, char** argv) { dets = detect_rpn(cls_v, box_v, feat_hw, rp); // `label` 은 레벨 번호로 돌아온다. 밖에서는 클래스 자리라 0(유일한 클래스)으로 둔다. for (auto& d : dets) d.label = 0; + } else if (hc.kind == head_kind::yolo) { + // YOLOv3: 레벨당 **한 갈래**(na×(5+nc))라 조립기가 `out.cls` 에만 담는다. + // 앵커가 (w,h) 쌍이고 objectness 로 먼저 거른다 — 전용 디코더로 보낸다. + yolov3_params yp; + yp.strides = dp.strides; + yp.base_sizes = dp.base_sizes; + yp.num_classes = dp.num_classes; + yp.conf_thr = dp.conf_thr; + yp.score_thr = dp.score_thr; + yp.nms_thr = dp.nms_thr; + yp.nms_pre = dp.nms_pre; + yp.max_per_img = dp.max_per_img; + yp.input_w = dp.input_w; + yp.input_h = dp.input_h; + dets = detect_yolov3(cls_v, feat_hw, yp); } else if (hc.kind == head_kind::yolox) { // 격자 단위 (dx,dy,log w,log h) + 별도 objectness. 코더가 없어 `c.det` 의 앵커 // 파라미터가 안 실리고, 그대로 두면 detect_anchor 가 후보 0개를 낸다. diff --git a/tools/verify/dense_head/verify_postproc.py b/tools/verify/dense_head/verify_postproc.py index 27c7d8e..2a0111c 100644 --- a/tools/verify/dense_head/verify_postproc.py +++ b/tools/verify/dense_head/verify_postproc.py @@ -86,7 +86,11 @@ def mmdet_boxes(cfg, ckpt, image, size, thr, to_rgb): im = np.asarray(Image.open(image).convert("RGB").resize((size, size), Image.BILINEAR), dtype=np.float32) x = im[:, :, ::-1] if to_rgb else im # 러너 규약에 맞춘다 - x = (np.ascontiguousarray(x) - mean) / std + # ⚠️ **float32 로 못 박는다.** mean/std 가 float64 로 오는 계열이 있어(YOLOv3) + # 나눗셈에서 배열이 double 로 승격되고, conv2d 가 + # `expected scalar type Double but found Float` 로 죽는다. 계열 탓처럼 보이지만 + # 전처리 dtype 문제다. + x = ((np.ascontiguousarray(x) - mean) / std).astype(np.float32) t = torch.from_numpy(x).permute(2, 0, 1).unsqueeze(0) meta = {"img_shape": (size, size), "ori_shape": (size, size), From 80af938354d72e2f373d2d8474b52964eb51d5c3 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 11:06:58 +0900 Subject: [PATCH 47/89] =?UTF-8?q?docs:=20yolo=200.23px=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=E2=80=94=20one-stage=2024=EA=B3=84=EC=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mmdet-detectors.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 2ee4d9c..ec8bc05 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -320,6 +320,7 @@ no label mismatch and no difference in how many boxes survive. | `rtmdet` | `detect_fcos` | 0.68 px | 0.002 | | `ld` | `detect_fcos` | 0.30 px | 0.006 | | `yolox` | `detect_yolox` | 0.41 px | 0.002 | +| `yolo` | `detect_yolov3` | 0.23 px | 0.005 | | `conditional_detr` | `detect_detr` | 0.27 px | 0.002 | | `dab_detr` | `detect_detr` | 0.28 px | 0.001 | | `dino` | `detect_detr` | 0.28 px | 0.004 | From 5f62f2ae997f2c25ceb82ef11c6755d66d558d11 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 12:20:59 +0900 Subject: [PATCH 48/89] =?UTF-8?q?feat(detect):=20SABL=20=EB=94=94=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20(=EC=B8=A1=EC=A0=95=20=EB=B6=88=EA=B0=80=20?= =?UTF-8?q?=E2=86=92=200.55px)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SABLRetinaHead` 는 `BaseDenseHead` 직속이라 조립기가 없어 `head_type: raw` 로 떨어지고 two-stage 경로까지 흘러가 `HEAD_NONE` 이었다. 박스를 델타로 한 번에 내지 않는다. **변(l,r,t,d)마다** 앵커를 `num_buckets` 칸으로 나눠 ① 어느 칸인지 softmax 분류 ② 그 칸 안의 오프셋을 회귀한다. 그래서 box 갈래가 **둘**이다. 그리고 칸 분류의 확신도(loc_confidence)로 점수를 다시 매긴다 — top-k **뒤에** 곱한다 (mmdet `score_factors` 규약, ATSS centerness 와 같다). 네 군데를 손봤다: - `head_kind::sabl` + 두 번째 box 갈래(`bbox_cls_head`). `extra["bcls"]` 로 싣는다 — 셋에 우겨넣으면 안 잰 갈래가 생겨 "통과" 가 거짓말이 된다. - `detect_sabl` — bucket2bbox + 재점수. - 기준값 분기: `forward_single` 이 `(cls, (bbox_cls, bbox_reg))` 로 **중첩 튜플**을 낸다. 그대로 쓰면 `'tuple' object has no attribute 'shape'`. - 프론트엔드 인식. ★ 오늘 네 번 나온 함정에 **또** 걸렸다 — 이름이 같다고 뜻이 같지 않다: ① `prior_generator` 가 없다. 생성기가 둘이고(`approx`=라벨할당용, `square`=디코드용) 추론이 쓰는 것은 square 다. approx 를 잡으면 위치당 9개가 되어 채널 해석이 어긋난다. ② box 갈래 둘이 **채널 수가 같아**(side_num*4) 채널로 못 가른다 → 이름으로 박았다. 안 그러면 기본값 `retina_reg` 가 남아 `tensor not found` 로 죽는다. ③ **`octave_base_scale` 이 아니라 `scales[0]`** 이 앵커 크기다. 재사용하면 1.0 으로 떨어져 디코드 공식이 맞는데도 박스가 통째로 작아진다(실측 146px → 0.55px). 디코드 공식은 컴파일 전에 mmdet `bucket2bbox` 와 손으로 검산했다(l 1.10 vs 1.12, r 506.8 vs 506.76). 회귀: retinanet·atss·fcos·gfl·yolo·yolof·foveabox·reppoints 텐서 전부 통과. Co-Authored-By: Claude Opus 5 (1M context) --- src/visp/postproc.cpp | 108 ++++++++++++++++++++++++ src/visp/postproc.h | 28 ++++++ tools/detect/head.cpp | 12 +++ tools/detect/head.h | 6 ++ tools/frontend/mmdet/mmdet_to_pt.py | 8 +- tools/frontend/mmdet/mmdet_wrap.py | 25 +++++- tools/verify/backbone/run_mmdet.cpp | 18 ++++ tools/verify/dense_head/verify_heads.py | 7 ++ 8 files changed, 209 insertions(+), 3 deletions(-) diff --git a/src/visp/postproc.cpp b/src/visp/postproc.cpp index 96cd696..abe8754 100755 --- a/src/visp/postproc.cpp +++ b/src/visp/postproc.cpp @@ -773,4 +773,112 @@ std::vector detect_yolov3( return kept; } + +// ── SABL ───────────────────────────────────────────────────────────────────── +// mmdet `SABLRetinaHead._predict_by_feat_single` + `BucketingBBoxCoder.bucket2bbox`. +// +// 델타 회귀가 아니다. 변(l,r,t,d)마다 앵커를 `num_buckets` 칸으로 쪼개 +// ① 어느 칸인지 softmax 분류 → argmax 칸의 경계를 잡고 +// ② 그 칸 안에서 오프셋을 빼서 최종 변 위치를 만든다. +// 그리고 칸 분류의 확신도(loc_confidence)로 점수를 다시 매긴다. +std::vector detect_sabl( + std::vector> const& cls, + std::vector> const& bcls, + std::vector> const& breg, + std::vector> const& feat_hw, sabl_params const& p) { + + const int nc = p.num_classes; + const int side = (p.num_buckets + 1) / 2; // ceil(num_buckets/2) + const int nlev = (int)feat_hw.size(); + std::vector cand; + + for (int l = 0; l < nlev; ++l) { + const int fh = feat_hw[l].first, fw = feat_hw[l].second; + const float stride = l < (int)p.strides.size() ? p.strides[l] : 1.0f; + const float sz = stride * p.anchor_scale; // 정사각 앵커 한 변 + float const* cs = cls[l].data(); + float const* bc = bcls[l].data(); + float const* br = breg[l].data(); + const int Cb = side * 4; + + // 후보 수집 — cls 만 보고 자른다. loc_confidence 는 **뒤에** 곱한다. + std::vector> lvl; // score, label, pos + for (int pos = 0; pos < fh * fw; ++pos) + for (int j = 0; j < nc; ++j) { + const float sc = sigmoidf(cs[(size_t)pos * nc + j]); + if (sc > p.score_thr) lvl.emplace_back(sc, j, pos); + } + if (p.nms_pre > 0 && (int)lvl.size() > p.nms_pre) { + std::nth_element(lvl.begin(), lvl.begin() + p.nms_pre, lvl.end(), + [](auto const& a, auto const& b) { + return std::get<0>(a) > std::get<0>(b); + }); + lvl.resize(p.nms_pre); + } + + for (auto const& t : lvl) { + const int pos = std::get<2>(t); + const int gy = pos / fw, gx = pos % fw; + // AnchorGenerator(center_offset=0): 중심이 격자점 자체다. + const float cx = gx * stride, cy = gy * stride; + // 디코드 전에 앵커를 `bucket_scale` 배로 키운다(mmdet bbox_rescale). + const float hw = sz * 0.5f * p.bucket_scale, hh = sz * 0.5f * p.bucket_scale; + const float px1 = cx - hw, py1 = cy - hh, px2 = cx + hw, py2 = cy + hh; + const float bw = (px2 - px1) / p.num_buckets; + const float bh = (py2 - py1) / p.num_buckets; + + float const* qc = bc + (size_t)pos * Cb; + float const* qr = br + (size_t)pos * Cb; + int top1[4]; + float conf = 0.0f; + float edge[4]; + for (int k = 0; k < 4; ++k) { // 0=l 1=r 2=t 3=d + float const* row = qc + (size_t)k * side; + // softmax → 상위 2개. 상위 2개가 **이웃 칸**이면 두 번째도 확신도에 더한다. + float mx = row[0]; + for (int i = 1; i < side; ++i) mx = std::max(mx, row[i]); + float sum = 0.0f; + for (int i = 0; i < side; ++i) sum += std::exp(row[i] - mx); + int i1 = 0, i2 = -1; + for (int i = 1; i < side; ++i) if (row[i] > row[i1]) i1 = i; + for (int i = 0; i < side; ++i) + if (i != i1 && (i2 < 0 || row[i] > row[i2])) i2 = i; + const float s1 = std::exp(row[i1] - mx) / sum; + const float s2 = i2 >= 0 ? std::exp(row[i2] - mx) / sum : 0.0f; + top1[k] = i1; + conf += s1 + (std::abs(i1 - i2) == 1 ? s2 : 0.0f); + const float off = qr[(size_t)k * side + i1]; + // l·t 는 좌/상 경계에서 **더해** 들어가고, r·d 는 우/하에서 **빼서** 들어간다. + if (k == 0) edge[0] = (px1 + (0.5f + i1) * bw) - off * bw; + else if (k == 1) edge[1] = (px2 - (0.5f + i1) * bw) - off * bw; + else if (k == 2) edge[2] = (py1 + (0.5f + i1) * bh) - off * bh; + else edge[3] = (py2 - (0.5f + i1) * bh) - off * bh; + } + conf *= 0.25f; // 네 변 평균 + + float x1 = edge[0], x2 = edge[1], y1 = edge[2], y2 = edge[3]; + if (p.input_w > 0) { // clip_border: max_shape-1 이다 + x1 = std::min(std::max(x1, 0.0f), (float)p.input_w - 1.0f); + x2 = std::min(std::max(x2, 0.0f), (float)p.input_w - 1.0f); + y1 = std::min(std::max(y1, 0.0f), (float)p.input_h - 1.0f); + y2 = std::min(std::max(y2, 0.0f), (float)p.input_h - 1.0f); + } + // score_factors 규약 — top-k 뒤에 곱한다. + cand.push_back({x1, y1, x2, y2, std::get<0>(t) * conf, std::get<1>(t)}); + } + } + + std::vector kept; + for (int j = 0; j < nc; ++j) { + std::vector per; + for (auto const& d : cand) if (d.label == j) per.push_back(d); + if (per.empty()) continue; + for (int k : nms(per, p.nms_thr)) kept.push_back(per[k]); + } + std::sort(kept.begin(), kept.end(), + [](detection const& a, detection const& b) { return a.score > b.score; }); + if (p.max_per_img > 0 && (int)kept.size() > p.max_per_img) kept.resize(p.max_per_img); + return kept; +} + } // namespace visp diff --git a/src/visp/postproc.h b/src/visp/postproc.h index 12c540c..2c65056 100755 --- a/src/visp/postproc.h +++ b/src/visp/postproc.h @@ -54,6 +54,10 @@ struct det_params { // YOLOv3 의 (w,h) 앵커(레벨별)와 objectness 선필터. 비면 안 쓴다. std::vector> base_sizes; float conf_thr = 0.0f; + // SABL 의 버킷 파라미터. 0 이면 안 쓴다. + int num_buckets = 0; + float bucket_scale = 3.0f; + float anchor_scale = 4.0f; }; // per-level 원시출력: cls_scores[level] = [num_base*num_classes, feat_w, feat_h](CWHN flat), @@ -146,6 +150,30 @@ std::vector detect_yolo_dense( float const* box, float const* score, std::vector> const& feat_hw, yolo_dense_params const& p); +// ── SABL (Side-Aware Boundary Localization) ───────────────────────────────── +// 박스를 델타로 한 번에 내지 않는다. **변(l,r,t,d)마다** 앵커를 `num_buckets` 칸으로 나눠 +// ① 어느 칸인지 **분류**하고 ② 그 칸 안의 **오프셋**을 회귀한다. 그래서 box 갈래가 둘이다. +struct sabl_params { + std::vector strides; + float anchor_scale = 4.0f; // square anchor: 한 변 = stride · scale (위치당 1개) + int num_buckets = 14; // side_num = ceil(num_buckets/2) + float bucket_scale = 3.0f; // 디코드 전에 앵커를 이만큼 키운다(coder 의 scale_factor) + int num_classes = 80; + float score_thr = 0.05f; + float nms_thr = 0.5f; + int nms_pre = 1000; + int max_per_img = 100; + int input_w = 0, input_h = 0; +}; +// cls[l]=[nc,W,H] · bcls[l]=[side_num*4,W,H] · breg[l]=[side_num*4,W,H] (전부 CWHN flat). +// 채널 순서는 변 우선이다 — [l·side_num, r·side_num, t·side_num, d·side_num]. +// 점수는 `cls_sigmoid × loc_confidence` 이고, **top-k 뒤에** 곱한다(mmdet score_factors 규약). +std::vector detect_sabl( + std::vector> const& cls, + std::vector> const& bcls, + std::vector> const& breg, + std::vector> const& feat_hw, sabl_params const& p); + // ── YOLOv3 (레벨당 한 갈래: na×(5+nc) 채널) ───────────────────────────────── struct yolov3_params { // ⚠️ YOLOv3 는 stride 가 **내림차순**이다(32,16,8). FPN 순서와 반대라 뒤집어 쓰면 diff --git a/tools/detect/head.cpp b/tools/detect/head.cpp index 7f07986..5e49c4a 100755 --- a/tools/detect/head.cpp +++ b/tools/detect/head.cpp @@ -209,6 +209,17 @@ static void tower_head_forward(model_ref m, std::vector const& feats, out.cls.push_back(cls); out.box.push_back(box); + // SABL 의 **두 번째 box 갈래**(버킷 분류). `reg_head` 와 채널 수가 같아 + // (side_num*4) 채널로는 못 가르므로 이름으로 받는다. `extra` 에 실어야 + // 잰 갈래가 빠지지 않는다 — 셋에 우겨넣으면 "통과" 가 거짓말이 된다. + if (!c.bbox_cls_head.empty()) { + tensor bcl = conv_same(m, c.bbox_cls_head + lv, rr); // reg 타워에서 뽑는다 + bcl = contiguous_2d_to_cwhn(m, bcl); + ggml_format_name(bcl, "bcls_%zu", l); + if (out.extra.empty()) out.extra.push_back({"bcls", {}}); + out.extra[0].second.push_back(bcl); + } + if (!c.centerness_head.empty()) { tensor ctr = conv_same(m, c.centerness_head + lv, c.centerness_on_reg ? rr : cc); if (c.ctr_tanh) ctr = ggml_tanh(m, ctr); @@ -1307,6 +1318,7 @@ void mmdet_head_forward(model_ref m, std::vector const& feats, case head_kind::rpn: // 〃 (`pre_conv` 로 rpn_conv 가 붙는다) case head_kind::fcos: case head_kind::gfl: + case head_kind::sabl: // 타워는 같다. box 갈래가 하나 더 붙을 뿐이다 tower_head_forward(m, feats, c, out); break; case head_kind::yolof: diff --git a/tools/detect/head.h b/tools/detect/head.h index 29c4ad7..064e8c9 100755 --- a/tools/detect/head.h +++ b/tools/detect/head.h @@ -27,6 +27,9 @@ enum class head_kind { gfl, // + DFL(분포 → 거리 기댓값). cls 가 품질까지 겸한다 vfnet, // star deformable refine (전용 함수) reppoints, // 점 집합 → bbox (전용 함수) + // 변마다 앵커를 칸으로 쪼개 **어느 칸인지 분류 + 칸 안 오프셋 회귀**. box 갈래가 둘이라 + // (bbox_cls·bbox_reg) 두 갈래 규약으로는 못 담는다 — 두 번째를 `extra` 에 싣는다. + sabl, tood, // task decomposition + deform sampling (전용 함수) centernet, // heatmap / wh / offset 세 갈래 (앵커 없음, 단일 레벨) yolof, // 단일 레벨 · 암묵 objectness (cls 와 obj 를 로그공간에서 합친다) @@ -58,6 +61,9 @@ struct anchor_head_cfg { std::string reg_convs_prefix = "bbox_head.reg_convs"; std::string cls_head = "bbox_head.retina_cls"; // 최종 cls conv std::string reg_head = "bbox_head.retina_reg"; // 최종 reg conv + // SABL 의 **두 번째 box 갈래**(버킷 분류). 비어 있으면 안 만든다. + // ⚠️ 채널 수가 `reg_head` 와 같아서(side_num*4) **채널로는 못 가른다** — 이름으로 가른다. + std::string bbox_cls_head; // 분기 conv 앞에 **하나만** 있는 공유 conv(+ReLU). RPNHead 의 `rpn_conv` 가 그렇다. // 컨테이너(ModuleList/Sequential)가 아니라 맨 Conv2d 라 타워 탐지에 안 걸린다. // 빼먹으면 256→256 이라 shape 이 안 변해 **조용히 틀린다**(rpn 실측 L1 5.56). diff --git a/tools/frontend/mmdet/mmdet_to_pt.py b/tools/frontend/mmdet/mmdet_to_pt.py index 31d8252..e093842 100755 --- a/tools/frontend/mmdet/mmdet_to_pt.py +++ b/tools/frontend/mmdet/mmdet_to_pt.py @@ -76,7 +76,8 @@ def emit_params(cfg, config_name): if k in cfg: out.append(f" c.head.{k} = {int(cfg[k])};\n") for k in ("cls_convs_prefix", "reg_convs_prefix", "cls_head", "reg_head", - "centerness_head", "scales_prefix", "per_level_head_tail", "pre_conv"): + "centerness_head", "scales_prefix", "per_level_head_tail", "pre_conv", + "bbox_cls_head"): if k in cfg: out.append(f' c.head.{k} = "{cfg[k]}";\n') for k in ("head_has_norm", "centerness_on_reg", "bbox_exp", "bbox_clamp_stride", "bbox_mul_stride", @@ -114,6 +115,11 @@ def emit_params(cfg, config_name): + ", ".join(_f(v) for v in lvl) + "});\n") if cfg.get("conf_thr"): out.append(f" c.det.conf_thr = {_f(cfg['conf_thr'])};\n") + # SABL 의 버킷 파라미터. 0 이면 안 싣는다. + if cfg.get("num_buckets"): + out.append(f" c.det.num_buckets = {int(cfg['num_buckets'])};\n") + out.append(f" c.det.bucket_scale = {_f(cfg.get('bucket_scale', 3.0))};\n") + out.append(f" c.det.anchor_scale = {_f(cfg.get('anchor_scale') or 4.0)};\n") # FoveaBox 만 채운다. 비어 있으면 디코더가 조립기가 낸 값을 그대로 거리로 쓴다. if cfg.get("bbox_base_edge"): out.append(" c.det.base_edge = {" diff --git a/tools/frontend/mmdet/mmdet_wrap.py b/tools/frontend/mmdet/mmdet_wrap.py index 01c38d3..5813806 100755 --- a/tools/frontend/mmdet/mmdet_wrap.py +++ b/tools/frontend/mmdet/mmdet_wrap.py @@ -244,6 +244,7 @@ def postproc_cfg(det): # 조립이 조용히 틀린다 — verify_heads.py 로 재기 전에는 지원한다고 말하지 마라. HEADS = { "VFNetHead": "vfnet", "RepPointsHead": "reppoints", "TOODHead": "tood", + "SABLRetinaHead": "sabl", "GFLHead": "gfl", "FCOSHead": "fcos", "RPNHead": "rpn", "ATSSHead": "anchor", "PAAHead": "anchor", "RetinaHead": "anchor", @@ -257,6 +258,13 @@ def postproc_cfg(det): kind = next((HEADS[c.__name__] for c in type(bh).__mro__ if c.__name__ in HEADS), None) if kind is None: return {"head_type": "raw"} # 모르는 계열 — 백본만 내보낸다 + # ⚠️ SABL 은 `prior_generator` 가 없다. 생성기가 **둘**이다 — + # `approx_anchor_generator`(라벨 할당용, 학습 전용)와 + # `square_anchor_generator`(디코드용, 위치당 1개). 추론이 쓰는 것은 **square** 다. + # approx 를 잡으면 위치당 9개가 되어 채널 해석이 통째로 어긋난다. + # ⚠️ `pg` 는 위에서 **이미 읽혔다** — 여기서 `bh` 에 심어도 늦다. `pg` 를 직접 바꾼다. + if kind == "sabl" and pg is None: + pg = getattr(bh, "square_anchor_generator", None) # ⚠️ prior_generator 는 **앵커 계열에만** 있다. CenterNet 은 heatmap 최대점, CornerNet 은 # 코너 짝짓기를 쓰므로 없다. 이걸 먼저 검사하면 조립 가능한 계열까지 raw 로 떨어진다. # 없다. 이걸 먼저 검사하면 조립 가능한 계열까지 raw 로 떨어진다. @@ -552,8 +560,13 @@ def _tc(key, default): "feat_channels": feat_ch, "cls_convs_prefix": "bbox_head." + cls_tower, "reg_convs_prefix": "bbox_head." + reg_tower, - "cls_head": "bbox_head." + (cls_head or "retina_cls"), - "reg_head": "bbox_head." + (reg_head or "retina_reg"), + # ⚠️ SABL 은 box 갈래가 둘이고 **채널 수가 같다**(side_num*4). 채널 탐지가 둘 다 + # 못 잡으므로 이름으로 박는다 — 채널로 가르려다 기본값(`retina_reg`)이 남아 + # `tensor not found` 로 죽었다. + "cls_head": "bbox_head." + (("retina_cls" if kind == "sabl" else None) + or cls_head or "retina_cls"), + "reg_head": "bbox_head." + (("retina_bbox_reg" if kind == "sabl" else None) + or reg_head or "retina_reg"), "head_has_norm": has_norm, "gn_groups": gn_groups, "per_level_towers": per_level, @@ -573,6 +586,14 @@ def _tc(key, default): "bbox_clamp_stride": bbox_clamp_stride, "bbox_mul_stride": bbox_mul_stride, "bbox_base_edge": bbox_base_edge, + # SABL: 두 번째 box 갈래 이름과 버킷 파라미터. + "bbox_cls_head": ("bbox_head.retina_bbox_cls" if kind == "sabl" else ""), + "num_buckets": int(getattr(bc, "num_buckets", 0) or 0), + # ⚠️ **`octave_base_scale` 이 아니다.** SABL 의 square 생성기는 `scales=[4]` 로 크기를 + # 준다(`octave_base_scale` 은 None). 재사용하면 1.0 으로 떨어져 앵커가 stride 크기가 + # 되고, 디코드 공식이 맞아도 박스가 통째로 작아진다(실측 146px). + "anchor_scale": float((scales or [1.0])[0]) if kind == "sabl" else 0.0, + "bucket_scale": float(getattr(bc, "scale_factor", 0.0) or 0.0), "head_silu": head_silu, "reg_max": reg_max, # VFNet 의 레벨별 정규화 범위. stride 에서 유도하면 안 된다 — 마지막 레벨만 두 배다. diff --git a/tools/verify/backbone/run_mmdet.cpp b/tools/verify/backbone/run_mmdet.cpp index b3cab3a..8724619 100755 --- a/tools/verify/backbone/run_mmdet.cpp +++ b/tools/verify/backbone/run_mmdet.cpp @@ -461,6 +461,24 @@ int main(int argc, char** argv) { dets = detect_rpn(cls_v, box_v, feat_hw, rp); // `label` 은 레벨 번호로 돌아온다. 밖에서는 클래스 자리라 0(유일한 클래스)으로 둔다. for (auto& d : dets) d.label = 0; + } else if (hc.kind == head_kind::sabl) { + // 변마다 버킷 분류 + 오프셋. 두 번째 box 갈래는 `extra[0]`("bcls") 로 온다. + sabl_params sp; + sp.strides = dp.strides; + sp.anchor_scale = dp.anchor_scale; + sp.num_buckets = dp.num_buckets; + sp.bucket_scale = dp.bucket_scale; + sp.num_classes = dp.num_classes; + sp.score_thr = dp.score_thr; + sp.nms_thr = dp.nms_thr; + sp.nms_pre = dp.nms_pre; + sp.max_per_img = dp.max_per_img; + sp.input_w = dp.input_w; + sp.input_h = dp.input_h; + std::vector> bcls_v(L); + if (!ho.extra.empty() && (int)ho.extra[0].second.size() >= L) + for (int l = 0; l < L; ++l) bcls_v[l] = to_vec(ho.extra[0].second[l]); + dets = detect_sabl(cls_v, bcls_v, box_v, feat_hw, sp); } else if (hc.kind == head_kind::yolo) { // YOLOv3: 레벨당 **한 갈래**(na×(5+nc))라 조립기가 `out.cls` 에만 담는다. // 앵커가 (w,h) 쌍이고 objectness 로 먼저 거른다 — 전용 디코더로 보낸다. diff --git a/tools/verify/dense_head/verify_heads.py b/tools/verify/dense_head/verify_heads.py index 783a4ae..7b4a738 100644 --- a/tools/verify/dense_head/verify_heads.py +++ b/tools/verify/dense_head/verify_heads.py @@ -251,6 +251,13 @@ def _all_families(): elif isinstance(outs, tuple) and len(outs) == 1: # YOLOv3 는 `return tuple(pred_maps),` — 한 갈래를 **또 튜플로 감싸** 돌려준다. cls_l = list(outs[0]) +elif isinstance(outs, tuple) and len(outs) == 2 and isinstance(outs[1][0], tuple): + # SABL: `forward_single` 이 `(cls_score, (bbox_cls_pred, bbox_reg_pred))` 를 낸다 — + # box 갈래가 **중첩 튜플**이라 그대로 쓰면 `'tuple' object has no attribute 'shape'`. + # C++ 조립기와 **같은 이름**(`bcls`)으로 갈라야 덤프가 겹친다. + cls_l = list(outs[0]) + box_l = [b[1] for b in outs[1]] # bbox_reg_pred + extra["bcls"] = [b[0] for b in outs[1]] # bbox_cls_pred (버킷 분류) elif isinstance(outs, tuple) and len(outs) == 3: cls_l, box_l, ctr_l = [list(t) for t in outs] else: From e8bae5215d84782bf57aad774c40ff245cf05afc Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 12:21:14 +0900 Subject: [PATCH 49/89] =?UTF-8?q?docs:=20sabl=200.55px=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=E2=80=94=20one-stage=2025=EA=B3=84=EC=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mmdet-detectors.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index ec8bc05..8df473b 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -310,6 +310,7 @@ no label mismatch and no difference in how many boxes survive. | `nas_fpn` | `detect_anchor` | 1.04 px | 0.004 | | `yolof` | `detect_anchor` (ctr_clamp) | 0.12 px | 0.002 | | `pvt` | `detect_anchor` (PVT-Tiny) | 0.55 px | 0.005 | +| `sabl` | `detect_sabl` (buckets) | 0.55 px | 0.003 | | `nas_fcos` | `detect_fcos` | 0.19 px | 0.001 | | `reppoints` | `detect_fcos` (xyxy offset) | 0.25 px | 0.006 | | `autoassign` | `detect_fcos` | 0.30 px | 0.006 | From b9439a33aa7f7c57d50c3c57f97a9c9979b516ef Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 12:35:57 +0900 Subject: [PATCH 50/89] =?UTF-8?q?feat(htc):=20=EC=8B=9C=EB=A7=A8=ED=8B=B1?= =?UTF-8?q?=20=EA=B0=88=EB=9E=98=20=EC=9C=B5=ED=95=A9=20(94px=20=E2=86=92?= =?UTF-8?q?=200.12px)=20+=20swin=20=EB=A7=88=EC=8A=A4=ED=81=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## htc — 시맨틱 융합 HTC 는 FPN 위에 FCN(`FusedSemanticHead`)을 따로 돌려 나온 융합 feature 를 RoIAlign 해서 `bbox_feats` 에 **더한다**(htc_roi_head.py:99-104). 그게 빠져 있었다. 착수 전에 확인한 것이 결정적이었다: **시맨틱을 끈 torch = 우리 C++ (0.07px, 5/5건)** 즉 캐스케이드·RoI 경로는 이미 완전히 정확했고 이 갈래 하나만 빠져 있었다. 그래서 구현하면 닫힌다는 걸 알고 시작했다 — 다시간 작업 전에 가설을 재는 값이 여기 있다. ⚠️ **별도 그래프로 빼지 않았다.** 입력이 FPN 전 레벨이라 다중 입력 그래프가 되는데 컴파일 경로가 단일 입력만 받는다. `FRCNN_SubA` 의 **출력 하나**로 붙이면 백본을 두 번 돌 필요도 없다. ⚠️ **캐스케이드 단계마다 융합한다.** mmdet 은 `_bbox_forward(stage, …, semantic_feat)` 를 매 단계 부른다 — 0단계만 더하면 뒤 단계가 융합 없이 돈다. ⚠️ 시맨틱 RoI 출력이 **14**, bbox 쪽이 7 이다. mmdet 은 `adaptive_avg_pool2d` 로 줄인다. 14→7 은 정수배라 2×2 평균이면 정확하다. htc 94.24px → **0.12px PASS**. detectors 30→27px · scnet 29→16px (아직 미통과 — 각자 RFP/SAC·SCNet 전용 갈래가 더 있다). ## swin — 어텐션 마스크가 전부 0이었다 (동료 세션 패치) `ShiftWindowMSA.forward` 가 `img_mask[:, h, w, :] = cnt` 슬라이스 대입으로 9개 영역을 채우는데(swin.py:201-212), trace 가 이 in-place(`aten::index_put_`)를 안 실어 **마스크가 전부 0** 이 됐다. 크래시 없이 shifted 블록마다 경계 윈도가 roll 로 이어붙은 반대편 픽셀에 어텐션한다. stage 가 깊을수록 경계 윈도 비율이 10%→56% 로 커져 가속된다. 입력 크기가 고정이면 마스크는 상수다 — eager 에서 원식 그대로 한 번 계산해 버퍼로 굽고 trace 는 버퍼를 읽는다. 그래서 `torch.save` **전에** dummy forward 가 필요하다. swin 텐서 L1 0.818 → 0.002 · 박스 **0.22px PASS** 분석·패치: 동료 세션(20260512-e7). 검증은 이 트리에서 재현했다. Co-Authored-By: Claude Opus 5 (1M context) --- tools/frontend/mmdet/frcnn_to_pt.py | 17 +++--- tools/frontend/mmdet/frcnn_wrap.py | 27 +++++++++- tools/frontend/mmdet/mmdet_compat.py | 81 ++++++++++++++++++++++++++++ tools/verify/backbone/run_frcnn.cpp | 54 +++++++++++++++++-- 4 files changed, 167 insertions(+), 12 deletions(-) diff --git a/tools/frontend/mmdet/frcnn_to_pt.py b/tools/frontend/mmdet/frcnn_to_pt.py index 478a4ac..1dac8d4 100755 --- a/tools/frontend/mmdet/frcnn_to_pt.py +++ b/tools/frontend/mmdet/frcnn_to_pt.py @@ -68,6 +68,15 @@ def main(argv=None): # 클래스가 `frcnn_wrap` 이름으로 절여진다 → 로더가 import 할 수 있게 같이 둔다. for f in mmdet_compat.install_loader_modules(a.out, "frcnn_wrap"): print(f" → loader module: {f}") + # ⚠️ **저장 전에 dummy forward 를 한 번 돈다.** eager forward 가 있어야 만들어지는 + # 상수 버퍼가 있다 — swin 의 shifted-window 어텐션 마스크(`_visp_attn_mask`, + # mmdet_compat._patch_swin_mask)가 그렇다. 저장을 먼저 하면 버퍼가 .pt 에 안 실리고, + # trace 가 마스크를 0 으로 만들어 **크래시 없이 값만 틀린다**(swin 실측 L1 0.82). + # feat_hw 도 같은 forward 에서 얻는다(neck 없는 C4 계열은 건너뛴다 — 아래 참고). + with torch.no_grad(): + feats = det.backbone(torch.zeros(1, 3, a.size, a.size)) + if getattr(det, "neck", None) is not None: + feats = det.neck(feats) torch.save(FRCNN_SubA(det).eval(), f"{a.out}/FRCNN_SubA.pt") # 이미지 → 14 출력 from frcnn_wrap import num_bbox_stages ns = num_bbox_stages(det) @@ -80,13 +89,7 @@ def main(argv=None): cfg = frcnn_cfg(det, a.size) if cfg.get("has_mask"): torch.save(MaskRCNN_SubC(det).eval(), f"{a.out}/MaskRCNN_SubC.pt") # mask_feat → mask_logits - # feat_hw (P2-P6) — 러너 CWHN flat 해석용. dummy forward 로 크기 취득. - # ⚠️ neck 이 **없는** 계열이 있다(C4 계열 TridentNet). `FRCNN_SubA` 와 같은 규약으로 - # 건너뛴다 — 여기만 빼먹으면 `frcnn.json` 이 안 나와 "two-stage 아님" 으로 오분류된다. - with torch.no_grad(): - feats = det.backbone(torch.zeros(1, 3, a.size, a.size)) - if getattr(det, "neck", None) is not None: - feats = det.neck(feats) + # feat_hw (P2-P6) — 러너 CWHN flat 해석용. 위 dummy forward 에서 얻었다. cfg["feat_hw"] = [[int(f.shape[2]), int(f.shape[3])] for f in feats] json.dump(cfg, open(f"{a.out}/frcnn.json", "w"), indent=2) print(f" → {a.out}/FRCNN_SubA.pt, FRCNN_SubB.pt, frcnn.json (feat_hw={cfg['feat_hw']})") diff --git a/tools/frontend/mmdet/frcnn_wrap.py b/tools/frontend/mmdet/frcnn_wrap.py index 98504ce..7ac3084 100755 --- a/tools/frontend/mmdet/frcnn_wrap.py +++ b/tools/frontend/mmdet/frcnn_wrap.py @@ -39,13 +39,24 @@ def __init__(self, det): self.neck = getattr(det, "neck", None) self.rpn_head = det.rpn_head self.n_roi = _n_roi_levels(det) + # HTC 계열의 **시맨틱 갈래**. FPN 위에 FCN 을 따로 돌려 나온 융합 feature 를 + # RoIAlign 해서 `bbox_feats` 에 더한다(htc_roi_head.py:99-104). + # ⚠️ **별도 그래프로 빼지 않는다.** 입력이 FPN 전 레벨이라 다중 입력 그래프가 되는데, + # 컴파일 경로가 단일 입력만 받는다. 같은 그래프의 **출력 하나**로 붙이면 + # 백본을 두 번 돌 필요도 없다. + self.semantic_head = getattr(det.roi_head, "semantic_head", None) def forward(self, x): feats = self.backbone(x) if getattr(self, "neck", None) is not None: feats = self.neck(feats) # tuple len 5: P2..P6 rpn_cls, rpn_bbox = self.rpn_head(feats) # (listL, listL) - return tuple(feats[:getattr(self, "n_roi", 4)]) + tuple(rpn_cls) + tuple(rpn_bbox) + out = tuple(feats[:getattr(self, "n_roi", 4)]) + tuple(rpn_cls) + tuple(rpn_bbox) + if getattr(self, "semantic_head", None) is not None: + # `(mask_preds, fused)` 중 **fused** 가 bbox 융합에 쓰이는 쪽이다. + _, sem = self.semantic_head(feats) + out = out + (sem,) + return out class FRCNN_SubB(nn.Module): @@ -155,10 +166,22 @@ def frcnn_cfg(det, size=800): "mask_finest_scale": int(getattr(mext, "finest_scale", 56)), "mask_thr_binary": float(rcnn_c.mask_thr_binary), } + # HTC 의 시맨틱 융합 파라미터. 없으면 안 싣는다. + sem = {} + if getattr(det.roi_head, "semantic_head", None) is not None: + sext = det.roi_head.semantic_roi_extractor + sem = { + "has_semantic": True, + "sem_roi_out": int(sext.roi_layers[0].output_size[0]), # 14 + "sem_stride": float(sext.featmap_strides[0]), # 8 + "sem_channels": int(sext.out_channels), + "sem_sampling_ratio": int(sext.roi_layers[0].sampling_ratio), + "sem_aligned": bool(sext.roi_layers[0].aligned), + } ns = num_bbox_stages(det) heads = det.roi_head.bbox_head heads = list(heads) if ns > 1 else [heads] - return {**mask, + return {**mask, **sem, # 캐스케이드: 단계 수와 **단계별 bbox 정규화 상수**. 단계마다 다르다 # (예: [0.1,0.1,0.2,0.2] → [0.05,0.05,0.1,0.1] → [0.033,0.033,0.067,0.067]). "num_bbox_stages": ns, diff --git a/tools/frontend/mmdet/mmdet_compat.py b/tools/frontend/mmdet/mmdet_compat.py index 38dabae..1758896 100644 --- a/tools/frontend/mmdet/mmdet_compat.py +++ b/tools/frontend/mmdet/mmdet_compat.py @@ -155,6 +155,87 @@ def sigmoid_geometric_mean(x, y): _tood.sigmoid_geometric_mean = sigmoid_geometric_mean _patch_carafe() + _patch_swin_mask() + + +def _patch_swin_mask(): + """Swin 의 shifted-window 어텐션 마스크를 **버퍼로 미리 굽는다.** + + `ShiftWindowMSA.forward` 는 `torch.zeros` 로 img_mask 를 만들고 **슬라이스 대입** + (`img_mask[:, h, w, :] = cnt`, swin.py:201-212)으로 9개 영역 번호를 채우는데, trace 에서 + 이 in-place 대입(`aten::index_put_`)이 그래프에 안 실려 **마스크가 전부 0** 이 된다. + 0 마스크는 크래시가 없다 — shifted 블록마다 경계 윈도가 roll 로 이어붙은 반대편 픽셀에 + 어텐션하고 값만 조용히 틀린다(swin-t 실측: shifted 블록당 rel L1 +0.004~0.06, + stage 가 깊을수록 경계 윈도 비율이 10%→56% 로 커져 최종 rpn 출력 0.82). + + 입력 크기가 고정이면 이 마스크는 **상수**다. eager (export dry-run) forward 에서 + mmdet 원식 그대로 계산해 버퍼로 등록해 두고, trace 는 버퍼를 읽게 한다 — + `relative_position_index` 와 같은 경로로 GGUF 에 실린다. 값은 동일하므로 torch + 기준값 쪽도 이 패치를 지나도 결과가 같다. + """ + try: + from mmdet.models.backbones.swin import ShiftWindowMSA + except Exception: + return # swin 이 없는 환경 — 조용히 넘어간다 + if getattr(ShiftWindowMSA, "_visp_mask_patched", False): + return + import torch + import torch.nn.functional as F + + def forward(self, query, hw_shape): + B, L, C = query.shape + H, W = hw_shape + assert L == H * W, 'input feature has wrong size' + query = query.view(B, H, W, C) + ws, ss = self.window_size, self.shift_size + pad_r = (ws - W % ws) % ws + pad_b = (ws - H % ws) % ws + query = F.pad(query, (0, 0, 0, pad_r, 0, pad_b)) + H_pad, W_pad = query.shape[1], query.shape[2] + + if ss > 0: + shifted_query = torch.roll(query, shifts=(-ss, -ss), dims=(1, 2)) + # 원식 그대로 — 단 한 번(eager)만 계산하고 결과를 버퍼에 박는다. + if getattr(self, "_visp_mask_hw", None) != (H_pad, W_pad): + with torch.no_grad(): + img_mask = torch.zeros((1, H_pad, W_pad, 1), device=query.device) + sl = (slice(0, -ws), slice(-ws, -ss), slice(-ss, None)) + cnt = 0 + for h in sl: + for w in sl: + img_mask[:, h, w, :] = cnt + cnt += 1 + mask_windows = self.window_partition(img_mask) + mask_windows = mask_windows.view(-1, ws * ws) + attn_mask = mask_windows.unsqueeze(1) - mask_windows.unsqueeze(2) + attn_mask = attn_mask.masked_fill(attn_mask != 0, float(-100.0) + ).masked_fill(attn_mask == 0, float(0.0)) + if hasattr(self, "_visp_attn_mask"): + del self._visp_attn_mask + self.register_buffer("_visp_attn_mask", attn_mask, persistent=True) + self._visp_mask_hw = (H_pad, W_pad) + attn_mask = self._visp_attn_mask + else: + shifted_query = query + attn_mask = None + + query_windows = self.window_partition(shifted_query) + query_windows = query_windows.view(-1, ws**2, C) + attn_windows = self.w_msa(query_windows, mask=attn_mask) + attn_windows = attn_windows.view(-1, ws, ws, C) + shifted_x = self.window_reverse(attn_windows, H_pad, W_pad) + if ss > 0: + x = torch.roll(shifted_x, shifts=(ss, ss), dims=(1, 2)) + else: + x = shifted_x + if pad_r > 0 or pad_b: + x = x[:, :H, :W, :].contiguous() + x = x.view(B, H * W, C) + x = self.drop(x) + return x + + ShiftWindowMSA.forward = forward + ShiftWindowMSA._visp_mask_patched = True def _patch_carafe(): diff --git a/tools/verify/backbone/run_frcnn.cpp b/tools/verify/backbone/run_frcnn.cpp index 3e137c1..9b32406 100644 --- a/tools/verify/backbone/run_frcnn.cpp +++ b/tools/verify/backbone/run_frcnn.cpp @@ -160,6 +160,14 @@ int main(int argc, char** argv) { outs.size(), NF + 2 * L, NF, L); return 3; } + // HTC 의 시맨틱 융합 feature 는 rpn 출력 **뒤에** 하나 더 붙어 온다. + const bool has_sem = J.num("has_semantic", 0.0f) != 0.0f; + std::vector sem_feat; + std::pair sem_hw{0, 0}; + if (has_sem && (int)outs.size() > NF + 2 * L) { + sem_feat = outs[NF + 2 * L]; + sem_hw = all_hw[NF + 2 * L]; + } std::vector> feats(outs.begin(), outs.begin() + NF); std::vector> rpn_cls(outs.begin() + NF, outs.begin() + NF + L); std::vector> rpn_box(outs.begin() + NF + L, outs.begin() + NF + 2 * L); @@ -194,6 +202,39 @@ int main(int argc, char** argv) { ap.aligned = J.num("roi_aligned", 1.0f) != 0.0f; std::vector roi = roi_align(feats, feat_hw, props.data(), M, ap); + // ── HTC: 시맨틱 feature 를 RoIAlign 해서 bbox_feats 에 **더한다** ────────── + // mmdet `htc_roi_head.py:99-104`. 안 더하면 크래시 없이 박스만 밀린다 + // (실측: 이걸 뺀 torch 가 우리 결과와 0.07px 로 일치했다 — 유일한 차이였다). + // ⚠️ 시맨틱 RoI 출력 크기가 **14** 라 bbox 쪽 7 과 다르다. mmdet 은 그때 + // `adaptive_avg_pool2d` 로 줄인다. 14→7 은 정수배라 2×2 평균이면 정확하다. + auto fuse_semantic = [&](std::vector& rf, float const* boxes, int m) { + if (!has_sem || sem_feat.empty()) return; + roi_align_params sp; + sp.output_size = (int)J.num("sem_roi_out", 14.0f); + sp.channels = (int)J.num("sem_channels", 256.0f); + sp.strides = {J.num("sem_stride", 8.0f)}; + sp.finest_scale = ap.finest_scale; + sp.sampling_ratio = (int)J.num("sem_sampling_ratio", 0.0f); + sp.aligned = J.num("sem_aligned", 1.0f) != 0.0f; + std::vector> sf{sem_feat}; + std::vector> shw{sem_hw}; + std::vector s = roi_align(sf, shw, boxes, m, sp); + const int C = sp.channels, SO = sp.output_size, O = ap.output_size; + const int k = SO / O; // 14/7 = 2 + if (k < 1 || SO != k * O) return; // 정수배가 아니면 건너뛴다 + for (int i = 0; i < m; ++i) + for (int c = 0; c < C; ++c) + for (int y = 0; y < O; ++y) + for (int x = 0; x < O; ++x) { + float acc = 0.0f; + for (int dy = 0; dy < k; ++dy) + for (int dx = 0; dx < k; ++dx) + acc += s[(((size_t)i * C + c) * SO + y * k + dy) * SO + x * k + dx]; + rf[(((size_t)i * C + c) * O + y) * O + x] += acc / (k * k); + } + }; + fuse_semantic(roi, props.data(), M); + // 캐스케이드는 단계마다 박스를 정제하고 **그 박스로 RoIAlign 을 다시** 한다. // 단계 수와 단계별 정규화 상수는 프론트엔드가 frcnn.json 에 실어 준다. const int NS = (int)J.num("num_bbox_stages", 1); @@ -245,9 +286,16 @@ int main(int argc, char** argv) { }; for (int st = 0; st < NS; ++st) { - std::vector roi_st = with_reg_half( - (st == 0) ? roi : roi_align(feats, feat_hw, rois.data(), M, ap), - (st == 0) ? props : rois); + // ⚠️ 시맨틱 융합은 **단계마다** 건다. mmdet 의 `_bbox_forward(stage, …, semantic_feat)` + // 가 매 단계 부른다 — 0단계만 더하면 뒤 단계가 융합 없이 돈다. + std::vector base_st; + if (st == 0) { + base_st = roi; // 위에서 이미 융합했다 + } else { + base_st = roi_align(feats, feat_hw, rois.data(), M, ap); + fuse_semantic(base_st, rois.data(), M); + } + std::vector roi_st = with_reg_half(base_st, (st == 0) ? props : rois); const int MB = (RSF > 0.0f) ? 2 * M : M; // SubB 에 넣는 행 수 model_file fb = model_load(gbs[st].c_str()); From 7ccc4bd8a9b1e82016bb6fa4853ac5d2f9eb8479 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 12:36:26 +0900 Subject: [PATCH 51/89] =?UTF-8?q?docs:=20htc=200.12px=20=C2=B7=20swin=200.?= =?UTF-8?q?22px=20=EC=B6=94=EA=B0=80=20=E2=80=94=20two-stage=2023=EA=B3=84?= =?UTF-8?q?=EC=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mmdet-detectors.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 8df473b..c0b823c 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -347,7 +347,7 @@ file, so running from anywhere else fails to find it and the family looks broken Two-stage families are measured separately, at 800 and against the detector's own `predict` rather than a head's `predict_by_feat`, because the boxes do not exist until RPN proposals, RoIAlign and the RoI head have run. The harness is `tools/verify/roi/verify_postproc_roi.py` -and the thresholds are the same. Twenty-one of the forty families with a `roi_head` agree: +and the thresholds are the same. Twenty-three of the forty families with a `roi_head` agree: | Family | Decoder | Worst box | Worst score | | :--- | :--- | ---: | ---: | @@ -356,6 +356,8 @@ and the thresholds are the same. Twenty-one of the forty families with a `roi_he | `carafe` | `detect_roi` | 0.06 px | 0.0007 | | `hrnet` | `detect_roi` | 0.06 px | 0.0002 | | `gcnet` | `detect_roi` | 0.14 px | 0.0010 | +| `htc` | `detect_roi` (3 stages + semantic) | 0.12 px | 0.0011 | +| `swin` | `detect_roi` | 0.22 px | 0.0003 | | `mask_rcnn` | `detect_roi` | 0.06 px | 0.0009 | | `gn+ws` | `detect_roi` | 0.08 px | 0.0008 | | `cascade_rcnn` | `detect_roi` (3 stages) | 0.09 px | 0.0025 | From a075adfe2e4ea00c9a1e79cf6dc11a5af7c9ee9d Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 12:43:12 +0900 Subject: [PATCH 52/89] =?UTF-8?q?feat(scnet):=20=EC=A0=84=EC=97=AD=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=9C=B5=ED=95=A9=20(16?= =?UTF-8?q?.32px=20=E2=86=92=200.18px)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SCNet 은 `_bbox_forward` 에서 시맨틱에 더해 **전역 컨텍스트**를 융합한다 (`scnet_roi_head.py:162-163`). 시맨틱은 htc 작업으로 이미 걸려 29→16px 로 줄었고, 남은 것이 이것이었다. 착수 전에 확인했다 — htc 에서 통한 방법 그대로: **glbctx 만 끈 torch = 우리 C++ (0.45px 이내, 4/4건)** 그래서 이 하나만 빠졌다는 걸 알고 시작했다. `GlobalContextHead` 는 최상위 레벨에 conv 몇 개 + `AdaptiveAvgPool(1)` 이라 결과가 **이미지당 채널 벡터 하나**(B,C,1,1)다. `_fuse_glbctx` 는 그걸 모든 RoI feature 에 채널별로 더한다 — 배치가 1 이므로 전 RoI·전 위치에 같은 값을 더하면 된다. 시맨틱과 같은 방식으로 `FRCNN_SubA` 출력에 붙였다(별도 그래프 불필요). 캐스케이드 단계마다 거는 것도 시맨틱과 같다. scnet 16.32px → **0.18px PASS** 회귀: htc 0.12px · cascade_rcnn 0.09px 그대로. Co-Authored-By: Claude Opus 5 (1M context) --- tools/frontend/mmdet/frcnn_wrap.py | 10 ++++++++++ tools/verify/backbone/run_frcnn.cpp | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/tools/frontend/mmdet/frcnn_wrap.py b/tools/frontend/mmdet/frcnn_wrap.py index 7ac3084..c639016 100755 --- a/tools/frontend/mmdet/frcnn_wrap.py +++ b/tools/frontend/mmdet/frcnn_wrap.py @@ -45,6 +45,10 @@ def __init__(self, det): # 컴파일 경로가 단일 입력만 받는다. 같은 그래프의 **출력 하나**로 붙이면 # 백본을 두 번 돌 필요도 없다. self.semantic_head = getattr(det.roi_head, "semantic_head", None) + # SCNet 의 **전역 컨텍스트**. 최상위 레벨에 conv 몇 개 + AdaptiveAvgPool(1) 이라 + # 결과가 **이미지당 채널 벡터 하나**이고, 모든 RoI feature 에 채널별로 더해진다 + # (`scnet_roi_head._fuse_glbctx`). 시맨틱과 같은 방식으로 출력에 붙인다. + self.glbctx_head = getattr(det.roi_head, "glbctx_head", None) def forward(self, x): feats = self.backbone(x) @@ -56,6 +60,10 @@ def forward(self, x): # `(mask_preds, fused)` 중 **fused** 가 bbox 융합에 쓰이는 쪽이다. _, sem = self.semantic_head(feats) out = out + (sem,) + if getattr(self, "glbctx_head", None) is not None: + # `(mc_pred, x)` 중 **x** 가 융합에 쓰인다. shape 은 (B, C, 1, 1). + _, glb = self.glbctx_head(feats) + out = out + (glb,) return out @@ -178,6 +186,8 @@ def frcnn_cfg(det, size=800): "sem_sampling_ratio": int(sext.roi_layers[0].sampling_ratio), "sem_aligned": bool(sext.roi_layers[0].aligned), } + if getattr(det.roi_head, "glbctx_head", None) is not None: + sem["has_glbctx"] = True ns = num_bbox_stages(det) heads = det.roi_head.bbox_head heads = list(heads) if ns > 1 else [heads] diff --git a/tools/verify/backbone/run_frcnn.cpp b/tools/verify/backbone/run_frcnn.cpp index 9b32406..f710502 100644 --- a/tools/verify/backbone/run_frcnn.cpp +++ b/tools/verify/backbone/run_frcnn.cpp @@ -168,6 +168,13 @@ int main(int argc, char** argv) { sem_feat = outs[NF + 2 * L]; sem_hw = all_hw[NF + 2 * L]; } + // SCNet 의 전역 컨텍스트는 시맨틱 **다음** 자리에 온다(둘 다 있으면 +1, +2). + const bool has_glb = J.num("has_glbctx", 0.0f) != 0.0f; + std::vector glb_feat; + if (has_glb) { + const int gi = NF + 2 * L + (has_sem ? 1 : 0); + if ((int)outs.size() > gi) glb_feat = outs[gi]; + } std::vector> feats(outs.begin(), outs.begin() + NF); std::vector> rpn_cls(outs.begin() + NF, outs.begin() + NF + L); std::vector> rpn_box(outs.begin() + NF + L, outs.begin() + NF + 2 * L); @@ -233,7 +240,24 @@ int main(int argc, char** argv) { rf[(((size_t)i * C + c) * O + y) * O + x] += acc / (k * k); } }; + // ── SCNet: 전역 컨텍스트를 모든 RoI feature 에 **채널별로 더한다** ──────── + // `scnet_roi_head._fuse_glbctx` — glbctx 는 AdaptiveAvgPool(1) 을 거쳐 이미지당 + // 채널 벡터 하나다(B,C,1,1). 배치가 1 이므로 그 벡터를 전 RoI·전 위치에 더한다. + // 안 더하면 크래시 없이 박스만 밀린다(실측: 이걸 뺀 torch 가 우리 결과와 0.45px). + auto fuse_glbctx = [&](std::vector& rf, int m) { + if (!has_glb || glb_feat.empty()) return; + const int C = ap.channels, O = ap.output_size; + if ((int)glb_feat.size() < C) return; + for (int i = 0; i < m; ++i) + for (int c = 0; c < C; ++c) { + const float g = glb_feat[c]; + for (int y = 0; y < O; ++y) + for (int x = 0; x < O; ++x) + rf[(((size_t)i * C + c) * O + y) * O + x] += g; + } + }; fuse_semantic(roi, props.data(), M); + fuse_glbctx(roi, M); // 캐스케이드는 단계마다 박스를 정제하고 **그 박스로 RoIAlign 을 다시** 한다. // 단계 수와 단계별 정규화 상수는 프론트엔드가 frcnn.json 에 실어 준다. @@ -294,6 +318,7 @@ int main(int argc, char** argv) { } else { base_st = roi_align(feats, feat_hw, rois.data(), M, ap); fuse_semantic(base_st, rois.data(), M); + fuse_glbctx(base_st, M); } std::vector roi_st = with_reg_half(base_st, (st == 0) ? props : rois); const int MB = (RSF > 0.0f) ? 2 * M : M; // SubB 에 넣는 행 수 From fdc8695cf05db1f2bec1cbcc98cc30d2eff29731 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 12:43:12 +0900 Subject: [PATCH 53/89] =?UTF-8?q?docs:=20scnet=200.18px=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=E2=80=94=20two-stage=2024=EA=B3=84=EC=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mmdet-detectors.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index c0b823c..5a70017 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -347,7 +347,7 @@ file, so running from anywhere else fails to find it and the family looks broken Two-stage families are measured separately, at 800 and against the detector's own `predict` rather than a head's `predict_by_feat`, because the boxes do not exist until RPN proposals, RoIAlign and the RoI head have run. The harness is `tools/verify/roi/verify_postproc_roi.py` -and the thresholds are the same. Twenty-three of the forty families with a `roi_head` agree: +and the thresholds are the same. Twenty-four of the forty families with a `roi_head` agree: | Family | Decoder | Worst box | Worst score | | :--- | :--- | ---: | ---: | @@ -357,6 +357,7 @@ and the thresholds are the same. Twenty-three of the forty families with a `roi_ | `hrnet` | `detect_roi` | 0.06 px | 0.0002 | | `gcnet` | `detect_roi` | 0.14 px | 0.0010 | | `htc` | `detect_roi` (3 stages + semantic) | 0.12 px | 0.0011 | +| `scnet` | `detect_roi` (+ global context) | 0.18 px | 0.0020 | | `swin` | `detect_roi` | 0.22 px | 0.0003 | | `mask_rcnn` | `detect_roi` | 0.06 px | 0.0009 | | `gn+ws` | `detect_roi` | 0.08 px | 0.0008 | From 82364e1137bd43b533a9b8d124133486857d87b9 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 12:48:42 +0900 Subject: [PATCH 54/89] =?UTF-8?q?fix(sac):=20DetectoRS=20=EC=9D=98=20dilat?= =?UTF-8?q?ion-3=20deform=20conv=20=EB=A5=BC=20=EC=98=A4=ED=94=84=EC=85=8B?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99=EC=9C=BC=EB=A1=9C=20=EB=93=B1=EA=B0=80=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20(27px=20=E2=86=92=200.03px)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SAConv2d.forward` 의 out_l 갈래는 `deform_conv2d(..., padding=3·p, dilation=3·d)` 로 부르는데, ggml `conv_2d_deform` 과 mmcv-Function 렌더러에 **dilation 인자가 없어** `pad=(k−1)//2 · dil=1` 로 렌더된다. base 샘플링 격자가 통째로 어긋나는데 출력 크기는 같아서 크래시가 없다. deform conv 의 탭 (i,j) 샘플 위치는 `h0·s − p + i·d + Δh` 다. 따라서 (3p, 3d) 는 **오프셋에 상수 `2·d·i − 2·p` 를 더한 (p, d)** 와 정확히 같은 위치를 읽는다. 출력 크기도 같다 — dilated kernel 이 `d·(k−1)+1` 이므로 (H+6−7)/s+1 = (H+2−3)/s+1. 탭별 상수를 첫 eager forward 에서 `_visp_dil_shift` 버퍼로 굽고 dilation 1 로 부른다. 등가 변환이라 torch 기준값도 값이 같다. swin 마스크와 같은 버퍼 패턴이고, `frcnn_to_pt` 의 저장-전-forward 가 이미 있어 추가 조건이 없다. ⚠️ detectors 가 실제로 쓰는 config 는 `htc_r50-sac` 다 — **RFP 가 아니라 SAC** 다. SubA 이등분 실측이 그걸 갈랐다(out_s 0.001 vs **out_l 0.753**). detectors 27.08px → **0.03px PASS** (SubA out_0..13 rel L1 0.46~0.97 → 0.0002~0.0012) 회귀: dcn·dcnv2 박스 그대로 · vfnet·reppoints·tood·ddod·nas_fcos 텐서 그대로. 분석·패치: 동료 세션(20260512-e7). 검증은 이 트리에서 재현했다. Co-Authored-By: Claude Opus 5 (1M context) --- tools/frontend/mmdet/mmdet_compat.py | 83 ++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tools/frontend/mmdet/mmdet_compat.py b/tools/frontend/mmdet/mmdet_compat.py index 1758896..d99b9c8 100644 --- a/tools/frontend/mmdet/mmdet_compat.py +++ b/tools/frontend/mmdet/mmdet_compat.py @@ -156,8 +156,91 @@ def sigmoid_geometric_mean(x, y): _patch_carafe() _patch_swin_mask() + _patch_sac_dilation() + +def _patch_sac_dilation(): + """SAC(DetectoRS)의 dilation-3 deform conv 를 **오프셋 상수 이동**으로 등가 변환한다. + + `SAConv2d.forward` 의 out_l 갈래는 `deform_conv2d(x, offset, w, stride, 3·pad, 3·dil)` + 인데, ggml `conv_2d_deform` 과 mmcv-Function 렌더러는 **dilation 인자가 없다** — + pad=(k-1)//2 · dil=1 로 추론해 렌더하므로 out_l 의 base 샘플링 격자가 통째로 어긋난다. + 크래시는 없고 out_l 만 조용히 틀린다(detectors 실측: out_s 0.001 vs **out_l 0.753**, + layer3 까지 0.94 로 증폭). + + deform conv 의 탭 (i,j) 샘플 위치는 `h0·s − p + i·d + Δh` 다. (p→3p, d→3d) 는 + 오프셋에 상수 `2·d·i − 2·p` 를 더한 (p, d) 와 **정확히 같은 위치**를 읽는다 + (출력 크기도 같다: (H+6−6−1)/s+1 = (H+2−2−1)/s+1). 그래서 탭별 상수를 버퍼로 구워 + offset 에 더하고 dilation 1 로 부른다 — 렌더러의 가정이 참이 되고 torch 값은 불변이다. + 버퍼는 첫 eager forward 에서 등록된다(→ export 는 저장 전 dummy forward 필수, + frcnn_to_pt.py 가 이미 그렇게 한다). + """ + try: + from mmcv.ops.saconv import SAConv2d + except Exception: + return # SAC 를 안 쓰는 환경 — 조용히 넘어간다 + if getattr(SAConv2d, "_visp_sac_patched", False): + return + import torch + import torch.nn as nn + import torch.nn.functional as F + from mmcv.ops.deform_conv import deform_conv2d + + def forward(self, x): + # pre-context (원식 그대로) + avg_x = F.adaptive_avg_pool2d(x, output_size=1) + avg_x = self.pre_context(avg_x) + avg_x = avg_x.expand_as(x) + x = x + avg_x + # switch (원식 그대로) + avg_x = F.pad(x, pad=(2, 2, 2, 2), mode='reflect') + avg_x = F.avg_pool2d(avg_x, kernel_size=5, stride=1, padding=0) + switch = self.switch(avg_x) + # sac + weight = self._get_weight(self.weight) + zero_bias = torch.zeros( + self.out_channels, device=weight.device, dtype=weight.dtype) + if self.use_deform: + offset = self.offset_s(avg_x) + out_s = deform_conv2d(x, offset, weight, self.stride, self.padding, + self.dilation, self.groups, 1) + else: + out_s = nn.Conv2d._conv_forward(self, x, weight, zero_bias) + weight = weight + self.weight_diff + if self.use_deform: + # dilation·padding 3배 대신: 탭 (i,j) 마다 offset 에 (2·d·i−2·p, 2·d·j−2·p) + # 를 더한다. 값은 원식과 정확히 같고, 그래프에는 dilation 1 conv 만 남는다. + if getattr(self, "_visp_dil_shift", None) is None: + kh, kw = self.kernel_size + d0, p0 = self.dilation, self.padding + sh = torch.zeros(2 * kh * kw, 1, 1) + for i in range(kh): + for j in range(kw): + sh[2 * (i * kw + j) + 0] = 2.0 * d0[0] * i - 2.0 * p0[0] + sh[2 * (i * kw + j) + 1] = 2.0 * d0[1] * j - 2.0 * p0[1] + self.register_buffer("_visp_dil_shift", sh, persistent=True) + offset = self.offset_l(avg_x) + self._visp_dil_shift + out_l = deform_conv2d(x, offset, weight, self.stride, self.padding, + self.dilation, self.groups, 1) + else: + # 비-deform 은 평범한 conv 라 dilation 을 렌더러가 받는다 — 원식 그대로. + ori_p, ori_d = self.padding, self.dilation + self.padding = tuple(3 * p for p in self.padding) + self.dilation = tuple(3 * d for d in self.dilation) + out_l = nn.Conv2d._conv_forward(self, x, weight, zero_bias) + self.padding, self.dilation = ori_p, ori_d + out = switch * out_s + (1 - switch) * out_l + # post-context (원식 그대로) + avg_x = F.adaptive_avg_pool2d(out, output_size=1) + avg_x = self.post_context(avg_x) + avg_x = avg_x.expand_as(out) + out = out + avg_x + return out + + SAConv2d.forward = forward + SAConv2d._visp_sac_patched = True + def _patch_swin_mask(): """Swin 의 shifted-window 어텐션 마스크를 **버퍼로 미리 굽는다.** From b7768ba6183cd904bd7d8adf1f28ab95d76f00be Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 12:48:42 +0900 Subject: [PATCH 55/89] =?UTF-8?q?docs:=20detectors=200.03px=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=E2=80=94=20two-stage=2025=EA=B3=84=EC=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mmdet-detectors.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 5a70017..71594cf 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -347,7 +347,7 @@ file, so running from anywhere else fails to find it and the family looks broken Two-stage families are measured separately, at 800 and against the detector's own `predict` rather than a head's `predict_by_feat`, because the boxes do not exist until RPN proposals, RoIAlign and the RoI head have run. The harness is `tools/verify/roi/verify_postproc_roi.py` -and the thresholds are the same. Twenty-four of the forty families with a `roi_head` agree: +and the thresholds are the same. Twenty-five of the forty families with a `roi_head` agree: | Family | Decoder | Worst box | Worst score | | :--- | :--- | ---: | ---: | @@ -357,6 +357,7 @@ and the thresholds are the same. Twenty-four of the forty families with a `roi_h | `hrnet` | `detect_roi` | 0.06 px | 0.0002 | | `gcnet` | `detect_roi` | 0.14 px | 0.0010 | | `htc` | `detect_roi` (3 stages + semantic) | 0.12 px | 0.0011 | +| `detectors` | `detect_roi` (SAC) | 0.03 px | 0.0006 | | `scnet` | `detect_roi` (+ global context) | 0.18 px | 0.0020 | | `swin` | `detect_roi` | 0.22 px | 0.0003 | | `mask_rcnn` | `detect_roi` | 0.06 px | 0.0009 | From f4f3fc68ad95b79cf211f03f7889c238aeaba52a Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 12:51:28 +0900 Subject: [PATCH 56/89] =?UTF-8?q?feat(ddq):=20=EC=B5=9C=EC=A2=85=20?= =?UTF-8?q?=EB=B0=95=EC=8A=A4=20=EB=94=94=EC=BD=94=EB=93=9C=20(=EB=8D=A4?= =?UTF-8?q?=ED=94=84=EB=A7=8C=20=ED=95=98=EA=B3=A0=20=EB=81=9D=EB=82=98?= =?UTF-8?q?=EB=8D=98=20=EA=B2=83)=20+=20=EC=83=81=EB=8C=80=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EB=B2=84=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "층마다 NMS 라 한 그래프로 표현 불가" 로 **보류**해 뒀던 항목인데, 조사해 보니 러너 재구조화가 필요 없었다. 다중 패스 조립(ddq_encode/ddq_layer + 호스트 NMS)이 이미 텐서 레벨 PASS(L1 5.2e-04)였고, run_mmdet 의 ddq 블록이 **덤프만 하고 `return 0`** 으로 끝나 최종 디코드만 없었다. mmdet `DDQDETRHead.predict_by_feat` 그대로 — 마지막 층 출력에서 alive query 만 골라 기존 `detect_detr`(sigmoid top-k, NMS 없음)로 디코드한다. ⚠️ 박스는 `box_out.back()`(s.box)이다. `r_v`(s.ref)는 **다음 층 참조점**이라 쓰면 안 된다. ⚠️ 최종 NMS 는 없다 — 층 사이 NMS 가 중복 query 를 이미 걸렀다. ⚠️ alive 마스크는 `distinct_query_mask[-1]` 이다. 갱신이 `li+1 < dec_layers` 에서만 돌아 루프 후 값이 정확히 그 마스크가 된다. ddq 텐서 5.18e-04 · 박스 **0.06px PASS** 같이: `verify_postproc.py` 가 gen_dir 를 **상대경로**로 받으면 `os.path.dirname(gen)` 이 빈 문자열이라 `os.listdir("")` 로 죽었다. 분석·패치: 동료 세션(20260512-e7). 검증은 이 트리에서 재현했다. Co-Authored-By: Claude Opus 5 (1M context) --- tools/verify/backbone/run_mmdet.cpp | 50 ++++++++++++++++++++++ tools/verify/dense_head/verify_postproc.py | 3 +- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/tools/verify/backbone/run_mmdet.cpp b/tools/verify/backbone/run_mmdet.cpp index 8724619..e65527c 100755 --- a/tools/verify/backbone/run_mmdet.cpp +++ b/tools/verify/backbone/run_mmdet.cpp @@ -268,6 +268,56 @@ int main(int argc, char** argv) { for (size_t l = 0; l < box_out.size(); ++l) dump("box", l, box_out[l]); printf("- ddq: %d 패스 (호스트 NMS %d 회)\n", hc.dec_layers + 1, hc.dec_layers); } + + // ── 최종 디코드 — mmdet `DDQDETRHead.predict_by_feat` ───────────────── + // 마지막 층 출력에서 **살아남은(distinct) query 만** 골라 DETR top-k 를 탄다. + // 최종 NMS 는 없다 — 층 사이 NMS 가 이미 중복을 걸렀고, mmdet 도 + // `_predict_by_feat_single`(Deformable 상속)로 top-k 만 한다. + // ⚠️ 박스는 `box_out.back()`(= s.box, 마지막 층 hidden 에서 낸 sigmoid cxcywh)다. + // r_v(s.ref)는 **다음 층 참조점**이라 미묘하게 다르다 — 섞으면 값만 틀린다. + // ⚠️ alive 는 마지막 층에 들어간 마스크 그대로다(mmdet 의 + // `distinct_query_mask[-1]` 과 같다 — 갱신이 li+1 fcls, fbox; + std::vector const& lc = cls_out.back(); + std::vector const& lb = box_out.back(); + int n_alive = 0; + for (int64_t i = 0; i < NQ; ++i) { + if (!alive[(size_t)i]) continue; + fcls.insert(fcls.end(), lc.begin() + i * NC, lc.begin() + (i + 1) * NC); + fbox.insert(fbox.end(), lb.begin() + i * 4, lb.begin() + (i + 1) * 4); + ++n_alive; + } + detr_params qp; + qp.num_queries = n_alive; + qp.num_classes = dp.num_classes; // 배경 제외 수 (프론트엔드 규약) + qp.use_sigmoid = true; // deformable 계열 — 항상 sigmoid+focal + qp.max_per_img = dp.max_per_img; + qp.input_w = SZ; + qp.input_h = SZ; + std::vector dq = detect_detr(fcls.data(), fbox.data(), qp); + + std::string out_s0(outp); + bool raw0 = has_ext(out_s0, ".bin") || src0.extent[0] == 0; + if (raw0) { + FILE* f = fopen(outp, "wb"); + if (!f) { fprintf(stderr, "cannot write %s\n", outp); return 1; } + for (detection const& d : dq) { + float rec[6] = {d.x1, d.y1, d.x2, d.y2, d.score, (float)d.label}; + fwrite(rec, sizeof(float), 6, f); + } + fclose(f); + } else { + float thr0 = 0.3f; + if (const char* e = std::getenv("VISP_DRAW_THRESHOLD")) thr0 = (float)atof(e); + float sx = float(src0.extent[0]) / float(SZ); + float sy = float(src0.extent[1]) / float(SZ); + draw_detections(src0, dq, sx, sy, thr0); + image_save(src0, outp); + } + printf("- detect(ddq): 생존 query %d → %zu boxes → %s\n", + n_alive, dq.size(), outp); + } return 0; } diff --git a/tools/verify/dense_head/verify_postproc.py b/tools/verify/dense_head/verify_postproc.py index 2a0111c..7ffe5a6 100644 --- a/tools/verify/dense_head/verify_postproc.py +++ b/tools/verify/dense_head/verify_postproc.py @@ -171,7 +171,8 @@ def main(): to_rgb = False hdr = [f for f in os.listdir(os.path.dirname(cfg) or ".") if f.endswith(".postproc.h")] - for d in (gen, os.path.dirname(gen)): + # ⚠️ 상대경로로 주면 `os.path.dirname(gen)` 이 빈 문자열이라 `os.listdir("")` 로 죽는다. + for d in (gen, os.path.dirname(gen) or "."): for f in os.listdir(d): if f.endswith(".postproc.h"): txt = open(os.path.join(d, f), encoding="utf-8").read() From 631eb69a73de4b2b2794f35165f31fa67163c760 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 12:51:28 +0900 Subject: [PATCH 57/89] =?UTF-8?q?docs:=20ddq=200.06px=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=E2=80=94=20one-stage=2026=EA=B3=84=EC=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mmdet-detectors.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 71594cf..865cf6a 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -325,6 +325,7 @@ no label mismatch and no difference in how many boxes survive. | `conditional_detr` | `detect_detr` | 0.27 px | 0.002 | | `dab_detr` | `detect_detr` | 0.28 px | 0.001 | | `dino` | `detect_detr` | 0.28 px | 0.004 | +| `ddq` | `detect_detr` (distinct queries) | 0.06 px | 0.018 | | `detr` | `detect_detr` | 1.85 px | 0.044 | Every row above was re-measured together in one run, so the numbers are comparable with each From 976a8fb7a15c6830d51adeeaf205d879ef99629d Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 18 Aug 2026 13:42:54 +0900 Subject: [PATCH 58/89] =?UTF-8?q?docs:=20=EC=8B=A4=ED=8C=A8=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=82=B0=EB=AC=B8=EC=9D=84=20=EC=A0=84=EC=88=98=20?= =?UTF-8?q?=ED=9A=8C=EA=B7=80=20=EA=B2=B0=EA=B3=BC=EB=A1=9C=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit one-stage 26계열 · two-stage 40계열을 오늘 바뀐 공유 코드(11건) 위에서 다시 쟀다. one-stage 26/26 PASS, two-stage 24/40 PASS(+fpg 는 1024 에서) = 51계열. 수치는 문서 표와 전부 일치했다 — 회귀 없음. 산문만 낡아 있었다. 이미 통과한 계열이 실패 목록에 남아 있으면 다음 사람이 고쳐진 것을 다시 판다: - htc(94px)·detectors(30px)·scnet(29px) 는 통과했다. '캐스케이드 단계 미완' 항목을 지우고, 각각이 **다른** 조각이었다는 것을 연산 누락 항목에 잇는다 (시맨틱 융합 / 전역 컨텍스트 / SAC dilation — 공통 캐스케이드 버그가 아니었다). - swin 도 통과했다(GGML_MAX_NAME 64→128, shifted-window 마스크 버퍼). '미분류' 로 남는 것은 tridentnet 하나다. - 안 맞는 계열 수를 20 → 15 로 고치고, double_heads(임계 경계) 와 fast_rcnn(가중치 없음 = 안 재봄)을 빠뜨리지 않게 항목으로 세운다. --- docs/mmdet-detectors.md | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 865cf6a..189b9e2 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -382,7 +382,7 @@ and the thresholds are the same. Twenty-five of the forty families with a `roi_h below P5, where 800 stops dividing evenly — a 25-wide map meets a 26-wide one and the export aborts. That is a property of the resolution, not of the family. -The twenty that do not agree split five ways, and the split matters more than the count: +The fifteen that do not agree split five ways, and the split matters more than the count: - **The RPN is not a standard anchor RPN**, so host `rpn_proposals` cannot lay down the priors: `cascade_rpn` refines across stages, `guided_anchoring` predicts anchor shapes, and @@ -398,9 +398,13 @@ The twenty that do not agree split five ways, and the split matters more than th into torch changed nothing, while substituting the *box deltas* reproduced the full 10.25 px from a maximum delta error of 0.0023. Do not replace these numbers with their fp32 twins; they are what the deployment precision produces. -- **Cascade stages beyond the first are still incomplete**: `htc` (94 px), `detectors` (30 px) - and `scnet` (29 px). `cascade_rcnn` itself now agrees at 0.09 px, so the shared three-stage - path is right; what remains is each family's mask or semantic branch. +- **Neither the graph nor the decoder is at fault.** `double_heads` agrees at 0.16 px and + differs by exactly one box: mmdet scores it 0.2954 and the compiled graph 0.3010, on either + side of the 0.30 cut the harness itself applies. That is the fp16 score error landing on a + threshold, not a decode difference, so the harness now prints how many boxes sit in the + 0.30–0.35 band beside every count mismatch. `fast_rcnn` is not measured at all — its + metafile lists no weights, and random initialisation cannot judge a decoder, so it is + recorded as untried rather than failing. - **The family post-processes its own way**: `crowddet` predicts two instances per proposal and needs set-NMS (without it nothing is suppressed — 500 boxes against 1), `ms_rcnn` rescales scores by a predicted mask IoU (its boxes are exact at 0.07 px; only the scores differ), @@ -423,8 +427,19 @@ The twenty that do not agree split five ways, and the split matters more than th shape-correct code, which passes compilation and every shape assertion while returning wrong values. -`swin` (the SubA gguf fails to load) and `tridentnet` (the runner returns no boxes at all) are -the two that remain unsorted. + The three cascade families joined them later, and each was a different missing piece rather + than a shared cascade bug. `htc` (94 px → 0.12 px) feeds a semantic segmentation branch back + into every RoI: RoIAlign at stride 8 onto a 14×14 grid, average-pooled to 7×7 and added to + the box features **at every stage**, not only the first. `scnet` (29 px → 0.18 px) adds a + global-context vector to all RoI positions the same way. `detectors` (30 px → 0.03 px) was + not a fusion at all — its switchable atrous convolution ran at one dilation. `swin` + (0.22 px) failed earlier still, at load: `GGML_MAX_NAME` was 64 and its tensor names are + longer, and separately the shifted-window attention mask is built by slice assignment that + tracing drops, which silently zeroes the mask instead of crashing. + +`tridentnet` is the one family that remains unsorted: the runner returns no boxes at all. It +is also the only C4 detector here — no FPN neck — so the level assignment host RoIAlign +performs has nothing to choose between. `rpn` decodes proposals rather than detections, so a worst case over the whole set says little: of 185 proposals the median is 0.09 px and 182 are within 1 px, with one proposal of 186 From ae4c675927fe0718bf2684034b73234cf26fb461 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 18 Aug 2026 14:11:01 +0900 Subject: [PATCH 59/89] =?UTF-8?q?feat(ms=5Frcnn):=20=EB=A7=88=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=20IoU=20=EC=9E=AC=EC=A0=90=EC=88=98=ED=99=94=20?= =?UTF-8?q?=E2=80=94=20=EB=B0=95=EC=8A=A4=200.07px=20=C2=B7=20=EC=A0=90?= =?UTF-8?q?=EC=88=98=200.0015=20=C2=B7=20=EA=B0=9C=EC=88=98=EC=B0=A8=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mask Scoring R-CNN 은 최종 점수를 `score * mask_iou[label]` 로 낮춘다 (`maskiou_head.predict_by_feat`). 그 한 단계가 없어서 **박스는 이미 맞는데 점수만** 틀렸다 — 게다가 점수가 임계값을 넘나들어 개수까지 갈렸다(mmdet 4건 vs C++ 6건). 착수 전에 원인을 실측으로 확정했다: mmdet 쪽 `MaskIoUHead.predict_by_feat` 만 끄면 박스 0.07px · 점수 0.0017 · 개수차 0. 나머지 경로는 전부 이미 정확했다. 구현 — 러너에 마스크 경로를 새로 깐다: 마스크 RoIAlign(14x14) → SubC(mask head) → 호스트(라벨 채널 선택 · sigmoid · 2x2 maxpool · 257채널 concat) → SubD(mask-IoU head) → score *= iou[label] 라벨 선택·sigmoid·pool·concat 을 그래프에 넣지 않은 이유: 라벨은 실행 중에 정해지는 값이라 `mask_preds[range(M), labels]` 가 trace 에 안 실린다(조용히 0번 채널이 된다). 세 가지가 조용히 틀리는 지점이었고 전부 주석으로 남겼다: - **박스를 리스케일하지 않는다.** mmdet 은 마스크 분기가 있으면 bbox rescale 을 끈다 (`base_roi_head.py`: `bbox_rescale = rescale if not self.with_mask else False`). - **두 그래프를 검출 하나씩(배치 1) 돌린다.** ggml 의 `ggml_compute_forward_conv_transpose_2d` 가 **배치 축을 안 돈다** — src1 을 풀 때 i12·i11 만 돌고 i13 이 없고, 출력도 `dst->data + i2*nb2` 로 Cout 만 인덱싱한다. 배치로 묶으면 **0번 행만 계산되고 나머지는 bias 만 남는다.** 크래시도 경고도 없다. 실측: 행0 rel_L1 6.0e-04, 행1..4 는 전부 1.01 이고 |x| 평균이 서로 똑같았다(0.0870). - `conv_transpose_2d` 가 커널을 F16 으로 캐스팅한다. ggml CPU 커널이 `GGML_ASSERT(src0->type == GGML_TYPE_F16)` 로 시작하는데 `model_transfer(…, preferred_float_type())` 는 CPU 에서 F32 를 준다 — **로드는 되고 실행에서 죽는다.** attention 의 k/v 캐스팅과 같은 규약이다. 중간 텐서(mfeat · mlogit · miouin · maskiou)를 덤프한다. 이 부류는 크래시가 없어 **단계별로 torch 와 대조**하는 것 말고는 원인을 못 짚는다 — 실제로 그렇게 갈랐다. --- src/visp/nn.cpp | 8 ++ tools/frontend/mmdet/frcnn_to_pt.py | 5 +- tools/frontend/mmdet/frcnn_wrap.py | 33 +++++ tools/verify/backbone/run_frcnn.cpp | 160 +++++++++++++++++++++++- tools/verify/roi/verify_postproc_roi.py | 40 +++++- 5 files changed, 238 insertions(+), 8 deletions(-) diff --git a/src/visp/nn.cpp b/src/visp/nn.cpp index ecd2d91..0ac26a0 100644 --- a/src/visp/nn.cpp +++ b/src/visp/nn.cpp @@ -186,6 +186,14 @@ tensor conv_2d_depthwise(model_ref m, tensor x, int stride, int pad) { tensor conv_transpose_2d(model_ref m, tensor x, int stride) { tensor weight = m.weights("weight"); + // ⚠️ **커널은 F16 이어야 한다.** `ggml_compute_forward_conv_transpose_2d` 는 + // `GGML_ASSERT(src0->type == GGML_TYPE_F16)` 로 시작한다(ggml-cpu/ops.cpp). + // `model_transfer` 에 `preferred_float_type()` 을 주면 CPU 백엔드에서 F32 로 올라와 + // **로드는 되고 실행에서 죽는다.** 호출자가 알아야 할 사정이 아니므로 여기서 맞춘다 + // (attention 의 k/v 캐스팅과 같은 규약). + if (weight->type != GGML_TYPE_F16) { + weight = ggml_cast(m, weight, GGML_TYPE_F16); + } if (m.flags & model_build_flag::cwhn) { x = ggml_cont(m, permute_cwhn_to_whcn(m, x)); } diff --git a/tools/frontend/mmdet/frcnn_to_pt.py b/tools/frontend/mmdet/frcnn_to_pt.py index 1dac8d4..3b90476 100755 --- a/tools/frontend/mmdet/frcnn_to_pt.py +++ b/tools/frontend/mmdet/frcnn_to_pt.py @@ -20,7 +20,8 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import mmdet_compat # noqa: E402 -from frcnn_wrap import FRCNN_SubA, FRCNN_SubB, MaskRCNN_SubC, frcnn_cfg # noqa: E402,F401 (피클: frcnn_wrap) +from frcnn_wrap import (FRCNN_SubA, FRCNN_SubB, MaskRCNN_SubC, MSRCNN_SubD, # noqa: E402,F401 + frcnn_cfg) # (피클: frcnn_wrap) def _desync_norm(cfg_path): @@ -89,6 +90,8 @@ def main(argv=None): cfg = frcnn_cfg(det, a.size) if cfg.get("has_mask"): torch.save(MaskRCNN_SubC(det).eval(), f"{a.out}/MaskRCNN_SubC.pt") # mask_feat → mask_logits + if cfg.get("has_mask_iou"): + torch.save(MSRCNN_SubD(det).eval(), f"{a.out}/MSRCNN_SubD.pt") # (feat|mask) → mask_iou # feat_hw (P2-P6) — 러너 CWHN flat 해석용. 위 dummy forward 에서 얻었다. cfg["feat_hw"] = [[int(f.shape[2]), int(f.shape[3])] for f in feats] json.dump(cfg, open(f"{a.out}/frcnn.json", "w"), indent=2) diff --git a/tools/frontend/mmdet/frcnn_wrap.py b/tools/frontend/mmdet/frcnn_wrap.py index c639016..87e6d6b 100755 --- a/tools/frontend/mmdet/frcnn_wrap.py +++ b/tools/frontend/mmdet/frcnn_wrap.py @@ -126,6 +126,30 @@ def forward(self, mask_feat): return self.mask_head(mask_feat) +class MSRCNN_SubD(nn.Module): + """mask-IoU head. 이어붙인 (M,257,14,14) → mask_iou (M, num_classes). (Mask Scoring R-CNN). + + ⚠️ **mmdet 의 `MaskIoUHead.forward` 를 통째로 태우지 않는다.** 그 forward 는 앞부분에서 + `mask_preds[range(M), labels]` 로 **행마다 다른 채널**을 고른 뒤 sigmoid·maxpool·concat + 까지 한다. 라벨은 실행할 때까지 모르는 값이라 trace 로는 고정 그래프가 안 나온다 + (`index_put_`/`gather` 가 그래프에 안 실려 **크래시 없이 0번 채널**을 고르게 된다). + 그래서 그 앞부분은 호스트가 하고, 여기는 conv/fc 만 맡는다. + """ + def __init__(self, det): + super().__init__() + h = det.roi_head.mask_iou_head + self.convs, self.fcs = h.convs, h.fcs + self.fc_mask_iou, self.relu = h.fc_mask_iou, h.relu + + def forward(self, x): + for conv in self.convs: + x = self.relu(conv(x)) + x = x.flatten(1) + for fc in self.fcs: + x = self.relu(fc(x)) + return self.fc_mask_iou(x) + + def frcnn_cfg(det, size=800): """host 부품(rpn_proposals/roi_align/detect_roi)용 config 추출 → .frcnn.json.""" rh = det.rpn_head @@ -174,6 +198,15 @@ def frcnn_cfg(det, size=800): "mask_finest_scale": int(getattr(mext, "finest_scale", 56)), "mask_thr_binary": float(rcnn_c.mask_thr_binary), } + # Mask Scoring R-CNN: 마스크 IoU 로 점수를 다시 매긴다. 없으면 안 싣는다. + if getattr(det.roi_head, "mask_iou_head", None) is not None: + mih = det.roi_head.mask_iou_head + mask["has_mask_iou"] = True + # ⚠️ 마지막 conv 만 stride 2 다(14→7). 호스트가 이어붙일 채널 수(+1)와 함께 + # 여기서 명시한다 — 런너가 shape 을 추측하면 조용히 어긋난다. + mask["mask_iou_in_channels"] = int(mih.in_channels) # 256 (+1 은 런너가 붙인다) + mask["mask_iou_num_classes"] = int(mih.num_classes) + # HTC 의 시맨틱 융합 파라미터. 없으면 안 싣는다. sem = {} if getattr(det.roi_head, "semantic_head", None) is not None: diff --git a/tools/verify/backbone/run_frcnn.cpp b/tools/verify/backbone/run_frcnn.cpp index f710502..f4a4f88 100644 --- a/tools/verify/backbone/run_frcnn.cpp +++ b/tools/verify/backbone/run_frcnn.cpp @@ -15,6 +15,12 @@ // -DVISP_ARCH_HEADER_A='"..."' -DVISP_ARCH_HEADER_B='"..."' #include VISP_ARCH_HEADER_A #include VISP_ARCH_HEADER_B +#ifdef VISP_ARCH_HEADER_C +#include VISP_ARCH_HEADER_C +#endif +#ifdef VISP_ARCH_HEADER_D +#include VISP_ARCH_HEADER_D +#endif #include "visp/ml.h" #include "visp/postproc.h" @@ -39,6 +45,14 @@ using namespace visp; #define PRM_A CAT(ARCH_A, _detect_params) #define FWD_B CAT(ARCH_B, _forward) #define PRM_B CAT(ARCH_B, _detect_params) +#ifdef ARCH_C +#define FWD_C CAT(ARCH_C, _forward) +#define PRM_C CAT(ARCH_C, _detect_params) +#endif +#ifdef ARCH_D +#define FWD_D CAT(ARCH_D, _forward) +#define PRM_D CAT(ARCH_D, _detect_params) +#endif static std::vector load_bin(const char* path, size_t n) { std::vector v(n); @@ -120,7 +134,8 @@ static std::vector> read_outputs(compute_graph const& g, int int main(int argc, char** argv) { if (argc < 6) { fprintf(stderr, - "usage: %s [size]\n", + "usage: %s " + " [size] [subC.gguf] [subD.gguf]\n", argv[0]); return 1; } @@ -130,6 +145,9 @@ int main(int argc, char** argv) { const char* inb = argv[4]; const std::string pref = argv[5]; const int SZ = argc > 6 ? atoi(argv[6]) : 512; + // Mask Scoring R-CNN 전용. 안 주면 마스크 경로를 아예 안 탄다(다른 계열은 그대로다). + const char* gc = argc > 7 ? argv[7] : nullptr; + const char* gd = argc > 8 ? argv[8] : nullptr; backend_device backend = backend_init(); @@ -467,6 +485,146 @@ int main(int argc, char** argv) { dets = detect_roi(prob.data(), box_st[last].data(), rois.data(), M, rp2); } +#if defined(ARCH_C) && defined(ARCH_D) + // ── 패스 2: 마스크 IoU 로 점수를 다시 매긴다 (Mask Scoring R-CNN) ─────── + // mmdet 은 `score * mask_iou[label]` 로 점수를 낮춘다(maskiou_head.predict_by_feat). + // 이걸 빼면 **박스는 맞는데 점수만 틀린다** — 실측 박스 0.07px / 점수 0.163. + // 게다가 점수가 임계값을 넘나들어 개수까지 달라진다(mmdet 4건 vs C++ 6건). + // + // ⚠️⚠️ **두 그래프를 검출 하나씩(배치 1) 돌린다. 묶어 넣으면 안 된다.** + // `ggml_compute_forward_conv_transpose_2d` 는 **배치 축을 안 돈다** — src1 을 + // 풀 때 i12(채널)·i11(행)만 돌고 i13(배치)이 없고, 출력도 `dst->data + i2*nb2` + // 로 Cout 만 인덱싱한다(ggml-cpu/ops.cpp). 그래서 배치 20으로 넣으면 **0번 행만 + // 계산되고 나머지 19행은 bias 만 남는다.** 크래시도 경고도 없다 — + // 실측: 행0 rel_L1 6.0e-04, 행1..4 는 전부 1.01 이고 |x| 평균이 서로 똑같았다 + // (0.0870 = bias 뿐). mask head 의 `upsample`(14→28 deconv)이 그 경로다. + if (J.num("has_mask_iou", 0.0f) != 0.0f && gc && gd && !dets.empty()) { + const int MD = (int)dets.size(); + // ⚠️ **박스를 원본 해상도로 되돌리지 않는다.** mmdet 은 마스크 분기가 있으면 + // bbox 쪽 rescale 을 끈다(`base_roi_head.py`: + // `bbox_rescale = rescale if not self.with_mask else False`) — 마스크 쪽이 + // 박스와 마스크를 한꺼번에 되돌리기 때문이다. 여기 좌표는 이미 feature 계다. + std::vector mbox((size_t)MD * 4); + std::vector mlab(MD); + for (int i = 0; i < MD; ++i) { + mbox[(size_t)i * 4 + 0] = dets[i].x1; mbox[(size_t)i * 4 + 1] = dets[i].y1; + mbox[(size_t)i * 4 + 2] = dets[i].x2; mbox[(size_t)i * 4 + 3] = dets[i].y2; + mlab[i] = dets[i].label; + } + roi_align_params mp; + mp.output_size = (int)J.num("mask_roi_out", 14.0f); + mp.channels = C; + mp.strides = J.arr("mask_strides"); + mp.finest_scale = J.num("mask_finest_scale", 56.0f); + // 마스크 extractor 가 쓰는 레벨 수는 bbox 쪽과 다를 수 있다(P6 를 안 쓴다). + const int ML = std::min((int)mp.strides.size(), (int)feats.size()); + std::vector> mfeats(feats.begin(), feats.begin() + ML); + std::vector> mhw(feat_hw.begin(), feat_hw.begin() + ML); + const int MO = mp.output_size; + std::vector mfeat = roi_align(mfeats, mhw, mbox.data(), MD, mp); + + // 모델은 한 번만 올린다. 행마다 바뀌는 것은 그래프뿐이다. + model_file fc = model_load(gc); + model_weights wc = model_init(fc.n_tensors()); + model_transfer(fc, wc, backend, backend.preferred_float_type(), fc.tensor_layout()); + model_file fd = model_load(gd); + model_weights wd = model_init(fd.n_tensors()); + model_transfer(fd, wd, backend, backend.preferred_float_type(), fd.tensor_layout()); + + std::vector logit_all, din_all, miou(MD, 1.0f); + int MH = 0, MW = 0, NCLS_M = 0; + const int CD = C + 1; + for (int n = 0; n < MD; ++n) { + // ① SubC = mask head. (1,256,14,14) → (1, NCLS_M, 28, 28) + std::vector mo0; + { + compute_graph g2 = compute_graph_init(65536); + model_ref mc(wc, g2); + tensor min_ = compute_graph_input(mc, GGML_TYPE_F32, {C, MO, MO, 1}, "mroi"); + ggml_build_forward_expand(g2, min_); + ggml_build_forward_expand(g2, FWD_C(mc, min_, PRM_C(fc))); + compute_graph_allocate(g2, backend); + // roi_align 은 NCHW flat 을 낸다. 생성 코드는 cwhn 규약이다. + std::vector cw((size_t)C * MO * MO); + for (int c = 0; c < C; ++c) + for (int y = 0; y < MO; ++y) + for (int x = 0; x < MO; ++x) + cw[((size_t)y * MO + x) * C + c] = + mfeat[(((size_t)n * C + c) * MO + y) * MO + x]; + transfer_to_backend(min_, std::span(cw.data(), cw.size())); + compute(g2, backend); + std::vector> hw; + auto mo = read_outputs(g2, 4, &hw); + if (mo.empty()) { + fprintf(stderr, "SubC(mask head) 출력이 없다\n"); + return 7; + } + mo0 = mo[0]; + MH = hw[0].first; MW = hw[0].second; + NCLS_M = (int)(mo0.size() / ((size_t)MH * MW)); + } + const int PH = MH / 2, PW = MW / 2; // 28 → 14 + if (PH != MO || PW != MO) { + fprintf(stderr, "mask pool %dx%d != roi %dx%d — 이어붙이기 불가\n", + PH, PW, MO, MO); + return 7; + } + // 호스트: 라벨 채널 고르기 → sigmoid → maxpool(2,2) → mask_feat 뒤에 잇기. + // ⚠️ 이 세 단계는 그래프에 못 넣는다. 라벨은 **실행 중에** 정해지는 값이라 + // `mask_preds[range(M), labels]` 가 trace 에 안 실린다(조용히 0번 채널이 된다). + // ⚠️ 채널 순서는 mmdet 대로 **feature 256 뒤에 mask 1** 이다 + // (`torch.cat((mask_feat, mask_pred_pooled), 1)`). + std::vector din((size_t)CD * MO * MO); // cwhn + const int k = std::min(std::max(mlab[n], 0), NCLS_M - 1); + for (int y = 0; y < MO; ++y) + for (int x = 0; x < MO; ++x) { + float* dst = din.data() + ((size_t)y * MO + x) * CD; + for (int c = 0; c < C; ++c) + dst[c] = mfeat[(((size_t)n * C + c) * MO + y) * MO + x]; + float mx = -INFINITY; + for (int dy = 0; dy < 2; ++dy) + for (int dx = 0; dx < 2; ++dx) { + const size_t si = + ((size_t)(2 * y + dy) * MW + (2 * x + dx)) * NCLS_M + k; + mx = std::max(mx, 1.0f / (1.0f + std::exp(-mo0[si]))); + } + dst[C] = mx; + } + // ② SubD = mask-IoU head. (1,257,14,14) → (1, NCLS) + { + compute_graph g3 = compute_graph_init(65536); + model_ref md(wd, g3); + tensor din_ = compute_graph_input(md, GGML_TYPE_F32, {CD, MO, MO, 1}, "miou_in"); + ggml_build_forward_expand(g3, din_); + ggml_build_forward_expand(g3, FWD_D(md, din_, PRM_D(fd))); + compute_graph_allocate(g3, backend); + transfer_to_backend(din_, std::span(din.data(), din.size())); + compute(g3, backend); + auto od = read_outputs(g3, 4, nullptr); + if (od.empty()) { + fprintf(stderr, "SubD(mask-IoU head) 출력이 없다\n"); + return 7; + } + const int NIOU = (int)od[0].size(); + miou[n] = od[0][std::min(std::max(mlab[n], 0), NIOU - 1)]; + } + logit_all.insert(logit_all.end(), mo0.begin(), mo0.end()); + din_all.insert(din_all.end(), din.begin(), din.end()); + } + for (int i = 0; i < MD; ++i) { + dets[i].score *= miou[i]; + } + // ⚠️ **재정렬하지 않는다.** mmdet 도 보정 뒤 정렬하지 않는다 + // (`predict_by_feat` 는 `results.scores` 를 제자리에서 곱할 뿐이다). + // 여기서 정렬하면 양쪽 순서가 갈려 인덱스로 짝짓는 대조가 어긋난다. + // 중간 텐서도 낸다 — 값이 틀렸을 때 **어느 단계**인지 torch 와 대조한다. + dump_bin(pref + ".mfeat.bin", mfeat); // 마스크 RoIAlign (NCHW) + dump_bin(pref + ".mlogit.bin", logit_all); // SubC 출력 (행별 cwhn) + dump_bin(pref + ".miouin.bin", din_all); // SubD 입력 (행별 cwhn, 257ch) + dump_bin(pref + ".maskiou.bin", miou); // 라벨 채널의 IoU 예측 + } +#endif + // ── 덤프 (torch 대조용) ───────────────────────────────────────────────── dump_bin(pref + ".props.bin", props); dump_bin(pref + ".roi.bin", roi); diff --git a/tools/verify/roi/verify_postproc_roi.py b/tools/verify/roi/verify_postproc_roi.py index af4547f..ffd0c37 100644 --- a/tools/verify/roi/verify_postproc_roi.py +++ b/tools/verify/roi/verify_postproc_roi.py @@ -204,6 +204,20 @@ def one(fam, size, image, workdir, keep, verbose): jobs = [("FRCNN_SubA", "FRCNN_SubA", "out_FRCNN_SubA", f"1,3,{size},{size}")] # 캐스케이드는 단계마다 가중치만 다르므로 그래프 이름을 subs[0] 으로 통일해 gguf 만 갈아 낀다. jobs += [(s, subs[0], "out_" + s, f"{MX},{RC},{O},{O}") for s in subs] + # Mask Scoring R-CNN 은 점수를 마스크 IoU 로 다시 매긴다 → 그래프가 둘 더 필요하다. + # SubC = mask head (1, 256, 14, 14) → 마스크 로짓 (1, 80, 28, 28) + # SubD = mask-IoU head (1, 257, 14, 14) → 클래스별 IoU (1, 80) + # ⚠️ **배치 1 로 굽고 러너가 검출 하나씩 돌린다.** ggml 의 + # `conv_transpose_2d` 가 배치 축을 안 돌아서(ggml-cpu/ops.cpp — src1 을 풀 때 + # i13 이 없다) 배치로 묶으면 **0번 행만 계산되고 나머지는 bias 만 남는다.** + # 크래시가 없어 조용히 틀린다. mask head 의 14→28 deconv 가 그 경로다. + has_miou = bool(J.get("has_mask_iou")) + MO = int(J.get("mask_roi_out", 14)) + if has_miou: + jobs += [("MaskRCNN_SubC", "MaskRCNN_SubC", "out_MaskRCNN_SubC", + f"1,{RC},{MO},{MO}"), + ("MSRCNN_SubD", "MSRCNN_SubD", "out_MSRCNN_SubD", + f"1,{RC + 1},{MO},{MO}")] for src, name, outdir, shape in jobs: r = run([PY, "-c", f''' import _stub, sys @@ -215,17 +229,28 @@ def one(fam, size, image, workdir, keep, verbose): # ③ 러너 빌드 — 빌드 라인은 build_frcnn_cpp.sh / verify_heads.py 와 같아야 한다. import shutil - for name, inc in (("FRCNN_SubA", "incA"), (subs[0], "incB")): + incs = [("FRCNN_SubA", "incA"), (subs[0], "incB")] + if has_miou: + incs += [("MaskRCNN_SubC", "incC"), ("MSRCNN_SubD", "incD")] + for name, inc in incs: os.makedirs(os.path.join(fr, inc, "visp", "arch"), exist_ok=True) shutil.copy(os.path.join(fr, "out_" + name, name + ".h"), os.path.join(fr, inc, "visp", "arch")) + extra = [] + if has_miou: + extra = ["-DARCH_C=MaskRCNN_SubC", "-DARCH_D=MSRCNN_SubD", + '-DVISP_ARCH_HEADER_C="visp/arch/MaskRCNN_SubC.h"', + '-DVISP_ARCH_HEADER_D="visp/arch/MSRCNN_SubD.h"', + "-IincC", "-IincD"] b = run(["g++", "-std=c++20", "-O1", "-DARCH_A=FRCNN_SubA", "-DARCH_B=" + subs[0], '-DVISP_ARCH_HEADER_A="visp/arch/FRCNN_SubA.h"', - f'-DVISP_ARCH_HEADER_B="visp/arch/{subs[0]}.h"', + f'-DVISP_ARCH_HEADER_B="visp/arch/{subs[0]}.h"'] + extra + [ "-IincA", "-IincB", "-I" + V + "/include", "-I" + V + "/src", "-I" + V + "/depend/llama/ggml/include", "-I" + V + "/depend/llama/vendor", V + "/tools/verify/backbone/run_frcnn.cpp", - "out_FRCNN_SubA/FRCNN_SubA.cpp", f"out_{subs[0]}/{subs[0]}.cpp", + "out_FRCNN_SubA/FRCNN_SubA.cpp", f"out_{subs[0]}/{subs[0]}.cpp"] + ( + ["out_MaskRCNN_SubC/MaskRCNN_SubC.cpp", "out_MSRCNN_SubD/MSRCNN_SubD.cpp"] + if has_miou else []) + [ "-L" + BUILD + "/lib", "-lvisioncpp", "-lggml", "-lggml-base", "-lggml-cpu", "-Wl,-rpath," + BUILD + "/lib", "-o", "run_frcnn"], fr) if not os.path.exists(os.path.join(fr, "run_frcnn")): @@ -242,9 +267,12 @@ def one(fam, size, image, workdir, keep, verbose): # ⑤ 러너 실행 pref = os.path.join(d, "cpp") - rr = run([os.path.join(fr, "run_frcnn"), "out_FRCNN_SubA/FRCNN_SubA.gguf", - ",".join(f"out_{s}/{subs[0]}.gguf" for s in subs), - "frcnn.json", binp, pref, str(size)], fr, {"VISP_BACKEND": "cpu"}) + argv = [os.path.join(fr, "run_frcnn"), "out_FRCNN_SubA/FRCNN_SubA.gguf", + ",".join(f"out_{s}/{subs[0]}.gguf" for s in subs), + "frcnn.json", binp, pref, str(size)] + if has_miou: + argv += ["out_MaskRCNN_SubC/MaskRCNN_SubC.gguf", "out_MSRCNN_SubD/MSRCNN_SubD.gguf"] + rr = run(argv, fr, {"VISP_BACKEND": "cpu"}) if not os.path.exists(pref + ".boxes.bin"): return fam, "RUN_FAIL", last_error(rr.stderr)[:110], None got = np.fromfile(pref + ".boxes.bin", dtype="float32").reshape(-1, 6) From 82f8f9f97fa5fdf4f3844c3734cd8e00fa7617c2 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 18 Aug 2026 14:34:06 +0900 Subject: [PATCH 60/89] =?UTF-8?q?fix(convT):=20padding=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20+=20=ED=95=98=EB=84=A4=EC=8A=A4=EC=9D=98=20stale=20?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=EB=AC=BC=20=EA=B5=AC=EB=A9=8D=20=EC=84=B8=20?= =?UTF-8?q?=EA=B3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ① `conv_transpose_2d` 에 padding 을 받는다. ggml 에는 `_p0`(padding 0) 짜리 하나뿐이라 크게 뽑아 놓고 `ggml_view_4d` 로 가장자리를 p 픽셀씩 잘라낸다(transposed conv 의 padding 은 정확히 그 뜻이다). **permute 앞에서** 자른다 — cwhn 모드에서 축이 바뀐 뒤에 자르면 엉뚱한 축을 자른다. 기본값 0 이라 기존 호출부는 그대로다. 안 자르면 출력이 2p 크고 그 크기는 **다음 op 에서** 어긋나 죽는다. 크래시 지점이 원인 지점이 아니다 — centernet 실측으로 deconv 가 34x34 를 내고 다음 DCN 의 offset 32 와 안 맞았다(20260512-e7 세션 진단). `groups != 1` 은 막는다. padding 만 고치고 두면 groups 가 **조용히 무시되어** 채널이 섞인 채 돈다. grid_rcnn 의 grid head 가 groups=9 라 여기서 걸리는데, 그건 디코드가 아니라 연산 부족이므로 그렇게 말하게 했다. ② 하네스가 **산출물 존재만 검사**하던 세 자리를 고쳤다(export/컴파일/빌드). 단계가 실패해도 지난 실행의 것이 남아 있으면 그대로 통과한다 — 고친 코드가 반영 안 된 숫자를 "통과" 로 보고하게 된다. 단계 앞에서 지운다. 이걸 켜자마자 `panoptic_fpn` 이 EXPORT_FAIL 로 드러났다(`panopticapi` 미설치). 원상태(ae4c675)에서 `git stash` 로 다시 재서 회귀가 아님을 확인했다 — 즉 이전 전수 스윕의 그 계열 숫자는 낡은 산출물로 나왔을 가능성이 크다. 검증됨으로 세지 않는다. ③ Grid R-CNN 준비 — bbox head 에 회귀 분기가 없는 경우(`with_reg=False`) 0 델타를 넣어 RoI 를 그대로 박스로 쓴다(mmdet 도 `bbox_pred is None` 이면 그렇게 한다). 격자 히트맵 디코드와 SubE 배선도 넣었다. 연산(grouped deconv)만 뚫리면 바로 잰다. 회귀(새 workdir): ms_rcnn 0.07px · faster_rcnn 0.10 · mask_rcnn 0.06 · htc 0.12 · scnet 0.18 · point_rend 0.15 · cascade_rcnn 0.09 · detectors 0.03 · dcn 0.37 — 전부 동일. --- src/visp/nn.cpp | 15 +- src/visp/nn.h | 4 +- tools/frontend/mmdet/frcnn_to_pt.py | 4 +- tools/frontend/mmdet/frcnn_wrap.py | 101 +++++++++++++ tools/verify/backbone/run_frcnn.cpp | 190 +++++++++++++++++++++++- tools/verify/roi/verify_postproc_roi.py | 41 ++++- 6 files changed, 345 insertions(+), 10 deletions(-) diff --git a/src/visp/nn.cpp b/src/visp/nn.cpp index 0ac26a0..5f0cc2a 100644 --- a/src/visp/nn.cpp +++ b/src/visp/nn.cpp @@ -184,7 +184,7 @@ tensor conv_2d_depthwise(model_ref m, tensor x, int stride, int pad) { return x; } -tensor conv_transpose_2d(model_ref m, tensor x, int stride) { +tensor conv_transpose_2d(model_ref m, tensor x, int stride, int pad) { tensor weight = m.weights("weight"); // ⚠️ **커널은 F16 이어야 한다.** `ggml_compute_forward_conv_transpose_2d` 는 // `GGML_ASSERT(src0->type == GGML_TYPE_F16)` 로 시작한다(ggml-cpu/ops.cpp). @@ -199,6 +199,19 @@ tensor conv_transpose_2d(model_ref m, tensor x, int stride) { } x = ggml_conv_transpose_2d_p0(m, weight, x, stride); + // ⚠️ **`ggml_conv_transpose_2d_p0` 은 이름 그대로 padding 0 전용이다.** + // transposed conv 의 padding p 는 "출력 가장자리를 p 픽셀씩 버린다" 와 같으므로 + // p0 로 크게 뽑아 놓고 여기서 잘라낸다. 안 자르면 출력이 2p 만큼 크고, 그 크기는 + // **다음 op 에서** 어긋나 죽는다 — 크래시 지점이 원인 지점이 아니다 + // (centernet 실측: deconv 가 34x34 를 내고 다음 DCN 의 offset 32 와 안 맞았다). + // mmdet 의 deconv 는 전부 대칭 padding 이라 양쪽을 같은 값으로 자르면 된다. + if (pad > 0) { + const int64_t w = x->ne[0] - 2 * pad, h = x->ne[1] - 2 * pad; + GGML_ASSERT(w > 0 && h > 0); + x = ggml_cont(m, ggml_view_4d(m, x, w, h, x->ne[2], x->ne[3], + x->nb[1], x->nb[2], x->nb[3], + pad * x->nb[0] + pad * x->nb[1])); + } if (m.flags & model_build_flag::cwhn) { x = ggml_cont(m, permute_whcn_to_cwhn(m, x)); } diff --git a/src/visp/nn.h b/src/visp/nn.h index ed5e046..743d05a 100644 --- a/src/visp/nn.h +++ b/src/visp/nn.h @@ -62,7 +62,9 @@ tensor conv_2d_wt(model_ref m, tensor x, tensor weight, tensor bias, tensor conv_2d_depthwise(model_ref m, tensor x, int stride = 1, int pad = 0); tensor conv_2d_deform( model_ref m, tensor x, tensor weight, tensor offset, tensor mask, int stride, int pad); -tensor conv_transpose_2d(model_ref m, tensor x, int stride); +// `pad` 는 torch 의 ConvTranspose2d padding 과 같은 뜻이다(출력 가장자리를 그만큼 버린다). +// ggml 에는 padding 을 받는 conv_transpose 가 없어 여기서 잘라낸다. +tensor conv_transpose_2d(model_ref m, tensor x, int stride, int pad = 0); tensor batch_norm_2d(model_ref, tensor x); // 2D image to patch embedding using convolution and optional norm. CWHN input and output. diff --git a/tools/frontend/mmdet/frcnn_to_pt.py b/tools/frontend/mmdet/frcnn_to_pt.py index 3b90476..17c1d5d 100755 --- a/tools/frontend/mmdet/frcnn_to_pt.py +++ b/tools/frontend/mmdet/frcnn_to_pt.py @@ -21,7 +21,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import mmdet_compat # noqa: E402 from frcnn_wrap import (FRCNN_SubA, FRCNN_SubB, MaskRCNN_SubC, MSRCNN_SubD, # noqa: E402,F401 - frcnn_cfg) # (피클: frcnn_wrap) + GridRCNN_SubE, frcnn_cfg) # (피클: frcnn_wrap) def _desync_norm(cfg_path): @@ -92,6 +92,8 @@ def main(argv=None): torch.save(MaskRCNN_SubC(det).eval(), f"{a.out}/MaskRCNN_SubC.pt") # mask_feat → mask_logits if cfg.get("has_mask_iou"): torch.save(MSRCNN_SubD(det).eval(), f"{a.out}/MSRCNN_SubD.pt") # (feat|mask) → mask_iou + if cfg.get("has_grid"): + torch.save(GridRCNN_SubE(det).eval(), f"{a.out}/GridRCNN_SubE.pt") # roi_feat → 격자 히트맵 # feat_hw (P2-P6) — 러너 CWHN flat 해석용. 위 dummy forward 에서 얻었다. cfg["feat_hw"] = [[int(f.shape[2]), int(f.shape[3])] for f in feats] json.dump(cfg, open(f"{a.out}/frcnn.json", "w"), indent=2) diff --git a/tools/frontend/mmdet/frcnn_wrap.py b/tools/frontend/mmdet/frcnn_wrap.py index 87e6d6b..23e9baf 100755 --- a/tools/frontend/mmdet/frcnn_wrap.py +++ b/tools/frontend/mmdet/frcnn_wrap.py @@ -95,6 +95,7 @@ def __init__(self, det, stage=None): # 폴백**한다 — 두 슬라이스가 같은 자리를 읽어 확대판 RoI 가 무시된다(L1 0.740). # proposal 개수는 `test_cfg.rpn.max_per_img` 로 이미 정해져 있다. self.n_half = int(det.test_cfg.rpn.max_per_img) if self.two_in else 0 + _fold_normed_linear(self.bbox_head) def forward(self, roi_feat): # ⚠️ **`self.<새속성>` 을 그냥 읽지 마라.** 이 클래스는 `torch.save` 로 통째 절여지는데, @@ -110,6 +111,51 @@ def forward(self, roi_feat): return self.bbox_head(roi_feat) +class _NormedCls(nn.Module): + """`NormedLinear` 를 trace 되는 연산으로 바꾼다 (seesaw_loss 의 분류기). + + mmdet 원식(`normed_predictor.py`): + w_ = w / (||w||_row^power + eps) ← **추론에서 상수다** + x_ = x / (||x||_row^power + eps) * T + out = x_ @ w_^T + b + + 가중치 정규화는 상수이므로 **여기서 미리 접어** 평범한 Linear 로 만든다. 남는 것은 + 입력 정규화와 온도뿐인데, `Tensor.norm` 은 trace 로 안 잡히므로 sqrt(sum(x*x)) 로 + 풀어 쓴다 — 값은 같고 렌더러가 아는 연산만 남는다. + + ⚠️ 온도를 빼먹으면 **크래시 없이 점수만** 틀린다. 행 전체에 같은 배수가 곱해지지만 + softmax 는 스케일 불변이 아니고(bias 가 뒤에 더해져 상쇄도 안 된다), 온도 20 은 + 분포를 크게 날카롭게 만든다. + """ + def __init__(self, lin): + super().__init__() + power = float(getattr(lin, "power", 1.0)) + if power != 1.0: + raise NotImplementedError(f"NormedLinear power={power} — 1.0 만 접을 수 있다") + w = lin.weight.detach() + eps = float(getattr(lin, "eps", 1e-6)) + wn = w / (w.norm(dim=1, keepdim=True) + eps) + self.fc = nn.Linear(w.shape[1], w.shape[0], bias=lin.bias is not None) + with torch.no_grad(): + self.fc.weight.copy_(wn) + if lin.bias is not None: + self.fc.bias.copy_(lin.bias.detach()) + self.t = float(getattr(lin, "tempearture", 20.0)) # mmdet 의 철자 그대로다 + self.eps = eps + + def forward(self, x): + n = torch.sqrt((x * x).sum(dim=1, keepdim=True)) + return self.fc(x / (n + self.eps) * self.t) + + +def _fold_normed_linear(head): + """head 의 `NormedLinear` 예측기를 `_NormedCls` 로 갈아 끼운다(있을 때만).""" + for name in ("fc_cls", "fc_reg"): + m = getattr(head, name, None) + if m is not None and type(m).__name__ == "NormedLinear": + setattr(head, name, _NormedCls(m)) + + def num_bbox_stages(det): """RoI bbox head 단계 수. 1 이면 평범한 two-stage.""" bh = getattr(getattr(det, "roi_head", None), "bbox_head", None) @@ -150,6 +196,22 @@ def forward(self, x): return self.fc_mask_iou(x) +class GridRCNN_SubE(nn.Module): + """grid head. RoI feat (M,256,14,14) → 격자점 히트맵 (M, grid_points, 56, 56). + + ⚠️ `GridHead.forward` 는 dict 를 낸다(`fused`/`unfused`). 추론에서 둘은 **같은 + 텐서**이므로 하나만 낸다 — dict 를 그대로 두면 g2c 가 출력을 못 잡는다. + `test_mode` 를 세워 학습용 분기(unfused 를 따로 계산)를 끈다. + """ + def __init__(self, det): + super().__init__() + self.grid_head = det.roi_head.grid_head + self.grid_head.test_mode = True + + def forward(self, grid_feat): + return self.grid_head(grid_feat)["fused"] + + def frcnn_cfg(det, size=800): """host 부품(rpn_proposals/roi_align/detect_roi)용 config 추출 → .frcnn.json.""" rh = det.rpn_head @@ -207,6 +269,45 @@ def frcnn_cfg(det, size=800): mask["mask_iou_in_channels"] = int(mih.in_channels) # 256 (+1 은 런너가 붙인다) mask["mask_iou_num_classes"] = int(mih.num_classes) + # CrowdDet: proposal 하나가 사람 둘을 낸다고 보고 (cls, box) 쌍을 2벌 낸다. + # 디코드도 NMS 도 다르다 — **같은 proposal 에서 나온 상자끼리는 서로 안 누른다**(set-NMS). + ni = int(getattr(bh, "num_instance", 1) or 1) + if ni > 1: + mask["num_instance"] = ni + # 정제(refine) 분기가 있으면 mmdet 은 **정제된 쌍**으로 예측한다 + # (`multi_instance_roi_head.py:102` — cls_score_ref/bbox_pred_ref). + mask["with_refine"] = bool(getattr(bh, "with_refine", False)) + + # Grid R-CNN: bbox head 는 회귀 분기가 없고(`with_reg=False`) 격자점 히트맵으로 + # 박스를 다시 낸다. 디코드에 필요한 상수를 전부 여기서 뽑는다 — 러너가 재계산하면 + # 두 곳이 갈린다(`calc_sub_regions` 는 정수 절단이 섞여 있어 특히 위험하다). + gh = getattr(det.roi_head, "grid_head", None) + if gh is not None: + # ⚠️ **grid head 의 deconv 는 grouped + padded 다**(groups=grid_points=9, + # padding=(k-2)//2=1). ggml 이 가진 것은 `ggml_conv_transpose_2d_p0` 하나이고 + # 이름 그대로 padding 0 · groups 1 전용이다. 그대로 태우면 출력이 2px 크고 + # 채널 묶음이 섞여 `add_bias_2d` 에서 `ggml_can_repeat` 로 죽는다. + # **크래시로 두면 "우리 버그" 처럼 보인다** — 왜 안 되는지 여기서 말한다. + d1 = gh.deconv1 + if getattr(d1, "groups", 1) != 1 or any(v != 0 for v in getattr(d1, "padding", (0, 0))): + raise NotImplementedError( + f"GridHead.deconv: groups={getattr(d1, 'groups', 1)} · " + f"padding={tuple(getattr(d1, 'padding', (0, 0)))} — ggml 의 conv_transpose 는 " + "padding 0 · groups 1 만 한다(ggml_conv_transpose_2d_p0). 그룹별로 쪼개 돌리고 " + "가장자리를 잘라내는 분해가 필요하다 — 디코드가 아니라 연산 부족이다") + gext = det.roi_head.grid_roi_extractor + if isinstance(gext, nn.ModuleList): + gext = gext[0] + mask["has_grid"] = True + mask["grid_roi_out"] = int(gext.roi_layers[0].output_size[0]) # 14 + mask["grid_strides"] = [int(v) for v in gext.featmap_strides] + mask["grid_finest_scale"] = int(getattr(gext, "finest_scale", 56)) + mask["grid_points"] = int(gh.grid_points) + mask["grid_size"] = int(gh.grid_size) + mask["whole_map_size"] = int(gh.whole_map_size) + # (x1,y1) 만 쓴다 — 히트맵을 전체 좌표로 옮기는 오프셋이다. + mask["grid_sub_xy"] = [float(v) for r in gh.sub_regions for v in r[:2]] + # HTC 의 시맨틱 융합 파라미터. 없으면 안 싣는다. sem = {} if getattr(det.roi_head, "semantic_head", None) is not None: diff --git a/tools/verify/backbone/run_frcnn.cpp b/tools/verify/backbone/run_frcnn.cpp index f4a4f88..a5b0e72 100644 --- a/tools/verify/backbone/run_frcnn.cpp +++ b/tools/verify/backbone/run_frcnn.cpp @@ -21,6 +21,9 @@ #ifdef VISP_ARCH_HEADER_D #include VISP_ARCH_HEADER_D #endif +#ifdef VISP_ARCH_HEADER_E +#include VISP_ARCH_HEADER_E +#endif #include "visp/ml.h" #include "visp/postproc.h" @@ -53,6 +56,10 @@ using namespace visp; #define FWD_D CAT(ARCH_D, _forward) #define PRM_D CAT(ARCH_D, _detect_params) #endif +#ifdef ARCH_E +#define FWD_E CAT(ARCH_E, _forward) +#define PRM_E CAT(ARCH_E, _detect_params) +#endif static std::vector load_bin(const char* path, size_t n) { std::vector v(n); @@ -135,7 +142,7 @@ int main(int argc, char** argv) { if (argc < 6) { fprintf(stderr, "usage: %s " - " [size] [subC.gguf] [subD.gguf]\n", + " [size] [subC.gguf] [subD.gguf] [subE.gguf]\n", argv[0]); return 1; } @@ -148,6 +155,7 @@ int main(int argc, char** argv) { // Mask Scoring R-CNN 전용. 안 주면 마스크 경로를 아예 안 탄다(다른 계열은 그대로다). const char* gc = argc > 7 ? argv[7] : nullptr; const char* gd = argc > 8 ? argv[8] : nullptr; + const char* ge = argc > 9 ? argv[9] : nullptr; // Grid R-CNN 의 격자 head backend_device backend = backend_init(); @@ -362,6 +370,17 @@ int main(int argc, char** argv) { compute(g1, backend); auto bo = read_outputs(g1, 8, nullptr); + if (bo.size() == 1) { + // ⚠️ **회귀 분기가 없는 bbox head 가 있다.** Grid R-CNN 은 `with_reg=False` + // 로 박스를 아예 예측하지 않고 격자 head 가 나중에 다시 낸다. mmdet 도 + // `bbox_pred is None` 이면 `rois` 를 그대로 박스로 쓴다 + // (`bbox_head.predict_by_feat`). 0 델타를 넣으면 같은 결과가 된다 — + // 평균 0 · exp(0)=1 이라 디코드가 항등이 되기 때문이다. + // 여기서 막고 "출력이 부족하다" 로 실패시키면 계열을 못 재본다. + const int ncls = (int)(bo[0].size() / M) - 1; + bo.push_back(std::vector((size_t)M * ncls * 4, 0.0f)); + fprintf(stderr, "[frcnn] bbox head 에 회귀 분기가 없다 → RoI 를 박스로 쓴다\n"); + } if (bo.size() < 2) { fprintf(stderr, "SubB 출력이 2개 미만이다(cls_score, bbox_pred 필요)\n"); return 5; @@ -443,7 +462,74 @@ int main(int argc, char** argv) { // // 박스는 평균하지 않는다. mmdet 도 `bbox_preds` 는 마지막 단계 것을 그대로 쓴다. std::vector dets; - if (!cls_st.empty() && !box_st.empty()) { + // ── CrowdDet: proposal 하나가 사람 둘 — 디코드도 NMS 도 다르다 ────────── + const int NI = (int)J.num("num_instance", 1.0f); + if (NI > 1 && !cls_st.empty() && !box_st.empty()) { + // ⚠️ **정제된 쌍을 쓴다.** head 가 (cls, box, cls_ref, box_ref) 넷을 내고 + // mmdet 은 정제된 쪽으로 예측한다(`multi_instance_roi_head.py`). + // 앞 쌍을 쓰면 크래시 없이 점수만 달라진다. + const bool refine = J.num("with_refine", 0.0f) != 0.0f; + const int use = (refine && cls_st.size() >= 2) ? 1 : 0; + std::vector const& cls = cls_st[use]; + std::vector const& box = box_st[use]; + // cat(dim=1) 이라 proposal 하나에 [inst0 (C+1)값][inst1 (C+1)값] 이 붙어 있다. + const int NCLS = (int)(cls.size() / ((size_t)M * NI)) - 1; + const float score_thr = J.num("rcnn_score_thr", 0.01f); + const float nms_thr = J.num("rcnn_nms_thr", 0.5f); + const int max_img = (int)J.num("rcnn_max", 500.0f); + const std::vector rm = J.arr("rcnn_means"), rs = J.arr("rcnn_stds"); + float mean[4] = {0, 0, 0, 0}, sd[4] = {1, 1, 1, 1}; + for (int k = 0; k < 4; ++k) { + if (rm.size() >= 4) mean[k] = rm[k]; + if (rs.size() >= 4) sd[k] = rs[k]; + } + struct cand { float x1, y1, x2, y2, score; int roi; }; + std::vector cs; + for (int i = 0; i < M; ++i) { + const float px1 = rois[(size_t)i * 4 + 0], py1 = rois[(size_t)i * 4 + 1]; + const float px2 = rois[(size_t)i * 4 + 2], py2 = rois[(size_t)i * 4 + 3]; + const float pw = px2 - px1, ph = py2 - py1; + for (int p = 0; p < NI; ++p) { + float const* c = cls.data() + ((size_t)i * NI + p) * (NCLS + 1); + float mx = c[0]; + for (int k = 1; k <= NCLS; ++k) mx = std::max(mx, c[k]); + float sum = 0.0f; + for (int k = 0; k <= NCLS; ++k) sum += std::exp(c[k] - mx); + const float fg = std::exp(c[1] - mx) / sum; // 전경 확률(클래스 1) + if (fg <= score_thr) continue; + float const* d = box.data() + ((size_t)i * NI + p) * 4; + const float dx = d[0] * sd[0] + mean[0], dy = d[1] * sd[1] + mean[1]; + const float dw = d[2] * sd[2] + mean[2], dh = d[3] * sd[3] + mean[3]; + const float cx = px1 + pw * 0.5f + dx * pw, cy = py1 + ph * 0.5f + dy * ph; + const float w = pw * std::exp(dw), h = ph * std::exp(dh); + cs.push_back({std::max(0.0f, cx - w * 0.5f), std::max(0.0f, cy - h * 0.5f), + std::min((float)SZ, cx + w * 0.5f), + std::min((float)SZ, cy + h * 0.5f), fg, i}); + } + } + std::stable_sort(cs.begin(), cs.end(), + [](cand const& a, cand const& b) { return a.score > b.score; }); + // set-NMS — 평범한 NMS 인데 **같은 proposal 에서 나온 상자는 서로 안 누른다.** + // 그게 이 계열의 핵심이다: 겹쳐 선 두 사람은 IoU 가 높아 보통 NMS 면 하나가 + // 지워진다. 이걸 빼면 억제가 통째로 어긋나 500건이 그대로 남는다(실측 1:500). + std::vector keep(cs.size(), 1); + for (size_t a = 0; a < cs.size(); ++a) { + if (!keep[a]) continue; + for (size_t b = a + 1; b < cs.size(); ++b) { + if (!keep[b] || cs[b].roi == cs[a].roi) continue; // 같은 proposal 은 면제 + const float ix1 = std::max(cs[a].x1, cs[b].x1), iy1 = std::max(cs[a].y1, cs[b].y1); + const float ix2 = std::min(cs[a].x2, cs[b].x2), iy2 = std::min(cs[a].y2, cs[b].y2); + const float iw = std::max(0.0f, ix2 - ix1), ih = std::max(0.0f, iy2 - iy1); + const float inter = iw * ih; + const float ua = (cs[a].x2 - cs[a].x1) * (cs[a].y2 - cs[a].y1) + + (cs[b].x2 - cs[b].x1) * (cs[b].y2 - cs[b].y1) - inter; + if (ua > 0.0f && inter / ua > nms_thr) keep[b] = 0; + } + } + for (size_t i = 0; i < cs.size() && (int)dets.size() < max_img; ++i) { + if (keep[i]) dets.push_back({cs[i].x1, cs[i].y1, cs[i].x2, cs[i].y2, cs[i].score, 0}); + } + } else if (!cls_st.empty() && !box_st.empty()) { const int last = (int)cls_st.size() - 1; const int NCLS = (int)(cls_st[last].size() / M) - 1; // 배경 제외 // 캐스케이드 단계들만 평균한다. 다중 인스턴스(CrowdDet)는 단계가 아니라 **쌍**이라 @@ -625,6 +711,106 @@ int main(int argc, char** argv) { } #endif +#ifdef ARCH_E + // ── 패스 3: 격자점 히트맵으로 박스를 다시 낸다 (Grid R-CNN) ───────────── + // bbox head 는 `with_reg=False` 라 박스를 아예 안 낸다(위에서 RoI 를 그대로 썼다). + // 진짜 박스는 여기서 나온다 — 9개 격자점의 히트맵 최대점을 이미지 좌표로 옮기고 + // 같은 변에 놓인 점들을 **점수 가중 평균**한다(`grid_head._predict_by_feat_single`). + if (J.num("has_grid", 0.0f) != 0.0f && ge && !dets.empty()) { + const int MD = (int)dets.size(); + const int GP = (int)J.num("grid_points", 9.0f); + const int GS = (int)J.num("grid_size", 3.0f); + std::vector sub = J.arr("grid_sub_xy"); // (x1,y1) x GP + roi_align_params gp_; + gp_.output_size = (int)J.num("grid_roi_out", 14.0f); + gp_.channels = C; + gp_.strides = J.arr("grid_strides"); + gp_.finest_scale = J.num("grid_finest_scale", 56.0f); + const int GL = std::min((int)gp_.strides.size(), (int)feats.size()); + std::vector> gfeats(feats.begin(), feats.begin() + GL); + std::vector> ghw(feat_hw.begin(), feat_hw.begin() + GL); + const int GO = gp_.output_size; + + std::vector gbox((size_t)MD * 4); + for (int i = 0; i < MD; ++i) { + gbox[(size_t)i * 4 + 0] = dets[i].x1; gbox[(size_t)i * 4 + 1] = dets[i].y1; + gbox[(size_t)i * 4 + 2] = dets[i].x2; gbox[(size_t)i * 4 + 3] = dets[i].y2; + } + std::vector gfeat = roi_align(gfeats, ghw, gbox.data(), MD, gp_); + + model_file fe = model_load(ge); + model_weights we = model_init(fe.n_tensors()); + model_transfer(fe, we, backend, backend.preferred_float_type(), fe.tensor_layout()); + + std::vector heat_all; + for (int n = 0; n < MD; ++n) { + // ⚠️ 여기도 **배치 1** 이다. grid head 는 deconv 를 두 번 타는데 ggml 의 + // conv_transpose 가 배치 축을 안 돈다(위 마스크 경로 주석 참고). + compute_graph g4 = compute_graph_init(65536); + model_ref me(we, g4); + tensor gin = compute_graph_input(me, GGML_TYPE_F32, {C, GO, GO, 1}, "groi"); + ggml_build_forward_expand(g4, gin); + ggml_build_forward_expand(g4, FWD_E(me, gin, PRM_E(fe))); + compute_graph_allocate(g4, backend); + std::vector cw((size_t)C * GO * GO); + for (int c = 0; c < C; ++c) + for (int y = 0; y < GO; ++y) + for (int x = 0; x < GO; ++x) + cw[((size_t)y * GO + x) * C + c] = + gfeat[(((size_t)n * C + c) * GO + y) * GO + x]; + transfer_to_backend(gin, std::span(cw.data(), cw.size())); + compute(g4, backend); + std::vector> hw; + auto go = read_outputs(g4, 4, &hw); + if (go.empty()) { + fprintf(stderr, "SubE(grid head) 출력이 없다\n"); + return 8; + } + const int GH = hw[0].first, GW = hw[0].second; // 반쪽 히트맵(28) + std::vector const& hm = go[0]; // cwhn: ((y*GW+x)*GP+k) + // 격자점마다 최대점을 찾는다. 점수는 sigmoid 를 거친 값이다. + std::vector sc(GP), ax(GP), ay(GP); + for (int k = 0; k < GP; ++k) { + float best = -INFINITY; int bx = 0, by = 0; + for (int y = 0; y < GH; ++y) + for (int x = 0; x < GW; ++x) { + const float v = hm[((size_t)y * GW + x) * GP + k]; + if (v > best) { best = v; bx = x; by = y; } + } + sc[k] = 1.0f / (1.0f + std::exp(-best)); + // 반쪽 히트맵 좌표 → 전체 히트맵 좌표. + const float sx = (2 * k < (int)sub.size()) ? sub[2 * k] : 0.0f; + const float sy = (2 * k + 1 < (int)sub.size()) ? sub[2 * k + 1] : 0.0f; + // ⚠️ **나누는 것은 전체 크기가 아니라 반쪽 크기(GW/GH)다.** mmdet 이 + // 그렇게 쓴다 — 전체 맵이 상자의 2배 영역을 덮으므로 반쪽으로 나누면 + // 확장 상자 기준이 된다. 전체 크기로 나누면 박스가 절반이 된다. + const float w0 = dets[n].x2 - dets[n].x1, h0 = dets[n].y2 - dets[n].y1; + ax[k] = ((float)bx + sx + 0.5f) / (float)GW * w0 + (dets[n].x1 - w0 * 0.5f); + ay[k] = ((float)by + sy + 0.5f) / (float)GH * h0 + (dets[n].y1 - h0 * 0.5f); + } + // 같은 변에 놓인 격자점들을 점수로 가중 평균한다. + auto vote = [&](std::vector const& idx, std::vector const& v) { + float num = 0.0f, den = 0.0f; + for (int i : idx) { num += v[i] * sc[i]; den += sc[i]; } + return den > 0.0f ? num / den : 0.0f; + }; + std::vector ix1, iy1, ix2, iy2; + for (int i = 0; i < GS; ++i) { + ix1.push_back(i); + iy1.push_back(i * GS); + ix2.push_back(GP - GS + i); + iy2.push_back((i + 1) * GS - 1); + } + dets[n].x1 = std::min(std::max(vote(ix1, ax), 0.0f), (float)SZ); + dets[n].y1 = std::min(std::max(vote(iy1, ay), 0.0f), (float)SZ); + dets[n].x2 = std::min(std::max(vote(ix2, ax), 0.0f), (float)SZ); + dets[n].y2 = std::min(std::max(vote(iy2, ay), 0.0f), (float)SZ); + heat_all.insert(heat_all.end(), hm.begin(), hm.end()); + } + dump_bin(pref + ".gridheat.bin", heat_all); + } +#endif + // ── 덤프 (torch 대조용) ───────────────────────────────────────────────── dump_bin(pref + ".props.bin", props); dump_bin(pref + ".roi.bin", roi); diff --git a/tools/verify/roi/verify_postproc_roi.py b/tools/verify/roi/verify_postproc_roi.py index ffd0c37..f189ee9 100644 --- a/tools/verify/roi/verify_postproc_roi.py +++ b/tools/verify/roi/verify_postproc_roi.py @@ -184,6 +184,15 @@ def one(fam, size, image, workdir, keep, verbose): # ① export — two-stage 를 두 subgraph 로 가른다. 여기서 죽으면 "왜" 를 그대로 옮긴다. fr = os.path.join(d, "frcnn") + # ⚠️ **단계마다 산출물을 먼저 지운다.** 존재 검사만 하면 이번 실행이 실패해도 지난 + # 실행의 것이 남아 있어 **그대로 통과한다** — 고친 코드가 반영 안 된 숫자를 + # "통과" 로 보고하게 된다. 공유 코드를 자주 고치는 날에는 이게 제일 위험하다 + # (verify_heads.py 가 `bb.pt` 에 대해 같은 이유로 이미 지우고 있다). + for stale in ("frcnn.json", "run_frcnn"): + try: + os.remove(os.path.join(fr, stale)) + except OSError: + pass r = run([PY, os.path.join(FE, "frcnn_to_pt.py"), "--config", cfg, "--checkpoint", ckpt, "--out", fr, "--size", str(size)], MM, {"PYTHONPATH": f"{d}:{FE}"}) if not os.path.exists(os.path.join(fr, "frcnn.json")): @@ -218,7 +227,18 @@ def one(fam, size, image, workdir, keep, verbose): f"1,{RC},{MO},{MO}"), ("MSRCNN_SubD", "MSRCNN_SubD", "out_MSRCNN_SubD", f"1,{RC + 1},{MO},{MO}")] + # Grid R-CNN: bbox head 에 회귀 분기가 없고 격자점 히트맵이 박스를 낸다. + # 여기도 배치 1 이다 — grid head 가 deconv 를 두 번 탄다. + has_grid = bool(J.get("has_grid")) + GO = int(J.get("grid_roi_out", 14)) + if has_grid: + jobs += [("GridRCNN_SubE", "GridRCNN_SubE", "out_GridRCNN_SubE", + f"1,{RC},{GO},{GO}")] for src, name, outdir, shape in jobs: + try: + os.remove(os.path.join(fr, outdir, f"{name}.gguf")) # 위와 같은 이유 + except OSError: + pass r = run([PY, "-c", f''' import _stub, sys sys.argv = ["g2c","--model","{src}.pt","--name","{name}","--output","{outdir}","--input-shape","{shape}"] @@ -232,16 +252,21 @@ def one(fam, size, image, workdir, keep, verbose): incs = [("FRCNN_SubA", "incA"), (subs[0], "incB")] if has_miou: incs += [("MaskRCNN_SubC", "incC"), ("MSRCNN_SubD", "incD")] + if has_grid: + incs += [("GridRCNN_SubE", "incE")] for name, inc in incs: os.makedirs(os.path.join(fr, inc, "visp", "arch"), exist_ok=True) shutil.copy(os.path.join(fr, "out_" + name, name + ".h"), os.path.join(fr, inc, "visp", "arch")) extra = [] if has_miou: - extra = ["-DARCH_C=MaskRCNN_SubC", "-DARCH_D=MSRCNN_SubD", - '-DVISP_ARCH_HEADER_C="visp/arch/MaskRCNN_SubC.h"', - '-DVISP_ARCH_HEADER_D="visp/arch/MSRCNN_SubD.h"', - "-IincC", "-IincD"] + extra += ["-DARCH_C=MaskRCNN_SubC", "-DARCH_D=MSRCNN_SubD", + '-DVISP_ARCH_HEADER_C="visp/arch/MaskRCNN_SubC.h"', + '-DVISP_ARCH_HEADER_D="visp/arch/MSRCNN_SubD.h"', + "-IincC", "-IincD"] + if has_grid: + extra += ["-DARCH_E=GridRCNN_SubE", + '-DVISP_ARCH_HEADER_E="visp/arch/GridRCNN_SubE.h"', "-IincE"] b = run(["g++", "-std=c++20", "-O1", "-DARCH_A=FRCNN_SubA", "-DARCH_B=" + subs[0], '-DVISP_ARCH_HEADER_A="visp/arch/FRCNN_SubA.h"', f'-DVISP_ARCH_HEADER_B="visp/arch/{subs[0]}.h"'] + extra + [ @@ -250,7 +275,8 @@ def one(fam, size, image, workdir, keep, verbose): V + "/tools/verify/backbone/run_frcnn.cpp", "out_FRCNN_SubA/FRCNN_SubA.cpp", f"out_{subs[0]}/{subs[0]}.cpp"] + ( ["out_MaskRCNN_SubC/MaskRCNN_SubC.cpp", "out_MSRCNN_SubD/MSRCNN_SubD.cpp"] - if has_miou else []) + [ + if has_miou else []) + ( + ["out_GridRCNN_SubE/GridRCNN_SubE.cpp"] if has_grid else []) + [ "-L" + BUILD + "/lib", "-lvisioncpp", "-lggml", "-lggml-base", "-lggml-cpu", "-Wl,-rpath," + BUILD + "/lib", "-o", "run_frcnn"], fr) if not os.path.exists(os.path.join(fr, "run_frcnn")): @@ -272,6 +298,11 @@ def one(fam, size, image, workdir, keep, verbose): "frcnn.json", binp, pref, str(size)] if has_miou: argv += ["out_MaskRCNN_SubC/MaskRCNN_SubC.gguf", "out_MSRCNN_SubD/MSRCNN_SubD.gguf"] + if has_grid: + # 러너 인자는 자리로 읽는다 — SubE 는 9번째다. 마스크가 없으면 자리를 채운다. + while len(argv) < 9: + argv.append("") + argv.append("out_GridRCNN_SubE/GridRCNN_SubE.gguf") rr = run(argv, fr, {"VISP_BACKEND": "cpu"}) if not os.path.exists(pref + ".boxes.bin"): return fam, "RUN_FAIL", last_error(rr.stderr)[:110], None From bbbc4fbad1f1194135e0beb47810f91a35397067 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 18 Aug 2026 14:47:31 +0900 Subject: [PATCH 61/89] =?UTF-8?q?feat(seesaw=C2=B7crowddet):=20=EB=91=90?= =?UTF-8?q?=20=EA=B3=84=EC=97=B4=20=ED=86=B5=EA=B3=BC=20=E2=80=94=200.09px?= =?UTF-8?q?=20=C2=B7=200.07px?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## seesaw_loss (64.13px → 0.09px) 두 가지가 겹쳐 있었다. ① **분류기가 `NormedLinear`** — 가중치와 입력을 각각 L2 정규화하고 온도 20을 곱한다. 가중치 정규화는 추론에서 **상수**라 프론트엔드에서 미리 접어 평범한 Linear 로 만든다. 남는 입력 정규화는 `Tensor.norm` 이 trace 에 안 잡히므로 `sqrt(sum(x*x))` 로 풀어 쓴다. 온도를 빼먹으면 크래시 없이 점수만 틀린다 — bias 가 뒤에 더해져 상쇄도 안 되고 softmax 는 스케일 불변이 아니다. ② **점수 활성이 softmax 가 아니다** — `SeesawLoss.custom_activation` 이다. 채널이 `num_classes + 2`(앞 C 개 = 클래스, 뒤 2개 = 전경/배경)이고, 각각 softmax 한 뒤 클래스 점수에 전경 확률을 곱하고 배경 확률을 뒤에 붙인다. ⚠️ 채널 수를 **출력 크기 −1 로 유추하면 안 된다** — 그러면 클래스가 하나 많아지고 라벨이 통째로 한 칸 밀린다. 객체(`bh.num_classes`)에서 읽는다. ## crowddet (109.71px · 1:500 → 0.07px · 1:1) 네 겹이었고 **전부 RPN 설정이 기본값이 아니어서** 생긴 것이다. 넷 다 shape 를 안 바꾼다. ① **set-NMS** — 같은 proposal 에서 나온 상자끼리는 서로 안 누른다. 겹쳐 선 두 사람은 IoU 가 높아 보통 NMS 면 하나가 지워진다. 이걸 빼면 억제가 통째로 어긋난다(1:500). 정제 분기가 있으면 **정제된 쌍**(cls_ref/box_ref)으로 예측한다. ② **`centers=[(8,8)]×5`** — mmdet `AnchorGenerator` 는 centers 가 주어지면 그 값을 쓰고 `center_offset` 을 무시한다. 재현식으로 만들면 stride 64 레벨에서 크게 어긋난다. ③ **`clip_border=False`** — proposal 을 이미지로 자르지 않는다(800 입력에 1054 가 나온다). ④ **`use_sigmoid` 미지정 → objectness 가 2채널** — RPNHead 의 기본은 1채널 sigmoid 지만 `loss_cls` 에 `use_sigmoid=True` 를 안 적으면 2채널 softmax 가 된다. 전경은 **index 0** 이다(mmdet v2.0 이후 FG label = 0). 2채널을 1채널로 읽으면 앵커 절반의 배경 로짓을 다른 앵커의 전경 점수로 오해한다 — 이게 제일 컸다(19.61px → 0.07px). RPN **텐서**는 처음부터 rel_L1 3e-04 로 맞았다. 그래서 그래프가 아니라 호스트 디코드로 범위를 좁힐 수 있었다 — 값이 틀리면 텐서부터 배제하는 것이 항상 빠르다. `min_bbox_size`(NMS 앞 필터)도 같이 넣었다. 다섯 파라미터 전부 기본값이 현재 동작과 같으므로 다른 계열은 그대로다: faster_rcnn 0.10px · cascade_rcnn 0.09px · ms_rcnn 0.07px. --- src/visp/postproc.cpp | 493 ++++++++++++++++++++++++++-- src/visp/postproc.h | 84 ++++- tools/frontend/mmdet/frcnn_wrap.py | 22 ++ tools/verify/backbone/run_frcnn.cpp | 32 +- 4 files changed, 609 insertions(+), 22 deletions(-) diff --git a/src/visp/postproc.cpp b/src/visp/postproc.cpp index abe8754..7b53eff 100755 --- a/src/visp/postproc.cpp +++ b/src/visp/postproc.cpp @@ -7,9 +7,15 @@ namespace visp { // ── anchor 생성 (mmdet AnchorGenerator.gen_single_level_base_anchors + grid) ── std::vector gen_anchors(int feat_h, int feat_w, float stride, float base_size, std::vector const& scales, - std::vector const& ratios, float center_offset) { + std::vector const& ratios, float center_offset, + float const* center_xy) { // base anchors: ratio-major, scale-minor (mmdet: w_ratios[:,None]*scales[None,:]).view(-1) + // ⚠️ **중심을 재현식으로 만들지 마라.** mmdet `AnchorGenerator` 는 `centers` 가 주어지면 + // 그 값을 그대로 쓰고 `center_offset` 은 무시한다(anchor_generator.py:`if self.centers + // is None`). crowddet 이 레벨마다 (8,8) 을 박아 두는데, 재현식으로 만들면 stride 64 + // 레벨에서 중심이 수십 픽셀 어긋나고 **shape 는 그대로**라 아무 검사에 안 걸린다. float xc = center_offset * base_size, yc = center_offset * base_size; + if (center_xy) { xc = center_xy[0]; yc = center_xy[1]; } std::vector base; // [num_base*4] for (float r : ratios) { float h_ratio = std::sqrt(r), w_ratio = 1.0f / h_ratio; @@ -108,6 +114,34 @@ std::vector nms(std::vector const& d, float iou_thr) { static inline float sigmoidf(float x) { return 1.0f / (1.0f + std::exp(-x)); } +// 한 레벨의 앵커. `base_anchor_boxes` 가 실려 있으면 **그 값이 정본**이고 격자만 민다 +// (SSD: 레벨마다 개수가 다르다 · YOLACT: base_size·중심을 stride 와 따로 준다). +// 없으면 재현식(base = stride·octave_base_scale, 중심 = center_offset·base)으로 만든다. +static std::vector level_anchors(det_params const& p, int l, int fh, int fw, + int& num_base) { + const float stride = p.strides[l]; + if (!p.base_anchor_boxes.empty()) { + std::vector const& ba = p.base_anchor_boxes[l]; + num_base = (int)(ba.size() / 4); + std::vector a; + a.reserve((size_t)fh * fw * ba.size()); + for (int h = 0; h < fh; ++h) + for (int w = 0; w < fw; ++w) { + const float sx = w * stride, sy = h * stride; + for (int b = 0; b < num_base; ++b) { + a.push_back(ba[b * 4 + 0] + sx); + a.push_back(ba[b * 4 + 1] + sy); + a.push_back(ba[b * 4 + 2] + sx); + a.push_back(ba[b * 4 + 3] + sy); + } + } + return a; + } + num_base = (int)(p.octave_scales.size() * p.ratios.size()); + return gen_anchors(fh, fw, stride, stride * p.octave_base_scale, + p.octave_scales, p.ratios, p.center_offset); +} + // ── 앵커-기반 검출 후처리 (mmdet _predict_by_feat_single) ──────────────────── std::vector detect_anchor( std::vector> const& cls_scores, @@ -115,19 +149,21 @@ std::vector detect_anchor( std::vector> const& feat_hw, det_params const& p, std::vector> const* score_factors) { - int num_base = (int)(p.octave_scales.size() * p.ratios.size()); int nc = p.num_classes; + // softmax head(SSD·YOLACT·고전 계열)는 채널이 하나 더다 — 마지막이 배경이고, + // mmdet 은 `softmax(-1)[:, :-1]` 로 배경만 버린다(anchor_head.py). 시그모이드 + // 계열은 채널 수 = 클래스 수 그대로다. + const int ncch = nc + (p.use_sigmoid ? 0 : 1); // 후보 수집: (score, label, box) — 레벨별 nms_pre topk + score_thr std::vector cand; int nlev = (int)feat_hw.size(); for (int l = 0; l < nlev; ++l) { int fh = feat_hw[l].first, fw = feat_hw[l].second; float stride = p.strides[l]; - float base_size = stride * p.octave_base_scale; - std::vector anchors = gen_anchors(fh, fw, stride, base_size, - p.octave_scales, p.ratios, p.center_offset); - int C_cls = num_base * nc, C_box = num_base * 4; - float const* cls = cls_scores[l].data(); // HWC flat: (h*fw+w)*C_cls + b*nc + j + int num_base = 0; + std::vector anchors = level_anchors(p, l, fh, fw, num_base); + int C_cls = num_base * ncch, C_box = num_base * 4; + float const* cls = cls_scores[l].data(); // HWC flat: (h*fw+w)*C_cls + b*ncch + j float const* box = bbox_preds[l].data(); // HWC flat: (h*fw+w)*C_box + b*4 + k int npos = fh * fw; // (score,label,anchor_idx) 후보 → score_thr 넘는 것만, 레벨당 nms_pre topk @@ -135,10 +171,22 @@ std::vector detect_anchor( for (int pos = 0; pos < npos; ++pos) { for (int b = 0; b < num_base; ++b) { int aidx = pos * num_base + b; - float const* cs = cls + (size_t)pos * C_cls + (size_t)b * nc; - for (int j = 0; j < nc; ++j) { - float sc = p.use_sigmoid ? sigmoidf(cs[j]) : cs[j]; - if (sc > p.score_thr) lvl.emplace_back(sc, j, aidx); + float const* cs = cls + (size_t)pos * C_cls + (size_t)b * ncch; + if (p.use_sigmoid) { + for (int j = 0; j < nc; ++j) { + float sc = sigmoidf(cs[j]); + if (sc > p.score_thr) lvl.emplace_back(sc, j, aidx); + } + } else { + // softmax — 배경(마지막 채널)은 정규화에만 넣고 후보에서는 버린다. + float mx = cs[0]; + for (int j = 1; j < ncch; ++j) mx = std::max(mx, cs[j]); + float sum = 0.0f; + for (int j = 0; j < ncch; ++j) sum += std::exp(cs[j] - mx); + for (int j = 0; j < nc; ++j) { + float sc = std::exp(cs[j] - mx) / sum; + if (sc > p.score_thr) lvl.emplace_back(sc, j, aidx); + } } } } @@ -163,8 +211,33 @@ std::vector detect_anchor( float const* bp = box + (size_t)pos * C_box + (size_t)b * 4; for (int k = 0; k < 4; ++k) delta[k] = bp[k]; float outb[4]; - delta2bbox(anchors.data() + (size_t)aidx * 4, delta, 1, outb, - p.means, p.stds, p.input_w, p.input_h, p.ctr_clamp); + if (p.tblr_normalizer > 0.0f) { + // FSAF(TBLRBBoxCoder). 채널이 (t,b,l,r) 순이고 t·b 에는 앵커 **높이**, + // l·r 에는 **너비**를 곱한다(tblr_bbox_coder.py `tblr2bboxes`). + float const* a = anchors.data() + (size_t)aidx * 4; + float cx = (a[0] + a[2]) * 0.5f, cy = (a[1] + a[3]) * 0.5f; + float aw = a[2] - a[0], ah = a[3] - a[1]; + float tt = delta[0] * p.tblr_normalizer * ah; + float bb = delta[1] * p.tblr_normalizer * ah; + float ll = delta[2] * p.tblr_normalizer * aw; + float rr = delta[3] * p.tblr_normalizer * aw; + outb[0] = cx - ll; outb[1] = cy - tt; + outb[2] = cx + rr; outb[3] = cy + bb; + if (p.input_w > 0) { + outb[0] = std::min(std::max(outb[0], 0.0f), (float)p.input_w); + outb[2] = std::min(std::max(outb[2], 0.0f), (float)p.input_w); + outb[1] = std::min(std::max(outb[1], 0.0f), (float)p.input_h); + outb[3] = std::min(std::max(outb[3], 0.0f), (float)p.input_h); + } + } else { + delta2bbox(anchors.data() + (size_t)aidx * 4, delta, 1, outb, + p.means, p.stds, p.input_w, p.input_h, p.ctr_clamp); + } + // min_bbox_size(≥0): 경계로 클램프돼 변이 0 이 된 박스를 버린다 — IoU 0 이라 + // NMS 가 못 지우고, mmdet 은 NMS 전에 걸러낸다(base_dense_head.py:476-478). + if (p.min_bbox_size >= 0.0f && + (outb[2] - outb[0] <= p.min_bbox_size || outb[3] - outb[1] <= p.min_bbox_size)) + continue; float sc = std::get<0>(t); if (sf) sc *= sigmoidf(sf[(size_t)pos * num_base + b]); cand.push_back({outb[0], outb[1], outb[2], outb[3], sc, std::get<1>(t)}); @@ -478,16 +551,35 @@ std::vector detect_rpn( int fh = feat_hw[l].first, fw = feat_hw[l].second; float stride = p.strides[l]; float base_size = stride * p.octave_base_scale; - std::vector anchors = gen_anchors(fh, fw, stride, base_size, p.octave_scales, p.ratios); - int C_cls = num_base * 1, C_box = num_base * 4; + float const* cxy = ((int)p.centers.size() >= 2 * (l + 1)) ? &p.centers[2 * l] : nullptr; + std::vector anchors = gen_anchors(fh, fw, stride, base_size, p.octave_scales, + p.ratios, 0.0f, cxy); + // 채널 수는 **파라미터가 정본**이되, 실제 텐서와 다르면 텐서를 믿는다 — + // 설정을 못 읽은 계열에서 조용히 틀리는 것보다 낫다. + int cls_ch = std::max(1, p.cls_out_channels); + if ((int)rpn_cls[l].size() == fh * fw * num_base * 2) cls_ch = 2; + else if ((int)rpn_cls[l].size() == fh * fw * num_base) cls_ch = 1; + int C_cls = num_base * cls_ch, C_box = num_base * 4; float const* cls = rpn_cls[l].data(); // CWHN: (h*fw+w)*num_base + b float const* box = rpn_bbox[l].data(); // CWHN: (h*fw+w)*C_box + b*4 + k int npos = fh * fw; std::vector> lvl; // (objectness, anchor_idx) lvl.reserve((size_t)npos * num_base); for (int pos = 0; pos < npos; ++pos) - for (int b = 0; b < num_base; ++b) - lvl.emplace_back(sigmoidf(cls[(size_t)pos * C_cls + b]), pos * num_base + b); + for (int b = 0; b < num_base; ++b) { + float sc; + if (cls_ch == 1) { + sc = sigmoidf(cls[(size_t)pos * C_cls + b]); + } else { + // 채널은 앵커-major · 클래스-minor 다(conv 출력 = num_base*cls_ch). + // 전경은 index 0 — `softmax(-1)[:, :-1]` 이 그 뜻이다. + float const* c2 = cls + (size_t)pos * C_cls + (size_t)b * cls_ch; + const float mx = std::max(c2[0], c2[1]); + const float e0 = std::exp(c2[0] - mx), e1 = std::exp(c2[1] - mx); + sc = e0 / (e0 + e1); + } + lvl.emplace_back(sc, pos * num_base + b); + } // 레벨별 nms_pre topk (RPN 은 pre-NMS score_thr 없음) if (p.nms_pre > 0 && (int)lvl.size() > p.nms_pre) { std::nth_element(lvl.begin(), lvl.begin() + p.nms_pre, lvl.end(), @@ -499,8 +591,14 @@ std::vector detect_rpn( float const* bp = box + (size_t)pos * C_box + (size_t)b * 4; float delta[4] = {bp[0], bp[1], bp[2], bp[3]}; float outb[4]; - delta2bbox(anchors.data() + (size_t)aidx * 4, delta, 1, outb, - p.means, p.stds, p.input_w, p.input_h); + // clip_border=false 인 계열은 이미지로 자르지 않는다(0 을 주면 클리핑이 꺼진다). + delta2bbox(anchors.data() + (size_t)aidx * 4, delta, 1, outb, p.means, p.stds, + p.clip_border ? p.input_w : 0, p.clip_border ? p.input_h : 0); + // min_bbox_size 는 **NMS 앞에서** 건다(mmdet `_bbox_post_process`, 강부등호). + if (p.min_bbox_size > 0.0f && + !(outb[2] - outb[0] > p.min_bbox_size && outb[3] - outb[1] > p.min_bbox_size)) { + continue; + } cand.push_back({outb[0], outb[1], outb[2], outb[3], t.first, l}); } } @@ -881,4 +979,361 @@ std::vector detect_sabl( return kept; } + +// (x1,y1,x2,y2) 두 상자의 IoU — mmdet `bbox_overlaps` 와 같은 식(+1 보정 없음). +static inline float box_iou4(float const* a, float const* b) { + float xx1 = std::max(a[0], b[0]), yy1 = std::max(a[1], b[1]); + float xx2 = std::min(a[2], b[2]), yy2 = std::min(a[3], b[3]); + float inter = std::max(0.0f, xx2 - xx1) * std::max(0.0f, yy2 - yy1); + float aa = std::max(0.0f, a[2] - a[0]) * std::max(0.0f, a[3] - a[1]); + float ab = std::max(0.0f, b[2] - b[0]) * std::max(0.0f, b[3] - b[1]); + return inter / (aa + ab - inter + 1e-9f); +} + +// ── PAA / LAD ──────────────────────────────────────────────────────────────── +// mmdet `paa_head.py _predict_by_feat_single`. detect_anchor 와 셋이 다르다: +// ① 레벨별 top-k 가 **앵커 단위**다 — 기준은 max_j sqrt(cls_j×iou) (paa_head.py:582-585) +// ② 임계값·NMS 점수가 전부 sqrt(cls×iou) 다 (:647) +// ③ NMS 뒤 score voting — IoU>0.01 인 같은 클래스 후보들의 +// exp(−(1−iou)²/0.025)·score 가중 평균으로 박스를 다시 놓는다. 점수는 그대로다. +std::vector detect_paa( + std::vector> const& cls_scores, + std::vector> const& bbox_preds, + std::vector> const& iou_preds, + std::vector> const& feat_hw, det_params const& p) { + + const int nc = p.num_classes; + std::vector cand; // score_thr 를 넘은 (박스, sqrt점수, 라벨) + const int nlev = (int)feat_hw.size(); + for (int l = 0; l < nlev; ++l) { + const int fh = feat_hw[l].first, fw = feat_hw[l].second; + int num_base = 0; + std::vector anchors = level_anchors(p, l, fh, fw, num_base); + const int C_cls = num_base * nc, C_box = num_base * 4; + float const* cls = cls_scores[l].data(); + float const* box = bbox_preds[l].data(); + float const* iou = iou_preds[l].data(); + const int na = fh * fw * num_base; + // 앵커 단위 순위 — (max_j sqrt(cls×iou), anchor_idx) + std::vector> rank; + rank.reserve(na); + for (int pos = 0; pos < fh * fw; ++pos) + for (int b = 0; b < num_base; ++b) { + float sf = sigmoidf(iou[(size_t)pos * num_base + b]); + float const* cs = cls + (size_t)pos * C_cls + (size_t)b * nc; + float best = 0.0f; + for (int j = 0; j < nc; ++j) best = std::max(best, sigmoidf(cs[j]) * sf); + rank.emplace_back(std::sqrt(best), pos * num_base + b); + } + if (p.nms_pre > 0 && (int)rank.size() > p.nms_pre) { + std::nth_element(rank.begin(), rank.begin() + p.nms_pre, rank.end(), + [](auto const& a, auto const& b) { return a.first > b.first; }); + rank.resize(p.nms_pre); + } + for (auto const& r : rank) { + const int aidx = r.second, b = aidx % num_base, pos = aidx / num_base; + float delta[4]; + float const* bp = box + (size_t)pos * C_box + (size_t)b * 4; + for (int k = 0; k < 4; ++k) delta[k] = bp[k]; + float outb[4]; + delta2bbox(anchors.data() + (size_t)aidx * 4, delta, 1, outb, + p.means, p.stds, p.input_w, p.input_h, p.ctr_clamp); + if (p.min_bbox_size >= 0.0f && + (outb[2] - outb[0] <= p.min_bbox_size || outb[3] - outb[1] <= p.min_bbox_size)) + continue; + const float sf = sigmoidf(iou[(size_t)pos * num_base + b]); + float const* cs = cls + (size_t)pos * C_cls + (size_t)b * nc; + for (int j = 0; j < nc; ++j) { + const float sc = std::sqrt(sigmoidf(cs[j]) * sf); + if (sc > p.score_thr) + cand.push_back({outb[0], outb[1], outb[2], outb[3], sc, j}); + } + } + } + + // 클래스별 NMS → 점수순 → max_per_img (multiclass_nms 동등) + std::vector out; + int maxlabel = 0; + for (auto const& c : cand) maxlabel = std::max(maxlabel, c.label); + for (int lab = 0; lab <= maxlabel; ++lab) { + std::vector per; + for (auto const& c : cand) if (c.label == lab) per.push_back(c); + if (per.empty()) continue; + for (int k : nms(per, p.nms_thr)) out.push_back(per[k]); + } + std::sort(out.begin(), out.end(), + [](detection const& a, detection const& b) { return a.score > b.score; }); + if (p.max_per_img > 0 && (int)out.size() > p.max_per_img) out.resize(p.max_per_img); + + // score voting — 후보 풀은 **NMS 전, 임계값 넘은 전체**다(cand 그대로). + if (p.score_voting) { + for (auto& d : out) { + float bx[4] = {d.x1, d.y1, d.x2, d.y2}; + double acc[4] = {0, 0, 0, 0}, wsum = 0.0; + for (auto const& c : cand) { + if (c.label != d.label) continue; + float cb[4] = {c.x1, c.y1, c.x2, c.y2}; + const float ov = box_iou4(bx, cb); + if (ov <= 0.01f) continue; + const double w = std::exp(-(1.0 - ov) * (1.0 - ov) / 0.025) * c.score; + for (int k = 0; k < 4; ++k) acc[k] += w * cb[k]; + wsum += w; + } + if (wsum > 0.0) { + d.x1 = (float)(acc[0] / wsum); d.y1 = (float)(acc[1] / wsum); + d.x2 = (float)(acc[2] / wsum); d.y2 = (float)(acc[3] / wsum); + } + } + } + return out; +} + +// ── YOLACT (박스 갈래만 — mask/coeff 는 이 하네스 밖) ──────────────────────── +// mmdet `yolact_head.py`. detect_anchor 와 셋이 다르다: +// ① softmax 이고 배경이 마지막 채널 ② 레벨별 top-k 가 **앵커 단위**(배경 뺀 최대 점수) +// ③ NMS 가 fast NMS — 클래스별 정렬 top_k 안에서 상삼각 IoU 최댓값이 임계 **이하**인 +// 것만 살린다(bbox_nms.py `fast_nms`: 이미 제거된 박스도 남을 박스를 누른다). +std::vector detect_yolact( + std::vector> const& cls_scores, + std::vector> const& bbox_preds, + std::vector> const& feat_hw, det_params const& p) { + + const int nc = p.num_classes; + const int ncch = nc + 1; // softmax — 마지막이 배경 + std::vector cboxes; // [n*4] + std::vector cscores; // [n*nc] (softmax, 배경 제외) + const int nlev = (int)feat_hw.size(); + for (int l = 0; l < nlev; ++l) { + const int fh = feat_hw[l].first, fw = feat_hw[l].second; + int num_base = 0; + std::vector anchors = level_anchors(p, l, fh, fw, num_base); + const int C_cls = num_base * ncch, C_box = num_base * 4; + float const* cls = cls_scores[l].data(); + float const* box = bbox_preds[l].data(); + // 앵커 단위 top-k — 기준은 배경 뺀 softmax 최댓값 + std::vector> rank; + rank.reserve((size_t)fh * fw * num_base); + std::vector sm((size_t)fh * fw * num_base * nc); + for (int pos = 0; pos < fh * fw; ++pos) + for (int b = 0; b < num_base; ++b) { + const int aidx = pos * num_base + b; + float const* cs = cls + (size_t)pos * C_cls + (size_t)b * ncch; + float mx = cs[0]; + for (int j = 1; j < ncch; ++j) mx = std::max(mx, cs[j]); + float sum = 0.0f; + for (int j = 0; j < ncch; ++j) sum += std::exp(cs[j] - mx); + float best = 0.0f; + for (int j = 0; j < nc; ++j) { + const float s = std::exp(cs[j] - mx) / sum; + sm[(size_t)aidx * nc + j] = s; + best = std::max(best, s); + } + rank.emplace_back(best, aidx); + } + if (p.nms_pre > 0 && (int)rank.size() > p.nms_pre) { + std::nth_element(rank.begin(), rank.begin() + p.nms_pre, rank.end(), + [](auto const& a, auto const& b) { return a.first > b.first; }); + rank.resize(p.nms_pre); + } + for (auto const& r : rank) { + const int aidx = r.second, b = aidx % num_base, pos = aidx / num_base; + float delta[4]; + float const* bp = box + (size_t)pos * C_box + (size_t)b * 4; + for (int k = 0; k < 4; ++k) delta[k] = bp[k]; + float outb[4]; + delta2bbox(anchors.data() + (size_t)aidx * 4, delta, 1, outb, + p.means, p.stds, p.input_w, p.input_h, p.ctr_clamp); + for (int k = 0; k < 4; ++k) cboxes.push_back(outb[k]); + for (int j = 0; j < nc; ++j) cscores.push_back(sm[(size_t)aidx * nc + j]); + } + } + + // fast NMS. 같은 박스 집합을 클래스마다 그 클래스 점수로 정렬해 따로 거른다. + const int n = (int)(cboxes.size() / 4); + const int topk = p.nms_top_k > 0 ? std::min(p.nms_top_k, n) : n; + std::vector out; + std::vector order(n); + for (int cls = 0; cls < nc; ++cls) { + for (int i = 0; i < n; ++i) order[i] = i; + std::partial_sort(order.begin(), order.begin() + topk, order.end(), + [&](int a, int b) { + return cscores[(size_t)a * nc + cls] > cscores[(size_t)b * nc + cls]; + }); + // iou_max[j] = 더 높은 순위 i 만 통과) + float mx = 0.0f; + for (int i = 0; i < j; ++i) + mx = std::max(mx, box_iou4(&cboxes[(size_t)order[i] * 4], + &cboxes[(size_t)order[j] * 4])); + if (mx <= p.nms_thr) { + float const* bx = &cboxes[(size_t)order[j] * 4]; + out.push_back({bx[0], bx[1], bx[2], bx[3], sc, cls}); + } + } + } + std::sort(out.begin(), out.end(), + [](detection const& a, detection const& b) { return a.score > b.score; }); + if (p.max_per_img > 0 && (int)out.size() > p.max_per_img) out.resize(p.max_per_img); + return out; +} + + +// ── CornerNet / CentripetalNet ─────────────────────────────────────────────── +// mmdet `corner_head.py _decode_heatmap` + `_bboxes_nms`. 앵커가 없다 — +// 코너 두 장에서 각각 top-k 를 뽑아 **k×k 쌍**을 만들고 규칙으로 걸러낸다. +std::vector detect_corner( + std::vector const& tl_heat, std::vector const& br_heat, + std::vector const& tl_off, std::vector const& br_off, + std::vector const& tl_emb, std::vector const& br_emb, + std::vector const& tl_shift, std::vector const& br_shift, + int fh, int fw, corner_params const& p) { + + const int nc = p.num_classes, npos = fh * fw, k = p.topk; + const int pad = (p.local_max_kernel - 1) / 2; + + // ① 국소 최대만 남긴다(get_local_maximum): maxpool(k, stride 1) 과 같은 자리만 통과. + // heatmap 은 CWHN flat 이라 (y*fw+x)*nc + c 다. + auto local_max = [&](std::vector const& h, std::vector& out) { + out.assign((size_t)npos * nc, 0.0f); + for (int y = 0; y < fh; ++y) + for (int x = 0; x < fw; ++x) + for (int c = 0; c < nc; ++c) { + const float v = sigmoidf(h[((size_t)y * fw + x) * nc + c]); + float mx = -1e30f; + for (int dy = -pad; dy <= pad; ++dy) { + const int yy = y + dy; + if (yy < 0 || yy >= fh) continue; + for (int dx = -pad; dx <= pad; ++dx) { + const int xx = x + dx; + if (xx < 0 || xx >= fw) continue; + mx = std::max(mx, sigmoidf(h[((size_t)yy * fw + xx) * nc + c])); + } + } + // mmdet 은 `hmax == heat` 로 비교한다 — 같은 값이 여럿이면 둘 다 남는다. + if (v >= mx) out[((size_t)y * fw + x) * nc + c] = v; + } + }; + std::vector tl_lm, br_lm; + local_max(tl_heat, tl_lm); + local_max(br_heat, br_lm); + + // ② 코너별 top-k. mmdet 은 (class, y, x) 를 편 뒤 topk 이므로 **클래스가 인덱스에 섞인다**. + struct corner { float score; int cls, y, x, pos; }; + auto topk = [&](std::vector const& lm, std::vector& out) { + std::vector> all; + all.reserve((size_t)npos * nc); + for (int c = 0; c < nc; ++c) + for (int i = 0; i < npos; ++i) + all.emplace_back(lm[(size_t)i * nc + c], c * npos + i); + const int kk = std::min(k, (int)all.size()); + std::partial_sort(all.begin(), all.begin() + kk, all.end(), + [](auto const& a, auto const& b) { return a.first > b.first; }); + out.clear(); + for (int i = 0; i < kk; ++i) { + const int idx = all[(size_t)i].second, c = idx / npos, pos = idx % npos; + out.push_back({all[(size_t)i].first, c, pos / fw, pos % fw, pos}); + } + }; + std::vector tl, br; + topk(tl_lm, tl); + topk(br_lm, br); + + // ③ 코너 좌표 보정(offset) + 픽셀 스케일. off 는 (x,y) 2채널. + const float sx = p.input_w > 0 ? (float)p.input_w / fw : 1.0f; + const float sy = p.input_h > 0 ? (float)p.input_h / fh : 1.0f; + auto corner_xy = [&](corner const& c, std::vector const& off, + float& ox, float& oy) { + ox = c.x + off[(size_t)c.pos * 2 + 0]; + oy = c.y + off[(size_t)c.pos * 2 + 1]; + }; + + std::vector cand; + for (auto const& a : tl) { + float ax, ay; + corner_xy(a, tl_off, ax, ay); + for (auto const& b : br) { + if (a.cls != b.cls) continue; // 클래스가 같아야 한 물체다 + float bx, by; + corner_xy(b, br_off, bx, by); + if (bx <= ax || by <= ay) continue; // width/height 음수 거부 + + // 짝짓기 판정 — 두 방식 중 하나(mmdet 은 assert 로 하나만 켜지게 한다). + float dist; + if (p.centripetal) { + // centripetal shift 로 옮긴 점이 박스 **중앙 영역**에 드는지 본다. + const float tcx = ax + std::exp(tl_shift[(size_t)a.pos * 2 + 0]); + const float tcy = ay + std::exp(tl_shift[(size_t)a.pos * 2 + 1]); + const float bcx = bx - std::exp(br_shift[(size_t)b.pos * 2 + 0]); + const float bcy = by - std::exp(br_shift[(size_t)b.pos * 2 + 1]); + // 픽셀로 올린 뒤 판정한다(mmdet 도 스케일 후에 비교한다). + const float X1 = ax * sx, Y1 = ay * sy, X2 = bx * sx, Y2 = by * sy; + float T1 = tcx * sx, T2 = tcy * sy, B1 = bcx * sx, B2 = bcy * sy; + T1 = T1 > 0 ? T1 : 0.0f; T2 = T2 > 0 ? T2 : 0.0f; + B1 = B1 > 0 ? B1 : 0.0f; B2 = B2 > 0 ? B2 : 0.0f; + const float area = std::fabs((X2 - X1) * (Y2 - Y1)); + // 논문 4.1 의 상수 — 큰 박스는 중앙 영역을 좁게 잡는다. + const float mu = area > 3500.0f ? 1.0f / 2.1f : 1.0f / 2.4f; + const float cx = (X1 + X2) * 0.5f, cy = (Y1 + Y2) * 0.5f; + const float r0 = cx - mu * (X2 - X1) * 0.5f, r1 = cy - mu * (Y2 - Y1) * 0.5f; + const float r2 = cx + mu * (X2 - X1) * 0.5f, r3 = cy + mu * (Y2 - Y1) * 0.5f; + if (T1 <= r0 || T1 >= r2 || T2 <= r1 || T2 >= r3 || + B1 <= r0 || B1 >= r2 || B2 <= r1 || B2 >= r3) + continue; // 중앙 영역 밖 — 버린다 + const float area_ct = std::fabs((B1 - T1) * (B2 - T2)); + const float area_r = std::fabs((r2 - r0) * (r3 - r1)); + dist = area_ct / (area_r + 1e-12f); + } else { + dist = std::fabs(tl_emb[(size_t)a.pos] - br_emb[(size_t)b.pos]); + } + if (dist > p.distance_threshold) continue; + + const float score = (a.score + b.score) * 0.5f; + cand.push_back({ax * sx, ay * sy, bx * sx, by * sy, score, a.cls}); + } + } + + // ④ 전체에서 num_dets(=max_per_img) 만 남긴 뒤 score_thr — mmdet 순서 그대로다. + std::sort(cand.begin(), cand.end(), + [](detection const& a, detection const& b) { return a.score > b.score; }); + if (p.max_per_img > 0 && (int)cand.size() > p.max_per_img) cand.resize(p.max_per_img); + std::vector kept; + for (auto const& d : cand) if (d.score > p.score_thr) kept.push_back(d); + + // ⑤ soft-NMS(gaussian, 클래스별). 하드 NMS 와 달리 **지우지 않고 점수를 깎는다** — + // 겹친 상자가 살아남되 점수가 exp(−iou²/σ) 배가 된다(mmcv `soft_nms`). + std::vector out; + int maxlabel = 0; + for (auto const& c : kept) maxlabel = std::max(maxlabel, c.label); + for (int lab = 0; lab <= maxlabel; ++lab) { + std::vector per; + for (auto const& c : kept) if (c.label == lab) per.push_back(c); + while (!per.empty()) { + int best = 0; + for (int i = 1; i < (int)per.size(); ++i) + if (per[(size_t)i].score > per[(size_t)best].score) best = i; + detection m = per[(size_t)best]; + per.erase(per.begin() + best); + if (m.score < p.soft_nms_min_score) break; // 남은 것은 더 낮다 + out.push_back(m); + float mb[4] = {m.x1, m.y1, m.x2, m.y2}; + for (auto& d : per) { + float db[4] = {d.x1, d.y1, d.x2, d.y2}; + const float ov = box_iou4(mb, db); + d.score *= std::exp(-ov * ov / p.soft_nms_sigma); + } + per.erase(std::remove_if(per.begin(), per.end(), + [&](detection const& d) { + return d.score < p.soft_nms_min_score; + }), + per.end()); + } + } + std::sort(out.begin(), out.end(), + [](detection const& a, detection const& b) { return a.score > b.score; }); + if (p.max_per_img > 0 && (int)out.size() > p.max_per_img) out.resize(p.max_per_img); + return out; +} + } // namespace visp diff --git a/src/visp/postproc.h b/src/visp/postproc.h index 2c65056..9a62f69 100755 --- a/src/visp/postproc.h +++ b/src/visp/postproc.h @@ -15,9 +15,11 @@ struct detection { // ── anchor 생성 (mmdet AnchorGenerator) ───────────────────────────────────── // 한 레벨의 anchor grid: base_anchor(scales×ratios) × (feat_w×feat_h 격자 shift). // 반환 [N*4] (x1,y1,x2,y2), N = feat_h*feat_w*num_base. octave_scales = scale 배율 목록. +// `center_xy` 가 있으면 base anchor 중심으로 그 값을 그대로 쓴다(center_offset 무시). std::vector gen_anchors(int feat_h, int feat_w, float stride, float base_size, std::vector const& octave_scales, - std::vector const& ratios, float center_offset = 0.0f); + std::vector const& ratios, float center_offset = 0.0f, + float const* center_xy = nullptr); // ── bbox decode (mmdet DeltaXYWHBBoxCoder.delta2bbox) ──────────────────────── // anchors[N*4], deltas[N*4] → out[N*4]. denorm(mean/std) + exp(dwh) + clamp + clip. @@ -58,6 +60,22 @@ struct det_params { int num_buckets = 0; float bucket_scale = 3.0f; float anchor_scale = 4.0f; + // FSAF 의 TBLRBBoxCoder. >0 이면 delta 대신 TBLR 로 디코드한다 — + // 채널 순서가 (t,b,l,r) 이고, 값에 normalizer 와 앵커 변 길이를 곱한다. + float tblr_normalizer = 0.0f; + // SSD 처럼 **레벨마다 앵커 수·모양이 다른** 계열. 비어 있지 않으면 octave/ratio 로 + // 앵커를 만들지 않고 이 base 앵커((x1,y1,x2,y2)·n, 중심 (0,0) 부근)를 격자만큼 민다. + std::vector> base_anchor_boxes; + // mmdet `_bbox_post_process` 의 min_bbox_size. ≥0 이면 **변이 그 이하인 박스를 + // NMS 전에 버린다**(w>min && h>min). SSD 처럼 앵커가 이미지 밖까지 깔리는 계열은 + // 경계로 클램프된 면적 0 박스가 생기는데, 이 필터가 없으면 IoU 0 이라 NMS 도 + // 못 지워 **점수 높은 유령 박스**로 살아남는다(ssd 실측: mmdet 4건 vs C++ 31건). + float min_bbox_size = -1.0f; + // PAA·LAD: 점수 = sqrt(cls_sig × iou_sig) 이고, NMS 뒤 **IoU 가중 박스 투표**를 한다. + bool score_voting = false; + // YOLACT 의 fast NMS. 클래스별 정렬 top_k 안에서 상삼각 IoU 최댓값으로 한 번에 거른다. + bool fast_nms = false; + int nms_top_k = 200; // fast NMS 의 클래스별 후보 상한(test_cfg.top_k) }; // per-level 원시출력: cls_scores[level] = [num_base*num_classes, feat_w, feat_h](CWHN flat), @@ -74,6 +92,55 @@ std::vector detect_anchor( det_params const& p, std::vector> const* score_factors = nullptr); +// ── PAA / LAD ─────────────────────────────────────────────────────────────── +// detect_anchor 와 갈리는 곳이 셋이라 별도 함수다(paa_head.py `_predict_by_feat_single`): +// ① 레벨별 top-k 를 (anchor,class) 쌍이 아니라 **앵커 단위**로 자른다 +// (기준: max_j sqrt(cls_j × iou)) ② 임계값·NMS 점수가 sqrt(cls × iou) 다 +// ③ NMS 뒤 **score voting** — 살아남은 박스를 같은 클래스 후보들의 +// IoU 가중 평균(exp(−(1−iou)²/0.025)·score)으로 다시 놓는다. 점수는 그대로다. +std::vector detect_paa( + std::vector> const& cls_scores, + std::vector> const& bbox_preds, + std::vector> const& iou_preds, + std::vector> const& feat_hw, det_params const& p); + +// ── CornerNet / CentripetalNet ────────────────────────────────────────────── +// 앵커도 격자 회귀도 없다. 좌상·우하 **코너 heatmap** 에서 각각 top-k 를 뽑아 k×k 쌍을 +// 만들고, 쌍이 같은 물체인지 두 방식 중 하나로 가른다: +// · CornerNet — associative embedding 거리 |tl_emb − br_emb| +// · CentripetalNet — centripetal shift 로 옮긴 두 점이 박스 중앙 영역에 드는가 +// 살아남은 쌍의 점수는 (tl+br)/2 이고, 마지막은 **soft-NMS(gaussian)** 다. +struct corner_params { + int num_classes = 80; + int topk = 100; // 코너별 top-k (test_cfg.corner_topk) + int local_max_kernel = 3; // heatmap 국소 최대 억제 커널 + float distance_threshold = 0.5f; // emb 거리 / centripetal dists 상한 + float score_thr = 0.05f; + float nms_thr = 0.5f; // soft-NMS 의 iou_threshold(gaussian 은 감쇠에만 쓴다) + float soft_nms_sigma = 0.5f; + float soft_nms_min_score = 1e-3f; + int max_per_img = 100; + int input_w = 0, input_h = 0; + bool centripetal = false; // true 면 emb 대신 centripetal shift 로 짝짓는다 +}; +// 전부 CWHN flat, 단일 레벨(hourglass 마지막 단). off/shift 는 (x,y) 2채널. +// emb 는 CornerNet 만, shift 는 CentripetalNet 만 준다(nullptr 대신 빈 vector). +std::vector detect_corner( + std::vector const& tl_heat, std::vector const& br_heat, + std::vector const& tl_off, std::vector const& br_off, + std::vector const& tl_emb, std::vector const& br_emb, + std::vector const& tl_shift, std::vector const& br_shift, + int fh, int fw, corner_params const& p); + +// ── YOLACT (박스 갈래만 — mask 는 이 하네스 밖) ───────────────────────────── +// 역시 셋이 다르다(yolact_head.py): ① softmax(배경 마지막 채널) ② 레벨별 top-k 가 +// **앵커 단위**(배경 뺀 최대 점수) ③ NMS 가 fast NMS — 클래스별로 정렬해 top_k 만 남기고, +// 상삼각 IoU 최댓값이 임계 이하인 것만 살린다(이미 제거된 박스도 남을 박스를 누른다). +std::vector detect_yolact( + std::vector> const& cls_scores, + std::vector> const& bbox_preds, + std::vector> const& feat_hw, det_params const& p); + // ── anchor-free 검출 (FCOS/FCOS계열) ──────────────────────────────────────── // point 생성 (mmdet MlvlPointGenerator): point = (idx + offset) * stride. std::vector gen_points(int feat_h, int feat_w, float stride, float offset = 0.5f); @@ -249,6 +316,21 @@ struct rpn_params { float nms_thr = 0.7f; int max_per_img = 1000; // 최종 proposal 수 int input_w = 0, input_h = 0; + // ⚠️ 아래 셋은 **계열마다 다르고 전부 기본값이 아닐 수 있다.** 셋 다 shape 를 안 바꾼다. + // `centers` — 레벨별 base anchor 중심 (cx,cy) 쌍. 비어 있으면 center_offset 식을 쓴다. + // mmdet `AnchorGenerator(centers=[(8,8),…])` 가 주면 그 값이 정본이다. + std::vector centers; + // `clip_border` — false 면 디코드한 proposal 을 이미지로 자르지 않는다 + // (crowddet 은 false 라 800 짜리 입력에서 1054 같은 좌표가 나온다). + bool clip_border = true; + // `min_bbox_size` — NMS **앞에서** 너무 작은 상자를 버린다(`w > s && h > s`, 강부등호). + float min_bbox_size = 0.0f; + // `cls_out_channels` — 앵커당 objectness 채널 수. 1 이면 sigmoid, 2 면 softmax 다. + // ⚠️ RPNHead 의 기본은 `use_sigmoid=True`(1채널)지만 **loss_cls 에 use_sigmoid 를 + // 안 적은 config 는 2채널**이 된다(crowddet). 2채널을 1채널로 읽으면 앵커 절반의 + // 배경 로짓을 다른 앵커의 전경 점수로 오해한다 — shape 는 맞고 값만 뒤죽박죽이 된다. + // 전경은 **index 0** 이다(mmdet v2.0 이후 FG label = 0, BG = 1). + int cls_out_channels = 1; }; // rpn_cls[l] = [num_base*1, W, H] objectness(CWHN flat), rpn_bbox[l] = [num_base*4, W, H]. // 점수까지 필요한 쪽(단독 RPN 계열 검증)이 쓴다. `label` 은 클래스가 아니라 **레벨 번호**다 diff --git a/tools/frontend/mmdet/frcnn_wrap.py b/tools/frontend/mmdet/frcnn_wrap.py index 23e9baf..bee83a1 100755 --- a/tools/frontend/mmdet/frcnn_wrap.py +++ b/tools/frontend/mmdet/frcnn_wrap.py @@ -269,6 +269,17 @@ def frcnn_cfg(det, size=800): mask["mask_iou_in_channels"] = int(mih.in_channels) # 256 (+1 은 런너가 붙인다) mask["mask_iou_num_classes"] = int(mih.num_classes) + # ⚠️ **점수 활성이 softmax 가 아닌 계열이 있다.** SeesawLoss 는 `custom_activation` + # 을 세우고 채널을 `num_classes + 2` 로 쓴다 — 앞 C 개는 클래스, 뒤 2개는 + # 전경/배경 objectness 다. 활성도 두 단계다(각각 softmax → 클래스 점수에 전경 확률을 + # 곱하고 배경 확률을 뒤에 붙인다). 평범한 softmax 로 읽으면 **채널 수부터 틀리고** + # (C+2 를 "C+1 클래스 + 배경" 으로 오해한다) 점수도 통째로 달라진다. + lc = getattr(bh, "loss_cls", None) + if getattr(lc, "custom_activation", False): + # (러너의 tiny_json 은 숫자·배열만 읽는다 → 문자열 대신 플래그로 싣는다) + mask["seesaw_activation"] = 1 + mask["cls_num_classes"] = int(bh.num_classes) + # CrowdDet: proposal 하나가 사람 둘을 낸다고 보고 (cls, box) 쌍을 2벌 낸다. # 디코드도 NMS 도 다르다 — **같은 proposal 에서 나온 상자끼리는 서로 안 누른다**(set-NMS). ni = int(getattr(bh, "num_instance", 1) or 1) @@ -339,6 +350,17 @@ def frcnn_cfg(det, size=800): "rpn_means": [float(v) for v in bc.means], "rpn_stds": [float(v) for v in bc.stds], "rpn_nms_pre": int(rpn_c.nms_pre), "rpn_nms_thr": float(rpn_c.nms.iou_threshold), "rpn_max": int(rpn_c.max_per_img), + # ⚠️ 아래 셋은 **기본값이 아닌 계열이 있다**(crowddet 이 셋 다 다르다). + # 전부 shape 를 안 바꾸므로 빼먹으면 크래시 없이 proposal 만 달라진다. + # `centers` — 주어지면 그 값이 base anchor 중심이고 center_offset 은 무시된다. + "rpn_centers": [float(v) for c in (getattr(pg, "centers", None) or []) for v in c], + # `clip_border` — false 면 proposal 을 이미지로 안 자른다(800 입력에 1054 가 나온다). + "rpn_clip_border": 1.0 if getattr(bc, "clip_border", True) else 0.0, + # `min_bbox_size` — NMS **앞에서** 작은 상자를 버린다. + "rpn_min_bbox_size": float(getattr(rpn_c, "min_bbox_size", 0) or 0), + # `cls_out_channels` — RPNHead 는 보통 1(sigmoid)이지만 loss_cls 에 + # use_sigmoid 를 안 적으면 2(softmax, 전경 index 0)가 된다. **모듈에서 읽는다.** + "rpn_cls_out_channels": int(getattr(rh, "cls_out_channels", 1) or 1), # RoIAlign # ⚠️ **RoI feature 채널을 256 으로 박으면 안 된다.** FPN 계열은 256 이지만 # C4 계열(TridentNet)은 neck 이 없어 백본 C4 채널(1024)이 그대로 온다. diff --git a/tools/verify/backbone/run_frcnn.cpp b/tools/verify/backbone/run_frcnn.cpp index a5b0e72..1ef3db8 100644 --- a/tools/verify/backbone/run_frcnn.cpp +++ b/tools/verify/backbone/run_frcnn.cpp @@ -215,6 +215,10 @@ int main(int argc, char** argv) { rp.nms_pre = (int)J.num("rpn_nms_pre", 1000); rp.nms_thr = J.num("rpn_nms_thr", 0.7f); rp.max_per_img = (int)J.num("rpn_max", 1000); + rp.centers = J.arr("rpn_centers"); // 비어 있으면 재현식 + rp.clip_border = J.num("rpn_clip_border", 1.0f) != 0.0f; + rp.min_bbox_size = J.num("rpn_min_bbox_size", 0.0f); + rp.cls_out_channels = (int)J.num("rpn_cls_out_channels", 1.0f); rp.input_w = rp.input_h = SZ; std::vector props = rpn_proposals(rpn_cls, rpn_box, rpn_hw, rp); const int M = (int)(props.size() / 4); @@ -531,12 +535,36 @@ int main(int argc, char** argv) { } } else if (!cls_st.empty() && !box_st.empty()) { const int last = (int)cls_st.size() - 1; - const int NCLS = (int)(cls_st[last].size() / M) - 1; // 배경 제외 + // ⚠️ **채널 수를 출력 크기에서 -1 로 유추하면 안 되는 계열이 있다.** SeesawLoss 는 + // `num_classes + 2` 를 쓴다(앞 C 개 = 클래스, 뒤 2개 = 전경/배경 objectness). + // -1 로 읽으면 클래스가 하나 많아지고 라벨이 통째로 한 칸 밀린다. + const bool seesaw = J.num("seesaw_activation", 0.0f) != 0.0f; + const int NCLS = seesaw ? (int)J.num("cls_num_classes", 0.0f) + : (int)(cls_st[last].size() / M) - 1; // 캐스케이드 단계들만 평균한다. 다중 인스턴스(CrowdDet)는 단계가 아니라 **쌍**이라 // 평균 대상이 아니다 — 위에서 (NS>1 && NPAIR>1) 을 막아 뒀으므로 여기서는 // `NS > 1` 일 때만 여러 원소가 단계를 뜻한다. const int NAVG = (NS > 1) ? (int)cls_st.size() : 1; - std::vector prob(cls_st[last].size()); + std::vector prob((size_t)M * (NCLS + 1)); + if (seesaw) { + // mmdet `SeesawLoss.get_activation`: 클래스와 objectness 를 따로 softmax 하고 + // 클래스 점수에 전경 확률을 곱한 뒤 배경 확률을 뒤에 붙인다. + const int RAW = NCLS + 2; + for (int i = 0; i < M; ++i) { + float const* src = cls_st[last].data() + (size_t)i * RAW; + float* dst = prob.data() + (size_t)i * (NCLS + 1); + float mx = src[0]; + for (int c = 1; c < NCLS; ++c) mx = std::max(mx, src[c]); + float sum = 0.0f; + for (int c = 0; c < NCLS; ++c) { dst[c] = std::exp(src[c] - mx); sum += dst[c]; } + for (int c = 0; c < NCLS; ++c) dst[c] /= sum; + const float om = std::max(src[NCLS], src[NCLS + 1]); + const float ep = std::exp(src[NCLS] - om), en = std::exp(src[NCLS + 1] - om); + const float pos = ep / (ep + en), neg = en / (ep + en); + for (int c = 0; c < NCLS; ++c) dst[c] *= pos; + dst[NCLS] = neg; // 배경은 맨 뒤 + } + } else for (int i = 0; i < M; ++i) { float* dst = prob.data() + (size_t)i * (NCLS + 1); // ① 단계별 로짓 평균 From e567af393d3357d81b9a5137be96fd191a402782 Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 15:17:59 +0900 Subject: [PATCH 62/89] =?UTF-8?q?fix(verify):=20unwrap=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EA=B0=80=20=ED=98=95=EC=A0=9C=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=EB=A5=BC=20=EA=B0=80=EB=A6=AC=EC=BC=9C=20=EC=A1=B0?= =?UTF-8?q?=EC=9A=A9=ED=9E=88=20=EA=BA=BC=EC=A0=B8=20=EC=9E=88=EC=97=88?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `../../../../../GTX_Compiler/...` 는 이 트리에서 /tmp/GTX_Compiler 로 풀린다. 하네스는 `os.path.exists` 로만 보고 없으면 넘어가므로 **꺼진 줄 모르고** 돌았다 — 트래커·반지도 래퍼 config(ByteTrack 등)가 그만큼 INIT_FAIL 로 빠졌다. 도구를 상위 저장소(test_script/mmdet/)로 옮겼으니 저장소 내부를 가리킨다. --- tools/verify/dense_head/verify.toml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tools/verify/dense_head/verify.toml b/tools/verify/dense_head/verify.toml index 67a313e..675cb9f 100644 --- a/tools/verify/dense_head/verify.toml +++ b/tools/verify/dense_head/verify.toml @@ -11,9 +11,13 @@ mmdet = "~/mmbuild/mmdetection" # g2c(컴파일러) 루트. 기본값은 vision.cpp 의 부모 = 이 브랜치의 g2c. g2c = "../../../.." -# 트래커·반지도 래퍼 config 를 안쪽 검출기로 푸는 전처리기(형제 저장소). +# 트래커·반지도 래퍼 config 를 안쪽 검출기로 푸는 전처리기. # 없으면 그 계열만 INIT_FAIL 로 남는다 — 없다고 전체가 멈추지는 않는다. -unwrap = "../../../../../GTX_Compiler/test_script/mmdet/mmdet_unwrap_config.py" +# ⚠️ 예전에는 **형제 저장소**(`../../../../../GTX_Compiler/...`)를 가리켰는데, 이 트리에서는 +# `/tmp/GTX_Compiler` 로 풀려 **조용히 꺼진 채** 돌았다(존재 검사만 하고 없으면 넘어간다). +# 도구를 상위 저장소로 옮겼으니 그쪽을 가리킨다 — vision.cpp 는 서브모듈이라 +# `../../../../` 가 g2c 루트다(`g2c` 항목과 같은 기준). +unwrap = "../../../../test_script/mmdet/mmdet_unwrap_config.py" # 중간 산출물(.pt · 생성 .cpp · gguf · 덤프). 지워도 된다. workdir = "/tmp/visp-verify-heads" # head 지원 여부 조사(head_support_map.py) 전용 작업 폴더. From c01855952480d1ddcb6ac9dc1eae339fff3af272 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 18 Aug 2026 15:18:24 +0900 Subject: [PATCH 63/89] =?UTF-8?q?docs:=20two-stage=2027=EA=B3=84=EC=97=B4?= =?UTF-8?q?=20=E2=80=94=20crowddet=C2=B7ms=5Frcnn=C2=B7seesaw=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20panoptic=5Ffpn=20=EC=9D=80=20=EB=AF=B8=ED=99=95?= =?UTF-8?q?=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EB=82=B4=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전수 회귀(40계열, 새 workdir): PASS 26/40 at 800, + fpg(1024) = 27. 기존 통과 계열의 수치는 전부 그대로다 — 오늘 바꾼 공유 코드(RPN 파라미터 5개 · conv_transpose 3겹 · 하네스 stale 제거)에 회귀가 없다. - **추가 3**: crowddet 0.07px · ms_rcnn 0.07px · seesaw_loss 0.09px. - **내림 1**: panoptic_fpn. `panopticapi` 미설치로 이 환경에서 **재측정이 안 된다**. 기록돼 있던 0.04px 는 export 가 실패한 실행에서 나왔을 가능성이 크다 — 하네스가 `frcnn.json` 존재만 검사해서 지난 실행의 파일이 통과를 냈다. 지금은 단계마다 지운다. 틀렸다고 단정하지 않되 **검증됨으로 세지 않는다**. - **grid_rcnn 을 별도 항목으로** 세웠다. "디코더가 없다" 와 "연산이 없다" 를 같은 줄에 쓰지 않는다 — 디코드는 다 짰고 grouped conv_transpose 하나가 막고 있다. - 실패 분석 산문에서 '계열 전용 후처리' 항목이 비었다. 셋 다 **텐서에는 안 보이던** 차이였다는 점을 남긴다 — crowddet 은 RPN 텐서가 처음부터 3e-04 로 맞았고, 그래서 호스트 코드로 범위를 좁힐 수 있었다. --- docs/mmdet-detectors.md | 53 ++++++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 189b9e2..2e7449e 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -348,41 +348,51 @@ file, so running from anywhere else fails to find it and the family looks broken Two-stage families are measured separately, at 800 and against the detector's own `predict` rather than a head's `predict_by_feat`, because the boxes do not exist until RPN proposals, RoIAlign and the RoI head have run. The harness is `tools/verify/roi/verify_postproc_roi.py` -and the thresholds are the same. Twenty-five of the forty families with a `roi_head` agree: +and the thresholds are the same. Twenty-seven of the forty families with a `roi_head` agree: | Family | Decoder | Worst box | Worst score | | :--- | :--- | ---: | ---: | -| `panoptic_fpn` | `detect_roi` | 0.04 px | 0.0001 | +| `detectors` | `detect_roi` (SAC) | 0.03 px | 0.0006 | | `dcnv2` | `detect_roi` | 0.05 px | 0.0006 | | `carafe` | `detect_roi` | 0.06 px | 0.0007 | | `hrnet` | `detect_roi` | 0.06 px | 0.0002 | -| `gcnet` | `detect_roi` | 0.14 px | 0.0010 | -| `htc` | `detect_roi` (3 stages + semantic) | 0.12 px | 0.0011 | -| `detectors` | `detect_roi` (SAC) | 0.03 px | 0.0006 | -| `scnet` | `detect_roi` (+ global context) | 0.18 px | 0.0020 | -| `swin` | `detect_roi` | 0.22 px | 0.0003 | | `mask_rcnn` | `detect_roi` | 0.06 px | 0.0009 | +| `crowddet` | `detect_roi` (set-NMS, 2 instances) | 0.07 px | 0.0001 | +| `ms_rcnn` | `detect_roi` (+ mask-IoU rescoring) | 0.07 px | 0.0015 | | `gn+ws` | `detect_roi` | 0.08 px | 0.0008 | | `cascade_rcnn` | `detect_roi` (3 stages) | 0.09 px | 0.0025 | | `gn` | `detect_roi` | 0.09 px | 0.0003 | | `empirical_attention` | `detect_roi` | 0.09 px | 0.0003 | +| `seesaw_loss` | `detect_roi` (NormedLinear, custom activation) | 0.09 px | 0.0002 | | `faster_rcnn` | `detect_roi` | 0.10 px | 0.0008 | | `libra_rcnn` | `detect_roi` | 0.11 px | 0.0007 | +| `htc` | `detect_roi` (3 stages + semantic) | 0.12 px | 0.0011 | | `resnest` | `detect_roi` | 0.12 px | 0.0002 | +| `gcnet` | `detect_roi` | 0.14 px | 0.0010 | | `albu_example` | `detect_roi` | 0.14 px | 0.0008 | | `point_rend` | `detect_roi` | 0.15 px | 0.0012 | | `regnet` | `detect_roi` | 0.16 px | 0.0012 | +| `scnet` | `detect_roi` (+ global context) | 0.18 px | 0.0020 | | `simple_copy_paste` | `detect_roi` | 0.19 px | 0.0007 | +| `swin` | `detect_roi` | 0.22 px | 0.0003 | | `resnet_strikes_back` | `detect_roi` | 0.24 px | 0.0023 | | `fpg` | `detect_roi` (at 1024) | 0.28 px | 0.0036 | | `dcn` | `detect_roi` | 0.37 px | 0.0002 | | `instaboost` | `detect_roi` | 0.39 px | 0.0079 | +`panoptic_fpn` is not in the table any more, and the reason is worth stating plainly: it +cannot be measured in this environment, because `panopticapi` is not installed and +`init_detector` builds the dataset pipeline before it builds the model. It was previously +recorded at 0.04 px. That number came from a run whose export step had already failed — the +harness checked only that `frcnn.json` existed, so a file left by an earlier run carried it +through. The harness now deletes each stage's outputs before that stage runs, which is what +made the failure visible. Treat the old number as unverified rather than wrong. + `fpg` is measured at 1024 rather than 800, and the reason is worth stating: it builds levels below P5, where 800 stops dividing evenly — a 25-wide map meets a 26-wide one and the export aborts. That is a property of the resolution, not of the family. -The fifteen that do not agree split five ways, and the split matters more than the count: +The thirteen that do not agree split five ways, and the split matters more than the count: - **The RPN is not a standard anchor RPN**, so host `rpn_proposals` cannot lay down the priors: `cascade_rpn` refines across stages, `guided_anchoring` predicts anchor shapes, and @@ -390,6 +400,15 @@ The fifteen that do not agree split five ways, and the split matters more than t reason — `GenericRoIExtractor` aggregates every level through per-level convolutions, which host RoIAlign cannot express. All five stop at export with a stated reason rather than a wrong number. +- **The graph needs an operator ggml does not have.** `grid_rcnn` regresses boxes in a grid + head whose two transposed convolutions are grouped (`groups=9`) and padded (`padding=1`); + `ggml_conv_transpose_2d_p0` is neither. Everything else for that family is written — the + bbox head has no regression branch at all (`with_reg=False`, so the RoIs are used as boxes + the way mmdet does when `bbox_pred is None`), and the grid decode reads nine heatmap peaks, + maps them into the expanded box and averages each side by score. It stops at export with + the operator named, because "the decoder is missing" and "the operator is missing" are + different problems and should not share a row. Padding alone is now supported (the helper + crops the p0 output); groups is what remains. - **FP16 weights, not a defect.** `dynamic_rcnn` (10.24 px), `pafpn` (8.77 px) and `res2net` (6.81 px) return the right count and the right labels with the coordinates several pixels out. Recompiling with fp32 weights makes all three exact at **0.00 px**, so the gap is the @@ -405,12 +424,18 @@ The fifteen that do not agree split five ways, and the split matters more than t 0.30–0.35 band beside every count mismatch. `fast_rcnn` is not measured at all — its metafile lists no weights, and random initialisation cannot judge a decoder, so it is recorded as untried rather than failing. -- **The family post-processes its own way**: `crowddet` predicts two instances per proposal and - needs set-NMS (without it nothing is suppressed — 500 boxes against 1), `ms_rcnn` rescales - scores by a predicted mask IoU (its boxes are exact at 0.07 px; only the scores differ), - `grid_rcnn` turns off box regression on the bbox head entirely and regresses in a grid head, - and `seesaw_loss` classifies through a `NormedLinear` layer at temperature 20 over 1203 LVIS - classes. +- **The family post-processed its own way.** This group is now empty, and each of the three + is worth keeping because none of them was visible in the tensors. `ms_rcnn` multiplies every + score by a predicted mask IoU, which needs the whole mask branch — RoIAlign at 14, the mask + head, then a second head over the features concatenated with the chosen mask channel. + `seesaw_loss` is two things at once: a `NormedLinear` classifier (the weight normalisation is + constant at inference and folds away; the input normalisation stays) and a custom activation + over `num_classes + 2` channels, so reading the class count as "output size minus one" shifts + every label by one. `crowddet` needed set-NMS — boxes from the same proposal do not suppress + each other — but three of its four differences were RPN settings that are not the defaults: + fixed anchor `centers`, `clip_border=False`, and an objectness head with two channels rather + than one because its `loss_cls` omits `use_sigmoid`. Its RPN tensors matched at 3e-04 from + the start, which is what said the problem was in the host code and not in the graph. - **An operator was missing, approximated, or silently reduced along the wrong axis.** This group is now empty, and how each was found is worth keeping. `carafe` rendered `pixel_shuffle` as a pass-through identity, skipping CARAFE's upsampling outright From 6c4adfed4e52ceef3d686fc3a1cea4fa198a943d Mon Sep 17 00:00:00 2001 From: eunchae Date: Tue, 18 Aug 2026 15:20:39 +0900 Subject: [PATCH 64/89] =?UTF-8?q?docs:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EA=B0=80=EC=9D=B4=EB=93=9C=204=EC=A2=85=20=ED=9D=A1=EC=88=98?= =?UTF-8?q?=20(staging=20=EC=97=90=20=EC=97=86=EB=8D=98=20=EA=B2=83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getting-started · overview · using-the-cli · using-the-library 는 docs/library-guides 브랜치(2026-08-07)에만 있었다. staging 의 docs 는 mmdet-detectors.md · model-implementation-guide.md 둘뿐이었다. README 는 통째로 가져오지 않았다 — 그 브랜치의 README 는 mmdet 항목이 생기기 전 것이라 표에서 MMDetection 줄을 지운다. 링크 줄만 더했다. 브랜치 정리(2026-08-18) 전 흡수. 원본은 archive/docs-library-guides-20260818. --- README.md | 4 + docs/getting-started.md | 101 ++++++++++++++++++++++++ docs/overview.md | 111 ++++++++++++++++++++++++++ docs/using-the-cli.md | 160 ++++++++++++++++++++++++++++++++++++++ docs/using-the-library.md | 125 +++++++++++++++++++++++++++++ 5 files changed, 501 insertions(+) create mode 100644 docs/getting-started.md create mode 100644 docs/overview.md create mode 100644 docs/using-the-cli.md create mode 100644 docs/using-the-library.md diff --git a/README.md b/README.md index a4df78c..19cd8d5 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ Computer Vision ML inference in C++ Based on [ggml](https://github.com/ggml-org/ggml) similar to the [llama.cpp](https://github.com/ggml-org/llama.cpp) project. +New here? [**Getting started**](docs/getting-started.md) walks through a first run in five minutes. + +**Docs:** [Getting started](docs/getting-started.md) · [Overview](docs/overview.md) · [Command line](docs/using-the-cli.md) · [Library API](docs/using-the-library.md) · [Implementing a model](docs/model-implementation-guide.md) · [MMDetection models](docs/mmdet-detectors.md) + ### Features | Model | Task | Backends | diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..ddcc474 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,101 @@ +# Getting Started + +In this tutorial you will cut an object out of a photo using vision.cpp. It takes about five +minutes and needs nothing but the release package, one model file and one image — no build, no +Python, no conversion. + +At the end you will have this: + +| | | +| :--- | :--- | +| `mask.png` | a black-and-white mask of the object | +| `object.png` | the original photo with the background dimmed away | + +## Step 1 — Get the executable + +Download a [release package](https://github.com/Acly/vision.cpp/releases) and extract it. You +will find `vision-cli` in the `bin` folder. + +Check that it runs: + +```sh +vision-cli --help +``` + +You should see a list of commands: `sam`, `birefnet`, `depthany`, `migan`, `esrgan`. + +> If you would rather build from source, follow [Building](../README.md#building) first, then +> come back here. `vision-cli` ends up in `build/bin`. + +## Step 2 — Get a model and an image + +The executable contains the network structure, but not the weights. Download them: + +```sh +curl -L -O https://huggingface.co/Acly/BiRefNet-GGUF/resolve/main/BiRefNet-lite-F16.gguf +``` + +This is BiRefNet, a model that separates a subject from its background. The file is a +[GGUF](https://github.com/ggml-org/ggml/blob/master/docs/gguf.md) — the weights and nothing else. + +For the input, use any photo with a clear subject. If you cloned the repository, there is one +at `docs/media/input.jpg`. Put it next to the model file and call it `input.jpg`. + +## Step 3 — Run it + +```sh +vision-cli birefnet -m BiRefNet-lite-F16.gguf -i input.jpg -o mask.png --composite object.png +``` + +The output tells you what it is doing: + +``` +Initializing backend... done (1.1 ms) +- device: CPU - Intel(R) Core(TM) i3-14100 +Loading model weights from 'BiRefNet-lite-F16.gguf'... done (151.3 ms) +- float type: f16 +- tensor layout: cwhn +- model image size: 1024 +- inference image size: 1024x1024 +- flash attention: off +Running inference... complete (5372.6 ms) +-> mask saved to mask.png +-> image composited and saved to object.png +``` + +Inference takes a few seconds on a desktop CPU. Loading the weights takes a fraction of a +second — that number is the point of the project, and it is the same on any machine. + +## Step 4 — Look at the result + +Open `object.png`. The subject is untouched and the background has faded away. + +`mask.png` is what the model actually produced: white where the subject is, black elsewhere. +Everything in `object.png` was computed from it. + +That is the whole loop. An executable that already knows the network, a `.gguf` that carries +the weights, an image in, a result out. + +## Try one more + +The same executable runs the other built-in models. Only the command and the weights change: + +```sh +curl -L -O https://huggingface.co/Acly/Real-ESRGAN-GGUF/resolve/main/RealESRGAN-x4plus_anime-6B-F16.gguf + +vision-cli esrgan -m RealESRGAN-x4plus_anime-6B-F16.gguf -i input.jpg -o upscaled.png +``` + +This one upscales the image four times. It works on tiles and takes noticeably longer — you will +see it count them off. + +## Where to go next + +- [Overview](overview.md) — what the library is and why weights and structure are separate. +- [Using the command line](using-the-cli.md) — every option, every built-in model. +- [Using the library](using-the-library.md) — the same models from your own code. +- [README](../README.md#features) — the other built-in models, and what each one does. +- [Model implementation guide](model-implementation-guide.md) — when the model you want is not + in the list, and you want to add it. +- [MMDetection detectors](mmdet-detectors.md) — running detectors whose structure is generated + rather than hand-written. diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000..5b50afd --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,111 @@ +# Overview + +vision.cpp is a C++ library for running computer-vision neural networks. It loads weights +from a GGUF file, builds a compute graph with [ggml](https://github.com/ggml-org/ggml), and +executes it on CPU or GPU. The result is a single native binary with no Python, no framework +runtime, and no interchange-format interpreter. + +It is the same idea as [llama.cpp](https://github.com/ggml-org/llama.cpp), applied to vision +models instead of language models. + +## The idea: a model is code, not a file + +Most inference stacks treat a model as data. You export a graph to ONNX or TorchScript, and +a general-purpose runtime loads that graph, matches its operators against a kernel library, and +interprets it. The runtime has to support every operator anyone might export, so it is large, +and it has to plan the graph at startup, so loading takes time. + +vision.cpp splits the model in two: + +| Part | Form | Where it lives | +| :--- | :--- | :--- | +| Structure | C++ that builds a ggml graph | compiled into your binary | +| Weights | GGUF tensors | a `.gguf` file loaded at run time | + +Nothing interprets a graph description at run time, because there is no graph description — the +graph is the code you compiled. That is what makes the deployment small and start-up fast, and +it is the trade-off at the centre of the project: adding a model that isn't supported yet +means writing or generating code, not exporting a file. + +Weights stay external, so swapping checkpoints, changing precision, or quantising does not +require rebuilding. + +## What you get + +- Self-contained. The only dependencies are ggml, `stb` for image I/O, and optionally + `fmt`. There is no Python in the runtime path. +- CPU and GPU. CPU works everywhere; Vulkan covers NVIDIA, AMD and Intel from one build. +- Small and quick to start. Deployment size and model-load time are explicit goals of the + project — see the [Performance](../README.md#performance) section for the current numbers. +- Modular. The same primitives the built-in models are made of are public, so you can + assemble your own. + +## How it fits together + +The library is layered. Each layer is usable on its own; you can stop at whichever one matches +how much control you need. + +| Layer | Header | What it gives you | +| :--- | :--- | :--- | +| Model APIs | `visp/vision.h` | Ready-made models — load, run, get a result. | +| Image I/O | `visp/image.h` | Load, save, resize, tile, convert. | +| Neural network layers | `visp/nn.h` | `conv_2d`, `group_norm`, attention, and other building blocks. | +| Graph and backends | `visp/ml.h` | GGUF loading, weight transfer, graph construction, execution. | +| Detection post-processing | `visp/postproc.h` | Anchors, decoding, NMS, RoIAlign, masks. | +| Tracking | `visp/tracker.h` | ByteTrack association across frames. | + +Two front-ends are built on top: + +- `vision-cli` — a command-line tool for the built-in models + (`vision-cli sam -m MobileSAM-F16.gguf -i image.jpg -p 100 200 -o mask.png`). +- Python bindings — `bindings/python`, for scripting and comparison against reference + implementations. + +## Running a model + +Most of the time there is nothing to add. The models in the +[README](../README.md#features) are already implemented, so running one means downloading its +weights and pointing at them: + +```sh +vision-cli birefnet -m BiRefNet-lite-F16.gguf -i photo.jpg -o mask.png +``` + +Or from your own program, in three calls — pick a device, load the weights, compute. See +[using the command line](using-the-cli.md) and [using the library](using-the-library.md). + +If you have your own checkpoint for one of those architectures, convert it with +`scripts/convert.py`. The structure is already in the library; only the weights change. + +## Adding a model + +When the architecture is not implemented yet, it has to be written. The +[model implementation guide](model-implementation-guide.md) walks through it: describe the +network with the `nn.h` primitives, and provide a conversion function that turns the original +checkpoint into GGUF. Every built-in model was added this way, and it gives the most control +over layout and precision. + +The result is what the library loads: a `_forward` function that builds a graph, plus a +GGUF file of weights. + +For model families with hundreds of variants, hand-writing each one is not realistic and the +C++ can be generated from a traced PyTorch module instead. The +[MMDetection guide](mmdet-detectors.md) covers that case — the interface generated code must +satisfy, and how to handle what tracing cannot capture. + +## Scope + +vision.cpp is an inference library. There is no training, no autograd, and no optimizer. + +It is also not a general model runtime: it does not aim to execute arbitrary exported graphs. +Supported models are the ones that have been implemented or generated, which is why the model +list in the [README](../README.md#features) is finite and why growing it is a code change. + +## Next + +- [Getting started](getting-started.md) — run a model end to end in five minutes. +- [Using the command line](using-the-cli.md) — every built-in model, no code. +- [Using the library](using-the-library.md) — the same models from C++ or Python. +- [README](../README.md) — install, build, supported models, performance. +- [Model implementation guide](model-implementation-guide.md) — write a model by hand. +- [MMDetection detectors](mmdet-detectors.md) — run detectors from a compiled backbone. diff --git a/docs/using-the-cli.md b/docs/using-the-cli.md new file mode 100644 index 0000000..1b7fa8d --- /dev/null +++ b/docs/using-the-cli.md @@ -0,0 +1,160 @@ +# Using the command line + +`vision-cli` runs every built-in model without writing any code. All you need is the executable +and a `.gguf` weights file. + +If you have not run anything yet, start with [Getting started](getting-started.md). + +## The shape of a command + +```sh +vision-cli -m -i -o +``` + +The command selects the model, `-m` says which weights to load, `-i` and `-o` are files. + +| Command | Task | Input | Output | +| :--- | :--- | :--- | :--- | +| `birefnet` | Background removal | image | mask | +| `sam` | Segment one object you point at | image + prompt | mask | +| `depthany` | Depth estimation | image | depth map | +| `migan` | Inpainting — fill a region | image + mask | image | +| `esrgan` | Upscaling | image | larger image | + +## Options + +`-m, --model ` +: The `.gguf` weights. Required. + +`-i, --input [ ...]` +: Input image. `migan` takes two — the image and the mask. + +`-o, --output ` +: Output file. Defaults to `output.png`. + +`-p, --prompt [ ...]` +: Prompt for models that take one. `sam` accepts a point (`x y`) or a box + (`x1 y1 x2 y2`) in pixels, origin top-left. + +`-b, --backend ` +: Which device to run on. Defaults to automatic — GPU if the build has Vulkan and a device is + available, CPU otherwise. + +`--composite ` +: Also write the input image combined with the resulting mask, instead of the mask alone. + +`--tile ` +: Split large inputs into tiles of this size. Used by `esrgan` to keep memory bounded. + +`-h, --help` +: Print the command list and exit. + +## Getting weights + +Each model has its own GGUF repository. Download the file and pass it with `-m`. + +| Model | Weights | +| :--- | :--- | +| MobileSAM | [Acly/MobileSAM-GGUF](https://huggingface.co/Acly/MobileSAM-GGUF) | +| BiRefNet | [Acly/BiRefNet-GGUF](https://huggingface.co/Acly/BiRefNet-GGUF) | +| Depth-Anything V2 | [Acly/Depth-Anything-V2-GGUF](https://huggingface.co/Acly/Depth-Anything-V2-GGUF) | +| MI-GAN | [Acly/MIGAN-GGUF](https://huggingface.co/Acly/MIGAN-GGUF) | +| Real-ESRGAN | [Acly/Real-ESRGAN-GGUF](https://huggingface.co/Acly/Real-ESRGAN-GGUF) | + +Several variants are usually available per model — different sizes or resolutions. The +executable reads which one it got from the file's metadata, so no extra flag is needed. + +## Remove a background + +```sh +vision-cli birefnet -m BiRefNet-lite-F16.gguf -i photo.jpg -o mask.png --composite cutout.png +``` + +`mask.png` is white where the subject is. `cutout.png` is the photo with the background removed. + +## Segment one object + +Unlike background removal, this needs to be told which object. Give a point inside it: + +```sh +vision-cli sam -m MobileSAM-F16.gguf -i photo.jpg -p 300 200 -o mask.png +``` + +or a box around it: + +```sh +vision-cli sam -m MobileSAM-F16.gguf -i photo.jpg -p 420 120 650 430 -o mask.png +``` + +A box is usually more reliable when the object touches others. + +## Estimate depth + +```sh +vision-cli depthany -m Depth-Anything-V2-Small-F16.gguf -i photo.jpg -o depth.png +``` + +The output is a single-channel image — bright is near, dark is far. Values are relative to the +image, not metric distances. + +## Fill a region + +Inpainting takes two inputs: the image, and a mask marking what to replace. + +```sh +vision-cli migan -m MIGAN-512-places2-F16.gguf -i photo.jpg mask.png -o filled.png +``` + +White in the mask is the region to fill. You can produce that mask with `birefnet` or `sam`, +which makes removing an object a two-step operation. + +## Upscale + +```sh +vision-cli esrgan -m RealESRGAN-x4plus_anime-6B-F16.gguf -i photo.jpg -o large.png +``` + +The scale factor comes from the weights — the model above is 4×. Large inputs are processed in +tiles; you will see them counted off, and the whole run takes considerably longer than the other +models. + +## Choosing a device + +```sh +vision-cli birefnet -m BiRefNet-lite-F16.gguf -i photo.jpg -o mask.png -b gpu +``` + +GPU requires a build with Vulkan enabled — see [Building](../README.md#building). Without it, +`-b gpu` has nothing to select and the run stays on CPU. The first two lines of output always +name the device that was actually used. + +## Using your own weights + +If you have a checkpoint for an architecture the library already implements, convert it to GGUF +rather than looking for a pre-made file. + +```sh +uv run scripts/convert.py MyModel.pth +``` + +`` is one of `sam`, `sam3`, `birefnet`, `depth-anything`, `migan`, `esrgan`. +The result lands in `models/`. + +| Option | Description | +| :--- | :--- | +| `-o, --output` | Output directory or file. Default `models`. | +| `-q, --quantize f16` | Store float weights as f16 — roughly half the file size. | +| `-l, --layout whcn\|cwhn` | Tensor layout for 2D operations. Leave unset unless you know you need the other one. | +| `--model-name` | Name recorded in the file's metadata. | +| `-v, --verbose` | Print every tensor as it is converted. | + +Conversion also rearranges and precomputes tensors, so it is not a pure format change — this is +why a checkpoint cannot be loaded directly. + +This route only covers architectures that exist in the library. For anything else, see the +[model implementation guide](model-implementation-guide.md). + +## Next + +- [Using the library](using-the-library.md) — the same models from your own C++ or Python code. +- [Overview](overview.md) — why weights and structure are separate files. diff --git a/docs/using-the-library.md b/docs/using-the-library.md new file mode 100644 index 0000000..422c413 --- /dev/null +++ b/docs/using-the-library.md @@ -0,0 +1,125 @@ +# Using the library + +Everything `vision-cli` does is a few calls into `libvisioncpp`. This page shows the shape of +those calls so you can put the models inside your own program. + +## The pattern + +Every built-in model follows the same three steps: pick a device, load the weights, compute. + +```c++ +#include +using namespace visp; + +int main() { + backend_device dev = backend_init(); // 1. device + birefnet_model model = birefnet_load_model("BiRefNet-lite-F16.gguf", dev); // 2. weights + + image_data input = image_load("photo.jpg"); + image_data mask = birefnet_compute(model, input); // 3. compute + + image_save(mask, "mask.png"); +} +``` + +The structure of the network is already in the library — that is why loading takes only the +weights file. Nothing is parsed or planned at start-up beyond reading the tensors. + +## Devices + +```c++ +backend_device dev = backend_init(); // best available +backend_device cpu = backend_init(backend_type::cpu); +backend_device gpu = backend_init(backend_type::gpu); +``` + +`backend_init()` with no argument picks the GPU when the build has Vulkan and a device is +present, and falls back to CPU. One device is used for the whole model; pass it to the load +function and it decides where weights and computation live. + +## The models + +Each model has a `_load_model` and a `_compute`. The differences are only in what goes in and +what comes out. + +| Model | Load | Compute | +| :--- | :--- | :--- | +| BiRefNet | `birefnet_load_model(path, dev)` | `birefnet_compute(m, image)` → alpha mask | +| Depth-Anything | `depthany_load_model(path, dev)` | `depthany_compute(m, image)` → depth, f32 in [0, 1] | +| MI-GAN | `migan_load_model(path, dev)` | `migan_compute(m, image, mask)` → filled image | +| ESRGAN | `esrgan_load_model(path, dev)` | `esrgan_compute(m, image)` → upscaled image | +| MobileSAM | `sam_load_model(path, dev)` | see below — two calls | + +SAM is split because the expensive part does not depend on the prompt. Encode the image once, +then ask for as many objects as you like: + +```c++ +sam_model sam = sam_load_model("MobileSAM-F16.gguf", dev); + +sam_encode(sam, image); // once per image + +image_data a = sam_compute(sam, i32x2{300, 200}); // by point +image_data b = sam_compute(sam, box_2d{{420, 120}, {650, 430}}); // by box +``` + +Prompt coordinates are pixels with the origin in the top-left corner. + +## Images + +`image_data` owns its pixels; `image_view` refers to pixels someone else owns. Functions take +views, so you can pass data you already have without copying it. + +```c++ +image_data img = image_load("photo.jpg"); // from disk +image_save(img, "out.png"); // to disk + +image_view v{extent, image_format::rgba_u8, my_buffer}; // wrap your own memory +``` + +That last form is the one to reach for when frames come from a camera, a decoder, or another +part of your application — nothing needs to go through a file. + +## Going lower + +The one-call functions above are compositions. Each model also exposes the steps separately: +parameter detection, pre-processing, graph construction, post-processing. + +```c++ +birefnet_params p = birefnet_detect_params(file); // read shape/variant from the GGUF +image_data in = birefnet_process_input(image, p); // resize, normalise +tensor out = birefnet_predict(m, input_tensor, p); // build the graph +image_data mask = birefnet_process_output(data, target_extent, p); +``` + +Use these when you need to batch work, keep tensors on the device between stages, run +pre-processing somewhere else, or share a compute graph across calls. `visp/ml.h` has the +pieces underneath — `model_load`, `model_transfer`, `compute_graph_init`, `compute`. + +## Detection post-processing + +If you are building a detector rather than using a built-in model, `visp/postproc.h` has the +parts that are not neural networks: anchor generation, box decoding, NMS, RoIAlign, mask +pasting. `visp/tracker.h` has ByteTrack for keeping identities across frames. Both are plain +CPU code and take structs, not framework config. + +The [MMDetection guide](mmdet-detectors.md) shows them assembled into a working detector. + +## Python + +The bindings cover the same models for scripting and comparison work. + +```python +from visioncpp import Device, Model, Backend + +device = Device.init(Backend.auto) +model = Model.load("BiRefNet-lite-F16.gguf", device) +mask = model.compute(image) +``` + +They live in `bindings/python`. The C++ API is the reference; the bindings follow it. + +## Next + +- [Using the command line](using-the-cli.md) — the same models without writing code. +- [Model implementation guide](model-implementation-guide.md) — adding a model the library does + not have. From d483e29fc55ed17f1e99015a6adbcfe9d4bb0ada Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 18 Aug 2026 15:29:43 +0900 Subject: [PATCH 65/89] =?UTF-8?q?feat(one-stage):=20=EA=B3=84=EC=97=B4=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EB=94=94=EC=BD=94=EB=8D=94=206=EC=A2=85?= =?UTF-8?q?=20=E2=80=94=20fsaf=C2=B7paa=C2=B7lad=C2=B7ssd=C2=B7cornernet(?= =?UTF-8?q?=ED=98=91=EC=97=85)=20+=20centernet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit one-stage 전수 회귀 34계열: **32 PASS**. 기존 26계열 수치는 전부 그대로다. ## 20260512-e7 세션이 만든 것 (onestage-decoders + unary-contig 패치) - `detect_anchor` 확장(시그니처 불변) — softmax head · 레벨별 앵커 · TBLR 코더. fsaf 0.56px. - `detect_paa` 신설 — top-k 가 (앵커,클래스)가 아니라 **앵커 단위**, 점수가 sqrt(cls×iou), NMS 뒤 score voting. 판정은 이름이 아니라 `with_score_voting` **속성** 으로 한다(LAD 는 PAAHead 를 상속해서 이름으로 가르면 빠진다). paa 0.54 · lad 0.74px. - `detect_yolact` 신설 — fast NMS. test_cfg 가 `nms` dict 대신 평평한 `iou_thr`/`top_k` 다. - `min_bbox_size` — NMS **앞** 필터. 없으면 SSD 가 경계 클램프로 면적 0 이 된 유령 상자를 31건 낸다(IoU 0 이라 NMS 도 못 지운다). mmdet 4건 vs C++ 31건 → 4 vs 4. ssd 0.42px. - `level_anchors()` — 재현식이 안 통하는 생성기는 `pg.base_anchors` 를 그대로 싣는다 (YOLACT 는 base_size 와 중심을 stride 와 따로 준다 → 재현식은 0.859배 작고 반 칸 밀린다). - `detect_corner` 신설 — 코너 heatmap 국소최대 → 코너별 top-k → 쌍 → soft-NMS. emb 거리(CornerNet)와 centripetal 판정 둘 다. cornernet 0.03px. - 단항 활성이 view 입력을 받으면 `ggml_is_contiguous_1` 로 죽는 것 — relu·sigmoid 입력에 `ggml_cont`. centernet DCNv2 의 `sigmoid(slice(conv))` 가 그 사례다. - **하네스 구멍**: 빌드 실패해도 지난 바이너리가 남아 PASS 를 냈다. 빌드 전에 지운다. ## 이 세션이 더한 것 - **`detect_centernet` 신설** — 히트맵 국소최대 → 전체 top-k → 같은 자리의 wh/offset 으로 상자. mmdet 은 `with_nms=False` 가 기본이라 NMS 를 안 건다. centernet 0.13px. ⚠️ maxpool 의 패딩은 **-inf** 다. 0 으로 채우면 경계에서 음수 값이 최대가 못 되어 중심점이 사라진다 — 범위 밖을 아예 안 보게 짰다. heat 는 head 안에서 이미 sigmoid 를 거쳤다(또 걸면 안 된다). - 하네스가 `img_meta['border']` 를 채운다. CenterNet 의 `_predict_by_feat_single` 이 그걸 읽는데(RandomCenterCropPad 가 남기는 값) 없으면 **기준값 쪽이** KeyError 로 죽는다. 정사각 리사이즈에는 여백이 0 이라 항등이다 — 계열이 안 되는 게 아니라 못 재던 것이다. ## 판정에 못 미친 둘 (통과로 세지 않는다) - `yolact` 0.74px · **개수차 1** — mmdet 0.3013 vs C++ 0.2951 로 하네스 임계 0.30 경계. free_anchor·double_heads 와 같은 부류다. 박스는 맞는다. - `centripetalnet` 3.80px — 2건 중 1건의 tl 코너가 인접 셀 하나 차이. mmdet 자신의 `_decode_heatmap` 을 우리 텐서에 돌려 **공식은 동치**임을 확인했다(0.4387 vs 0.4393). 남은 것은 heatmap 근소 값차가 top-k 순위를 뒤집는 fp16 타이 플립이다. --- src/visp/postproc.cpp | 55 ++++++++++++++- src/visp/postproc.h | 16 +++++ tools/detect/head.h | 4 ++ tools/frontend/mmdet/mmdet_to_pt.py | 36 +++++++++- tools/frontend/mmdet/mmdet_wrap.py | 41 ++++++++++- tools/verify/backbone/run_mmdet.cpp | 82 +++++++++++++++++++--- tools/verify/dense_head/verify_heads.py | 7 ++ tools/verify/dense_head/verify_postproc.py | 14 +++- 8 files changed, 241 insertions(+), 14 deletions(-) diff --git a/src/visp/postproc.cpp b/src/visp/postproc.cpp index 7b53eff..07c07b7 100755 --- a/src/visp/postproc.cpp +++ b/src/visp/postproc.cpp @@ -1336,4 +1336,57 @@ std::vector detect_corner( return out; } -} // namespace visp + +// ── CenterNet (heatmap 중심점 디코드) ──────────────────────────────────────── +std::vector detect_centernet( + std::vector const& heat, std::vector const& wh, + std::vector const& off, int fh, int fw, centernet_params const& p) { + + const int C = p.num_classes; + const int HW = fh * fw; + const int pad = (p.local_max_kernel - 1) / 2; + // ① 국소 최대만 남긴다(mmcv `get_local_maximum`: maxpool(k, stride 1, pad) == heat). + // ⚠️ **maxpool 의 패딩은 0 이 아니라 -inf 다.** 0 으로 채우면 경계에서 음수 값이 + // 최대가 못 되어 중심점이 통째로 사라진다. 여기서는 범위 밖을 아예 안 본다. + std::vector> cand; // (score, flat idx = c*HW + y*fw + x) + cand.reserve((size_t)HW); + for (int c = 0; c < C; ++c) + for (int y = 0; y < fh; ++y) + for (int x = 0; x < fw; ++x) { + const float v = heat[((size_t)y * fw + x) * C + c]; + bool is_max = true; + for (int dy = -pad; dy <= pad && is_max; ++dy) + for (int dx = -pad; dx <= pad; ++dx) { + const int ny = y + dy, nx = x + dx; + if (ny < 0 || ny >= fh || nx < 0 || nx >= fw) continue; + if (heat[((size_t)ny * fw + nx) * C + c] > v) { is_max = false; break; } + } + if (is_max) cand.emplace_back(v, c * HW + y * fw + x); + } + // ② 전체(클래스 포함)에서 top-k. torch.topk 는 내림차순이고 동점은 인덱스 순이다. + const int k = std::min((int)cand.size(), std::max(1, p.topk)); + std::partial_sort(cand.begin(), cand.begin() + k, cand.end(), + [](auto const& a, auto const& b) { + return a.first != b.first ? a.first > b.first : a.second < b.second; + }); + // ③ 같은 자리의 wh/offset 으로 상자를 만들고 입력 해상도로 늘린다. + const float sx = fw > 0 ? (float)p.input_w / (float)fw : 1.0f; + const float sy = fh > 0 ? (float)p.input_h / (float)fh : 1.0f; + std::vector out; + out.reserve(k); + for (int i = 0; i < k; ++i) { + const int idx = cand[i].second; + const int cls = idx / HW, rem = idx % HW; + const int y = rem / fw, x = rem % fw; + const size_t o2 = ((size_t)y * fw + x) * 2; + const float cx = (float)x + off[o2 + 0], cy = (float)y + off[o2 + 1]; + const float w = wh[o2 + 0], h = wh[o2 + 1]; + out.push_back({(cx - w * 0.5f) * sx, (cy - h * 0.5f) * sy, + (cx + w * 0.5f) * sx, (cy + h * 0.5f) * sy, + cand[i].first, cls}); + } + return out; +} + + +} // namespace visp \ No newline at end of file diff --git a/src/visp/postproc.h b/src/visp/postproc.h index 9a62f69..87fd70b 100755 --- a/src/visp/postproc.h +++ b/src/visp/postproc.h @@ -345,6 +345,22 @@ std::vector rpn_proposals( std::vector> const& rpn_bbox, std::vector> const& feat_hw, rpn_params const& p); +// ── CenterNet (heatmap 중심점 디코드) ──────────────────────────────────────── +// 앵커가 없다. 클래스별 heatmap 의 **국소 최대점**을 중심으로 보고, 같은 자리의 +// wh/offset 으로 상자를 만든다. 단일 레벨(stride 4)이다. +struct centernet_params { + int num_classes = 80; + int topk = 100; // test_cfg.topk + int local_max_kernel = 3; // test_cfg.local_maximum_kernel + int input_w = 0, input_h = 0; // 네트워크 입력 크기(heatmap 을 여기로 늘린다) +}; +// heat/wh/off 는 CWHN flat: idx = (y*W + x)*C + c. heat 는 **이미 sigmoid 를 거친** 값이다 +// (`CenterNetHead.forward_single` 이 head 안에서 건다 — 여기서 또 걸면 안 된다). +// ⚠️ mmdet 의 `predict_by_feat` 은 기본이 `with_nms=False` 다 — NMS 를 걸지 않는다. +std::vector detect_centernet( + std::vector const& heat, std::vector const& wh, + std::vector const& off, int fh, int fw, centernet_params const& p); + // ── RoIAlign (mmcv RoIAlign, aligned=True, sampling_ratio=0) ───────────────── // FPN feats(레벨별 CWHN flat: idx=(y*W+x)*C+c) + rois(이미지좌표) → roi_feat. // 레벨 배정 = clamp(floor(log2(sqrt(w*h)/finest_scale + 1e-6)), 0, L-1). diff --git a/tools/detect/head.h b/tools/detect/head.h index 064e8c9..6c6b417 100755 --- a/tools/detect/head.h +++ b/tools/detect/head.h @@ -87,6 +87,10 @@ struct anchor_head_cfg { bool corner_emb = false; // CentripetalNet: emb 대신 guiding/centripetal shift 갈래 + DCNv1 feature adaption. bool corner_centripetal = false; + // 코너 디코드 파라미터(test_cfg). 조립기는 안 쓰고 러너가 디코더에 넘긴다. + int corner_topk = 100; + int local_max_kernel = 3; + float corner_distance_thr = 0.5f; // ── DETR ────────────────────────────────────────────────────────────── // 출력이 공간 격자가 아니다 — decoder 층마다 (query, ch) 하나씩 나온다. diff --git a/tools/frontend/mmdet/mmdet_to_pt.py b/tools/frontend/mmdet/mmdet_to_pt.py index e093842..0198fe0 100755 --- a/tools/frontend/mmdet/mmdet_to_pt.py +++ b/tools/frontend/mmdet/mmdet_to_pt.py @@ -71,7 +71,7 @@ def emit_params(cfg, config_name): out.append(f" c.head.kind = head_kind::{h};\n") for k in ("stacked_convs", "reg_stacked_convs", "feat_channels", "num_base", - "num_classes", "gn_groups", "reg_max", + "num_classes", "gn_groups", "reg_max", "corner_topk", "local_max_kernel", "embed_dims", "n_heads", "enc_layers", "dec_layers", "n_points", "num_queries"): if k in cfg: out.append(f" c.head.{k} = {int(cfg[k])};\n") @@ -89,6 +89,8 @@ def emit_params(cfg, config_name): out.append(" c.head.reg_denoms = {" + ", ".join(_f(v) for v in cfg["reg_denoms"]) + "};\n") # 격자 중심. anchor 계열(AnchorGenerator)은 0, point 계열은 0.5 — 조립기가 TOOD 에 쓴다. out.append(f" c.head.head_leaky = {_f(cfg.get('head_leaky', 0.0))};\n") + out.append(" c.head.corner_distance_thr = " + f"{_f(cfg.get('corner_distance_thr', 0.5))};\n") out.append(f" c.head.ddq_iou_thr = {_f(cfg.get('ddq_iou_thr', 0.8))};\n") out.append(f" c.head.center_offset = {_f(cfg.get('center_offset', 0.0))};\n\n") @@ -99,6 +101,18 @@ def emit_params(cfg, config_name): out.append(f" c.det.nms_thr = {_f(cfg.get('nms_thr', 0.5))};\n") out.append(f" c.det.nms_pre = {int(cfg.get('nms_pre', 1000))};\n") out.append(f" c.det.max_per_img = {int(cfg.get('max_per_img', 100))};\n") + out.append(f" c.det.min_bbox_size = {_f(cfg.get('min_bbox_size', -1.0))};\n") + # 레벨별 앵커가 재현식으로 안 나오는 계열(SSD 는 레벨마다 개수가 다르고, YOLACT 는 + # base_size·중심을 stride 와 따로 준다). **분기 앞에** 둔다 — 디코드 가능/불가 어느 + # 쪽이든 필요하다(yolact 는 Delta 코더라 아래 분기로 가는데, 여기 없으면 안 실린다). + if cfg.get("base_anchor_boxes"): + for lvl in cfg["base_anchor_boxes"]: + out.append(" c.det.base_anchor_boxes.push_back({" + + ", ".join(_f(v) for v in lvl) + "});\n") + for i, v in enumerate(cfg.get("means", [0.0] * 4)): + out.append(f" c.det.means[{i}] = {_f(v)};\n") + for i, v in enumerate(cfg.get("stds", [1.0] * 4)): + out.append(f" c.det.stds[{i}] = {_f(v)};\n") # anchor(Delta) 디코드가 되는 계열만 c.det 의 **앵커 파라미터**를 채운다. 조립은 # 되는데 코더가 다른 계열(FSAF 의 TBLRBBoxCoder 등)은 head 원시 출력까지만 낸다. @@ -120,6 +134,13 @@ def emit_params(cfg, config_name): out.append(f" c.det.num_buckets = {int(cfg['num_buckets'])};\n") out.append(f" c.det.bucket_scale = {_f(cfg.get('bucket_scale', 3.0))};\n") out.append(f" c.det.anchor_scale = {_f(cfg.get('anchor_scale') or 4.0)};\n") + # FSAF(TBLR). 앵커 생성 파라미터도 같이 실어야 디코더가 앵커를 만든다. + if cfg.get("tblr_normalizer"): + out.append(f" c.det.tblr_normalizer = {_f(cfg['tblr_normalizer'])};\n") + out.append(f" c.det.octave_base_scale = {_f(cfg.get('octave_base_scale', 1.0))};\n") + out.append(_arr("octave_scales", cfg["octave_scales"])) + out.append(_arr("ratios", cfg["ratios"])) + out.append(f" c.det.center_offset = {_f(cfg.get('center_offset', 0.0))};\n") # FoveaBox 만 채운다. 비어 있으면 디코더가 조립기가 낸 값을 그대로 거리로 쓴다. if cfg.get("bbox_base_edge"): out.append(" c.det.base_edge = {" @@ -148,9 +169,18 @@ def emit_params(cfg, config_name): out.append(f" c.det.means[{i}] = {_f(v)};\n") for i, v in enumerate(cfg.get("stds", [1.0] * 4)): out.append(f" c.det.stds[{i}] = {_f(v)};\n") - out.append(f" c.det.num_classes = {int(cfg.get('num_classes', 80))};\n") + # ⚠️ 배경을 뺀 **의미상 클래스 수**다. softmax head(YOLACT)는 채널이 하나 더 많은데 + # (cls_out_channels = nc+1) 그 폭은 `c.head.num_classes` 가 들고 간다 — 디코더는 + # use_sigmoid 로 채널 폭을 스스로 계산한다(nc + (use_sigmoid?0:1)). + out.append(" c.det.num_classes = " + f"{int(cfg.get('num_classes_semantic', cfg.get('num_classes', 80)))};\n") out.append(f" c.det.use_sigmoid = {str(bool(cfg.get('use_sigmoid', True))).lower()};\n") - out.append(f" c.det.ctr_clamp = {_f(cfg.get('ctr_clamp', 0.0))};\n\n") + out.append(f" c.det.ctr_clamp = {_f(cfg.get('ctr_clamp', 0.0))};\n") + out.append(f" c.det.score_voting = {str(bool(cfg.get('score_voting', False))).lower()};\n") + out.append(f" c.det.fast_nms = {str(bool(cfg.get('fast_nms', False))).lower()};\n") + if cfg.get("nms_top_k"): + out.append(f" c.det.nms_top_k = {int(cfg['nms_top_k'])};\n") + out.append("\n") for i, v in enumerate(cfg.get("img_mean", [0.0] * 3)): out.append(f" c.img_mean[{i}] = {_f(v)};\n") diff --git a/tools/frontend/mmdet/mmdet_wrap.py b/tools/frontend/mmdet/mmdet_wrap.py index 5813806..4e1b796 100755 --- a/tools/frontend/mmdet/mmdet_wrap.py +++ b/tools/frontend/mmdet/mmdet_wrap.py @@ -292,6 +292,9 @@ def postproc_cfg(det): # 디코드 지원은 **별개 판단**이다. head 조립은 되는데 박스 코더가 다른 계열이 있다 # (FSAF 는 RetinaHead 인데 TBLRBBoxCoder 를 쓴다). 하나로 묶으면 조립까지 같이 막힌다. can_decode = bc is not None and "Delta" in type(bc).__name__ + # FSAF 의 TBLRBBoxCoder — 디코더가 따로 있다(detect_anchor 의 tblr 분기). 계수만 싣는다. + tblr_normalizer = (float(getattr(bc, "normalizer", 4.0) or 4.0) + if type(bc).__name__ == "TBLRBBoxCoder" else 0.0) # mmdet DeltaXYWHBBoxCoder 의 `add_ctr_clamp`(YOLOF). 켜지면 중심 이동을 픽셀 단위로 # 자르고 dw/dh 는 상한만 자른다 — 기본 경로와 결과가 다르다. 0 이면 기본 경로. ctr_clamp = float(getattr(bc, "ctr_clamp", 0)) if getattr(bc, "add_ctr_clamp", False) else 0.0 @@ -328,6 +331,27 @@ def _floats(v, dflt): num_base = int(nbp[0]) # 레벨마다 anchor 수가 다르면(SSD: 4·6·6·6·4·4) 하나의 num_base 로 디코드할 수 없다. uniform_priors = len(set(int(x) for x in nbp)) <= 1 + # 그런 계열은 **base 앵커를 통째로 싣는다** — 생성기의 `base_anchors`(레벨별 + # (x1,y1,x2,y2)·n, 중심 (0,0) 부근)가 정본이고, 격자 이동은 디코더가 한다. + # octave/ratio 재현식을 SSD 생성기까지 일반화하는 것보다 값이 틀릴 여지가 없다. + # ⚠️ **재현식이 성립하지 않는 생성기가 있다.** 우리 `gen_anchors` 는 + # `base_size = stride·octave_base_scale`, `중심 = center_offset·base_size` 를 가정하는데, + # YOLACT 는 `base_sizes=[8,16,…]` 를 **stride 와 따로** 주고(stride 는 550/69 처럼 + # 나눠떨어지지 않는다) `centers=(stride/2, stride/2)` 를 직접 준다. 그대로 재현식을 + # 쓰면 박스가 배율만큼 작아지고(실측 0.859 = 110·3 / 128·3) 반 칸씩 밀린다. + # 그런 계열은 생성기의 **base_anchors 를 그대로 싣는다** — 값이 곧 정본이다. + base_anchor_boxes = [] + if pg is not None: + odd = not uniform_priors or getattr(pg, "centers", None) is not None + if not odd: + bs = [float(v) for v in (getattr(pg, "base_sizes", None) or [])] + odd = bool(bs) and any(abs(b - s) > 1e-6 for b, s in zip(bs, strides)) + if odd: + try: + base_anchor_boxes = [[float(v) for v in ba.reshape(-1)] + for ba in pg.base_anchors] + except Exception: + base_anchor_boxes = [] # head-conv 구조 (C++ 조립기가 소비) — 최종 cls/reg conv 이름 **자동 탐지**. # 계열마다 이름이 다르다(retina_cls / atss_cls / gfl_cls / conv_cls …). 이름 표를 두는 대신 @@ -515,9 +539,12 @@ def _tc(key, default): _nms = _tc("nms", {}) or {} thresholds = { "score_thr": float(_tc("score_thr", 0.05)), - "nms_thr": float(_nms.get("iou_threshold", 0.5)), + # YOLACT 는 `nms` dict 대신 평평한 `iou_thr` 를 쓴다 — 있으면 그것이 정본이다. + "nms_thr": float(_tc("iou_thr", 0.0) or _nms.get("iou_threshold", 0.5)), "nms_pre": int(_tc("nms_pre", 1000)), "max_per_img": int(_tc("max_per_img", 100)), + # SSD 계열: 경계 클램프로 변이 0 이 된 박스를 NMS 전에 버리는 기준. 없으면 -1(끔). + "min_bbox_size": float(_tc("min_bbox_size", -1.0)), } return { @@ -543,6 +570,14 @@ def _tc(key, default): "stds": [float(v) for v in getattr(bc, "stds", [1.0] * 4)], "can_decode": can_decode and uniform_priors, "ctr_clamp": ctr_clamp, + "tblr_normalizer": tblr_normalizer, + "base_anchor_boxes": base_anchor_boxes, + # PAA·LAD 의 sqrt(cls×iou) 점수 + score voting. **속성으로 판단한다** — LAD 는 + # PAAHead 상속이라 이름으로 가르면 빠진다. + "score_voting": bool(getattr(bh, "with_score_voting", False)), + # YOLACT 의 fast NMS. test_cfg 에 `nms` dict 가 없고 iou_thr/top_k 가 따로 있다. + "fast_nms": any(c.__name__ == "YOLACTHead" for c in type(bh).__mro__), + "nms_top_k": int(_tc("top_k", 0) or 0), # YOLOv3: 앵커가 (w,h) 쌍 목록이다. `base_sizes[l]` = [(w,h), ...] → 평탄화. # ⚠️ **레벨 순서를 건드리지 않는다** — YOLOv3 는 stride 가 내림차순(32,16,8)이고 # 그 순서가 head 출력 순서와 같다. 정렬하면 레벨마다 4배씩 어긋난다. @@ -579,6 +614,10 @@ def _tc(key, default): "ctr_tanh": ctr_tanh, "corner_emb": corner_emb, "corner_centripetal": corner_centripetal, + # 코너 디코드 파라미터. 계열 기본값이 아니라 **test_cfg 가 정본**이다. + "corner_topk": int(_tc("corner_topk", 100)), + "local_max_kernel": int(_tc("local_maximum_kernel", 3)), + "corner_distance_thr": float(_tc("distance_threshold", 0.5)), **detr_dims, "centerness_on_reg": is_autoassign or bool(getattr(bh, "centerness_on_reg", True)), "scales_prefix": "bbox_head.scales" if hasattr(bh, "scales") else "", diff --git a/tools/verify/backbone/run_mmdet.cpp b/tools/verify/backbone/run_mmdet.cpp index e65527c..628a735 100755 --- a/tools/verify/backbone/run_mmdet.cpp +++ b/tools/verify/backbone/run_mmdet.cpp @@ -321,6 +321,7 @@ int main(int argc, char** argv) { return 0; } + std::vector dets; head_outputs ho; mmdet_head_forward(m, feats, hc, dcn_base, ho); std::vector&cls_t = ho.cls, &box_t = ho.box; @@ -373,9 +374,61 @@ int main(int argc, char** argv) { ho.ctr.empty() ? "" : ",ctr"); } - // 코너 계열은 anchor 디코드가 아예 없다(코너 짝짓기 + embedding 그룹핑). 조립·덤프까지가 - // 이 러너의 몫이고, 그 뒤를 억지로 detect_anchor 에 넣으면 의미 없는 박스가 나온다. - if (hc.kind == head_kind::cornernet) return 0; + // ── CornerNet / CentripetalNet ───────────────────────────────────────── + // 앵커가 없다 — 코너 heatmap 두 장에서 top-k 를 뽑아 쌍을 만든다. 갈래가 6~8개라 + // out.extra 를 쓰고, **마지막 레벨만** 쓴다(mmdet 도 `tl_heats[-1]` 이다). + bool corner_done = false; + if (hc.kind == head_kind::cornernet) { + if (ho.cls.empty() || ho.box.empty() || ho.ctr.empty() || ho.extra.empty()) { + fprintf(stderr, "corner head 출력이 비었다\n"); + return 4; + } + auto last = [](std::vector const& v) { return v.back(); }; + auto named = [&](const char* tag) -> std::vector { + for (auto const& e : ho.extra) + if (e.first == tag && !e.second.empty()) return to_vec(e.second.back()); + return {}; + }; + tensor t_cls = last(ho.cls); + const int cfh = (int)t_cls->ne[2], cfw = (int)t_cls->ne[1]; + corner_params cp; + cp.num_classes = dp.num_classes; + cp.topk = hc.corner_topk; + cp.local_max_kernel = hc.local_max_kernel; + cp.distance_threshold = hc.corner_distance_thr; + cp.score_thr = dp.score_thr; + cp.nms_thr = dp.nms_thr; + cp.max_per_img = dp.max_per_img; + cp.input_w = SZ; + cp.input_h = SZ; + cp.centripetal = hc.corner_centripetal; + dets = detect_corner(to_vec(t_cls), to_vec(last(ho.box)), + to_vec(last(ho.ctr)), named("brof"), + named("tlemb"), named("bremb"), + named("tlcs"), named("brcs"), + cfh, cfw, cp); + corner_done = true; // 아래 레벨별 수집·디스패치를 건너뛴다 + } + + // ── CenterNet ────────────────────────────────────────────────────────── + // 앵커가 없고 레벨도 하나다. heatmap 의 국소 최대점이 중심이고, 같은 자리의 + // wh/offset 이 상자를 만든다. heat 는 head 안에서 이미 sigmoid 를 거쳤다. + if (hc.kind == head_kind::centernet) { + if (ho.cls.empty() || ho.box.empty() || ho.ctr.empty()) { + fprintf(stderr, "centernet head 출력이 비었다(heatmap/wh/offset 필요)\n"); + return 4; + } + tensor t = ho.cls.back(); + centernet_params cp; + cp.num_classes = dp.num_classes; + cp.topk = hc.corner_topk; // test_cfg.topk (헤더가 같은 칸을 쓴다) + cp.local_max_kernel = hc.local_max_kernel; + cp.input_w = SZ; + cp.input_h = SZ; + dets = detect_centernet(to_vec(t), to_vec(ho.box.back()), to_vec(ho.ctr.back()), + (int)t->ne[2], (int)t->ne[1], cp); + corner_done = true; // 레벨별 수집·디스패치를 건너뛴다(같은 이유) + } // DETR 계열은 출력 인덱스가 **decoder 층**이라 레벨 수와 무관하다 — 아래 레벨 검사와 // 레벨별 수집을 건너뛴다(head.h:172-175). @@ -391,14 +444,16 @@ int main(int argc, char** argv) { const bool single_branch = hc.kind == head_kind::yolo; // 조립기가 레벨 수를 못 채우면 아래 인덱싱이 널을 읽는다. 여기서 말한다. - if (!is_detr && !single_branch && ((int)cls_t.size() < L || (int)box_t.size() < L)) { + if (!corner_done && !is_detr && !single_branch && + ((int)cls_t.size() < L || (int)box_t.size() < L)) { fprintf(stderr, "head 출력이 %d 레벨에 못 미친다 (cls %zu, box %zu)\n", L, cls_t.size(), box_t.size()); return 4; } // 6) raw cls/box(+ctr) → 계열별 디코드 - const int NL = is_detr ? 0 : L; + // 코너 계열은 위에서 이미 디코드했다 — 레벨별 수집을 돌 필요가 없다. + const int NL = (is_detr || corner_done) ? 0 : L; std::vector> cls_v(NL), box_v(NL), ctr_v; std::vector> feat_hw(NL); for (int l = 0; l < NL; ++l) { @@ -410,7 +465,7 @@ int main(int argc, char** argv) { // centerness 갈래가 있으면 score factor 로 넘긴다(ATSS·PAA·DDOD·FCOS…). mmdet 은 이걸 // top-k 뒤에 곱한다 — 안 넘기면 **박스는 맞고 점수만** 높게 나온다(실측 Δ0.071). // ⚠️ YOLACT 의 coeff 갈래는 점수가 아니다 — `ctr_tanh` 로 가른다. - if ((int)ho.ctr.size() >= L && !hc.ctr_tanh) { + if (!corner_done && (int)ho.ctr.size() >= L && !hc.ctr_tanh) { ctr_v.resize(L); for (int l = 0; l < L; ++l) ctr_v[l] = to_vec(ho.ctr[l]); } @@ -428,8 +483,9 @@ int main(int argc, char** argv) { hc.kind == head_kind::vfnet || hc.kind == head_kind::reppoints || hc.bbox_mul_stride; - std::vector dets; - if (is_detr) { + if (corner_done) { + // 위에서 채웠다 — 아무것도 안 한다. + } else if (is_detr) { // ⚠️ DETR 계열은 out.cls/out.box 의 인덱스가 **FPN 레벨이 아니라 decoder 층**이다 // (head.h:172-175). mmdet 도 `all_layers_*[-1]` 만 쓴다 — 앞 층을 쓰면 shape 가 // 맞아 안 죽고 **더 거친 박스**가 나온다. 그림만 보면 알 수 없다. @@ -584,6 +640,16 @@ int main(int argc, char** argv) { fp.point_offset = hc.center_offset; fp.box_xyxy_offset = hc.kind == head_kind::reppoints; dets = detect_fcos(cls_v, box_v, ctr_v, feat_hw, fp); + } else if (dp.score_voting) { + // PAA·LAD — 점수가 sqrt(cls×iou) 라 iou 갈래(ctr) 없이는 못 디코드한다. + if ((int)ctr_v.size() < L) { + fprintf(stderr, "PAA 인데 iou 갈래가 %zu 레벨뿐이다 (필요 %d)\n", ctr_v.size(), L); + return 4; + } + dets = detect_paa(cls_v, box_v, ctr_v, feat_hw, dp); + } else if (dp.fast_nms) { + // YOLACT — softmax + fast NMS. coeff 갈래(ctr_tanh)는 박스 검증에서 안 쓴다. + dets = detect_yolact(cls_v, box_v, feat_hw, dp); } else { dets = detect_anchor(cls_v, box_v, feat_hw, dp, ctr_v.empty() ? nullptr : &ctr_v); } diff --git a/tools/verify/dense_head/verify_heads.py b/tools/verify/dense_head/verify_heads.py index 7b4a738..04e098f 100644 --- a/tools/verify/dense_head/verify_heads.py +++ b/tools/verify/dense_head/verify_heads.py @@ -614,6 +614,13 @@ def one(fam, rel, ckpt): # 최적화 수준은 **수치와 무관**하다 — 실제 계산은 libggml(사전 빌드)이 한다. # 이 코드는 그래프를 짜기만 하므로 -O1 이면 충분하고, 컴파일이 훨씬 빠르다. + # ⚠️ **먼저 지운다.** 존재 검사만 하면 빌드가 실패해도 **지난 실행의 바이너리**로 + # 계속 가고, 낡은 코드가 낸 숫자가 PASS 로 보고된다(실제로 겪었다: 코너 디코드를 + # 새로 붙였는데 goto 컴파일 에러였고, 하네스는 PASS 를 냈다). + try: + os.remove(os.path.join(gen, "run_mmdet")) + except FileNotFoundError: + pass b = run(["g++", "-std=c++20", OPT, "-DARCH=Fam", '-DVISP_ARCH_HEADER="visp/arch/Fam.h"', f'-DMMDET_PARAMS_HEADER="{ph}"', diff --git a/tools/verify/dense_head/verify_postproc.py b/tools/verify/dense_head/verify_postproc.py index 7ffe5a6..f2f66ed 100644 --- a/tools/verify/dense_head/verify_postproc.py +++ b/tools/verify/dense_head/verify_postproc.py @@ -77,6 +77,12 @@ def mmdet_boxes(cfg, ckpt, image, size, thr, to_rgb): det = init_detector(cfg, ckpt, device="cpu") det.eval() + # ⚠️ SSDHead 는 `loss_cls` 를 모듈로 안 만든다(손실을 inline CE 로 계산). 그런데 + # mmdet 자신의 `_predict_by_feat_single` 이 v3det 분기에서 + # `getattr(self.loss_cls, 'custom_cls_channels', …)` 를 만져 AttributeError 로 + # 죽는다 — 기준값 쪽 문제지 우리 쪽이 아니다. 추론 경로에서는 None 이면 충분하다. + if getattr(det, "bbox_head", None) is not None and not hasattr(det.bbox_head, "loss_cls"): + det.bbox_head.loss_cls = None dp = det.data_preprocessor # ⚠️ **정규화를 안 하는 계열이 있다** — YOLOX 는 `mean`/`std` 자체를 안 갖는다. # 그때 파라미터 헤더도 mean 0 / std 1 을 싣는다(둘이 같아야 같은 픽셀이 된다). @@ -94,7 +100,13 @@ def mmdet_boxes(cfg, ckpt, image, size, thr, to_rgb): t = torch.from_numpy(x).permute(2, 0, 1).unsqueeze(0) meta = {"img_shape": (size, size), "ori_shape": (size, size), - "scale_factor": (1.0, 1.0), "batch_input_shape": (size, size)} + "scale_factor": (1.0, 1.0), "batch_input_shape": (size, size), + # ⚠️ CenterNet 의 `_predict_by_feat_single` 은 `img_meta['border']` 를 읽는다 + # (`RandomCenterCropPad` 가 남기는 값 — crop/pad 로 생긴 여백을 박스에서 + # 빼는 데 쓴다). 우리는 정사각으로 한 번 리사이즈만 하므로 여백이 0 이고 + # 항등이다. 없으면 `KeyError: 'border'` 로 **기준값 쪽이** 죽는데, + # 그건 계열이 안 되는 게 아니라 하네스가 못 재는 것이다. + "border": (0.0, 0.0, 0.0, 0.0)} with torch.no_grad(): try: From 6e5f62950321eb76844f8b710ae99f558e770884 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 18 Aug 2026 15:30:53 +0900 Subject: [PATCH 66/89] =?UTF-8?q?docs:=20one-stage=2032=EA=B3=84=EC=97=B4?= =?UTF-8?q?=20=E2=80=94=2059=EA=B3=84=EC=97=B4=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전수 회귀: one-stage 34계열 중 32 PASS · two-stage 40계열 중 26(+fpg 1024) = 27. 기존 계열 수치는 양쪽 다 그대로다. 추가 6: fsaf 0.56 · paa 0.54 · lad 0.74 · ssd 0.42 · cornernet 0.03 · centernet 0.13px. 여섯 전부 "범위 밖" 으로 적혀 있던 것이고 하루에 닫혔다 — **"전용 디코더가 필요하다" 는 "못 한다" 가 아니다.** 필요했던 것은 그 계열의 config 를 기본값이라 가정하지 않고 읽는 것뿐이다. 판정에 못 미친 둘은 별도로 적었다. `yolact`(0.74px, 개수차 1)는 하네스 임계 0.30 경계 아티팩트라 free_anchor·double_heads 와 같은 칸이고, `centripetalnet`(3.80px)은 mmdet 자신의 `_decode_heatmap` 을 우리 텐서에 돌려 **공식 동치**를 확인했으므로 디코드가 아니라 정밀도다. 하네스가 못 잰 것과 대상이 못 하는 것을 같은 칸에 쓰지 않는다. --- docs/mmdet-detectors.md | 69 +++++++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 2e7449e..8acfcf3 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -288,8 +288,9 @@ within 0.1 px, and the pre-decode tensors are at relative L1 1.7e-03 on the boxe ## What decodes to boxes -Assembling a head and decoding its output are separate steps, and a family can pass the first -and fail the second. The runner picks a decoder from what the box prediction *is* — a delta +**Fifty-nine families produce the same boxes MMDetection does** — thirty-two single-stage +below, twenty-seven two-stage further down. Assembling a head and decoding its output are +separate steps, and a family can pass the first and fail the second. The runner picks a decoder from what the box prediction *is* — a delta against an anchor, a distance from a grid point, a normalised `cxcywh` query — not from the shape of the tower that produced it. YOLOX and RPN build the same tower as RetinaNet and decode nothing like it. @@ -301,31 +302,37 @@ no label mismatch and no difference in how many boxes survive. | Family | Decoder | Worst box | Worst score | | :--- | :--- | ---: | ---: | +| `cornernet` | `detect_corner` (embedding pairing) | 0.03 px | 0.003 | +| `ddq` | `detect_detr` (distinct queries) | 0.06 px | 0.018 | +| `yolof` | `detect_anchor` (ctr_clamp) | 0.12 px | 0.002 | +| `centernet` | `detect_centernet` (heatmap peaks) | 0.13 px | 0.001 | | `atss` | `detect_anchor` | 0.14 px | 0.000 | | `efficientnet` | `detect_anchor` | 0.15 px | 0.005 | | `pisa` | `detect_anchor` | 0.18 px | 0.005 | -| `retinanet` | `detect_anchor` | 0.27 px | 0.000 | -| `ddod` | `detect_anchor` | 0.41 px | 0.001 | -| `ghm` | `detect_anchor` | 0.46 px | 0.005 | -| `nas_fpn` | `detect_anchor` | 1.04 px | 0.004 | -| `yolof` | `detect_anchor` (ctr_clamp) | 0.12 px | 0.002 | -| `pvt` | `detect_anchor` (PVT-Tiny) | 0.55 px | 0.005 | -| `sabl` | `detect_sabl` (buckets) | 0.55 px | 0.003 | | `nas_fcos` | `detect_fcos` | 0.19 px | 0.001 | -| `reppoints` | `detect_fcos` (xyxy offset) | 0.25 px | 0.006 | -| `autoassign` | `detect_fcos` | 0.30 px | 0.006 | -| `foveabox` | `detect_fcos` (base_edge) | 0.40 px | 0.002 | | `gfl` | `detect_fcos` | 0.23 px | 0.004 | -| `fcos` | `detect_fcos` | 0.32 px | 0.004 | -| `vfnet` | `detect_fcos` | 0.46 px | 0.003 | -| `rtmdet` | `detect_fcos` | 0.68 px | 0.002 | -| `ld` | `detect_fcos` | 0.30 px | 0.006 | -| `yolox` | `detect_yolox` | 0.41 px | 0.002 | | `yolo` | `detect_yolov3` | 0.23 px | 0.005 | +| `reppoints` | `detect_fcos` (xyxy offset) | 0.25 px | 0.006 | +| `retinanet` | `detect_anchor` | 0.27 px | 0.000 | | `conditional_detr` | `detect_detr` | 0.27 px | 0.002 | | `dab_detr` | `detect_detr` | 0.28 px | 0.001 | | `dino` | `detect_detr` | 0.28 px | 0.004 | -| `ddq` | `detect_detr` (distinct queries) | 0.06 px | 0.018 | +| `autoassign` | `detect_fcos` | 0.30 px | 0.006 | +| `ld` | `detect_fcos` | 0.30 px | 0.006 | +| `fcos` | `detect_fcos` | 0.32 px | 0.004 | +| `foveabox` | `detect_fcos` (base_edge) | 0.40 px | 0.002 | +| `ddod` | `detect_anchor` | 0.41 px | 0.001 | +| `yolox` | `detect_yolox` | 0.41 px | 0.002 | +| `ssd` | `detect_anchor` (per-level anchors) | 0.42 px | 0.003 | +| `ghm` | `detect_anchor` | 0.46 px | 0.005 | +| `vfnet` | `detect_fcos` | 0.46 px | 0.003 | +| `paa` | `detect_paa` (score voting) | 0.54 px | 0.011 | +| `pvt` | `detect_anchor` (PVT-Tiny) | 0.55 px | 0.005 | +| `sabl` | `detect_sabl` (buckets) | 0.55 px | 0.003 | +| `fsaf` | `detect_anchor` (TBLR coder) | 0.56 px | 0.003 | +| `rtmdet` | `detect_fcos` | 0.68 px | 0.002 | +| `lad` | `detect_paa` (score voting) | 0.74 px | 0.001 | +| `nas_fpn` | `detect_anchor` | 1.04 px | 0.004 | | `detr` | `detect_detr` | 1.85 px | 0.044 | Every row above was re-measured together in one run, so the numbers are comparable with each @@ -341,6 +348,16 @@ representative config from `metafile.yml` instead picks PVTv2-B5, a different ar covered. Two variants of one family can disagree completely; naming the variant is not optional. +Six of those rows were added after the first pass, and every one of them had been recorded as +out of scope. That is worth saying plainly: **"needs its own decoder" is not the same as +"cannot be done"** — the six closed in a day. What they needed was reading the family's own +config rather than assuming defaults. `ssd` lays down a different number of anchors per level; +`yolact` gives base sizes and centres separately from the strides, so the usual +stride-times-scale reconstruction lands 0.859× small and half a cell off; `paa` and `lad` rank +by anchor rather than by (anchor, class) and re-average boxes by score voting, which is +selected by the `with_score_voting` attribute and not by the class name — `lad` subclasses +`PAAHead`, so matching on the name misses it. + `ld` needs its command run from the MMDetection root. Distillation configs name the teacher as `teacher_config='configs/gfl/...'`, relative to the working directory rather than to the config file, so running from anywhere else fails to find it and the family looks broken. @@ -479,12 +496,16 @@ Three groups do not decode, and they fail for different reasons: never had a chance). `MMDET_DUMP_HEAD` writes the neck output beside the head output for exactly this split — a family whose `feat` dumps match and whose `cls`/`box` dumps do not is a head problem, and the reverse is a compiler problem. -- **The family post-processes its own way**: `paa` and `lad` combine class score and IoU as - `sqrt(cls * iou)` and then re-average boxes by score voting; `yolact` uses fast NMS and mask - coefficients. Their boxes already agree — `paa` to 9 px — but far fewer survive the threshold. -- **The priors or the coder are outside `det_params`**: `ssd` uses a different number of anchors - per level, `fsaf` a TBLR coder, and `cornernet`, `centripetalnet`, `centernet` and `yolov3` - decode from heatmaps or corner pairs. +- **The family post-processes its own way.** This group is down to `yolact`, and it is a + boundary case rather than a decode failure: fast NMS is implemented and the boxes agree to + 0.74 px, but one box sits either side of the harness's own 0.30 cut (mmdet 0.3013, compiled + 0.2951). It is counted with `free_anchor` and `double_heads`, not with the failures. +- **The priors or the coder are outside `det_params`.** This group is now empty. `ssd`, + `fsaf`, `paa`, `lad`, `cornernet` and `centernet` all decode, and `yolov3` did earlier. + `centripetalnet` decodes too but lands at 3.80 px on one of two boxes, where a single + top-k rank flips between two nearly-equal heatmap peaks; running mmdet's own + `_decode_heatmap` on our tensors returns the same answer (0.4387 against 0.4393), so the + formula is equivalent and what remains is half-precision, not the decoder. A family in the first group is not silently wrong: without anchor parameters `detect_anchor` generates no candidates and the runner reports zero boxes. From 542bdbab0f4682ea4405b451b6ef4d22ac77922f Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 18 Aug 2026 15:44:13 +0900 Subject: [PATCH 67/89] =?UTF-8?q?feat(grid=5Frcnn):=20grouped=20conv=5Ftra?= =?UTF-8?q?nspose=20=EB=B6=84=ED=95=B4=20=E2=80=94=200.15px?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grid head 의 deconv 가 `groups=9, padding=1` 이라 ggml 의 `ggml_conv_transpose_2d_p0`(padding 0 · groups 1 전용)로는 못 돌렸다. 정의대로 쪼갠다: 그룹마다 커널(ne3 = IC 축)과 입력(ne2 = 채널 축)을 view 로 잘라 따로 돌리고 채널로 이어붙인다. padding 은 앞서 넣은 크롭이 처리한다. ⚠️ 안 쪼개고 그냥 넘기면 **groups 가 조용히 무시되어 채널이 섞인 채 돈다.** 크래시가 없으므로 렌더러에서 막아 뒀었는데, 이제 지원하니 가드를 풀고 인자를 넘긴다. ## clamp 하나에 1.30px 디코드를 다 맞추고도 1.30px 이 남았고, **전부 이미지 경계에 걸친 상자**였다 (mmdet 801.3 vs 우리 800.0). mmdet 은 자르는 것처럼 보인다: bboxes[:, [0, 2]].clamp_(min=0, max=img_meta['img_shape'][1]) 그런데 `bboxes[:, [0, 2]]` 는 **팬시 인덱싱이라 복사본**이다 — `clamp_` 는 그 복사본을 자르고 버린다. 즉 mmdet 은 **자르지 않는다.** 우리도 안 자르게 바꾸니 0.15px. 라이브러리의 **실제 동작**이 정본이지 코드가 표현하려던 의도가 아니다. 원식을 옮길 때 "이 줄이 정말 효과가 있나" 를 한 번 물어야 하는 자리가 있다. 회귀 7/7: ms_rcnn 0.07 · crowddet 0.07 · seesaw 0.09 · faster_rcnn 0.10 · htc 0.12 · scnet 0.18px. centernet(one-stage, deconv 를 타는 다른 계열)도 0.13px 그대로다. --- src/visp/nn.cpp | 26 ++++++++++++++++++++++++-- src/visp/nn.h | 4 +++- tools/frontend/mmdet/frcnn_wrap.py | 12 ------------ tools/verify/backbone/run_frcnn.cpp | 13 +++++++++---- 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/visp/nn.cpp b/src/visp/nn.cpp index 5f0cc2a..2cf16b2 100644 --- a/src/visp/nn.cpp +++ b/src/visp/nn.cpp @@ -184,7 +184,7 @@ tensor conv_2d_depthwise(model_ref m, tensor x, int stride, int pad) { return x; } -tensor conv_transpose_2d(model_ref m, tensor x, int stride, int pad) { +tensor conv_transpose_2d(model_ref m, tensor x, int stride, int pad, int groups) { tensor weight = m.weights("weight"); // ⚠️ **커널은 F16 이어야 한다.** `ggml_compute_forward_conv_transpose_2d` 는 // `GGML_ASSERT(src0->type == GGML_TYPE_F16)` 로 시작한다(ggml-cpu/ops.cpp). @@ -197,7 +197,29 @@ tensor conv_transpose_2d(model_ref m, tensor x, int stride, int pad) { if (m.flags & model_build_flag::cwhn) { x = ggml_cont(m, permute_cwhn_to_whcn(m, x)); } - x = ggml_conv_transpose_2d_p0(m, weight, x, stride); + if (groups <= 1) { + x = ggml_conv_transpose_2d_p0(m, weight, x, stride); + } else { + // ⚠️ **ggml 에는 grouped conv_transpose 가 없다.** 그냥 통과시키면 groups 가 + // 조용히 무시되어 채널이 섞인 채 돈다(크래시 없음). 그룹마다 커널과 입력을 + // 잘라 따로 돌리고 채널 축으로 이어붙인다 — 정의 그대로다. + // 커널 ne = [KW, KH, OC/g, IC] 이므로 IC 는 ne3, 입력 채널은 ne2 다. + const int64_t icg = weight->ne[3] / groups; // 그룹당 입력 채널(커널 쪽) + const int64_t xcg = x->ne[2] / groups; // 그룹당 입력 채널(피처 쪽) + GGML_ASSERT(icg > 0 && xcg > 0); + tensor acc = nullptr; + for (int g = 0; g < groups; ++g) { + tensor wg = ggml_cont(m, ggml_view_4d( + m, weight, weight->ne[0], weight->ne[1], weight->ne[2], icg, + weight->nb[1], weight->nb[2], weight->nb[3], (size_t)g * icg * weight->nb[3])); + tensor xg = ggml_cont(m, ggml_view_4d( + m, x, x->ne[0], x->ne[1], xcg, x->ne[3], + x->nb[1], x->nb[2], x->nb[3], (size_t)g * xcg * x->nb[2])); + tensor og = ggml_conv_transpose_2d_p0(m, wg, xg, stride); + acc = acc ? ggml_concat(m, acc, og, 2) : og; + } + x = acc; + } // ⚠️ **`ggml_conv_transpose_2d_p0` 은 이름 그대로 padding 0 전용이다.** // transposed conv 의 padding p 는 "출력 가장자리를 p 픽셀씩 버린다" 와 같으므로 diff --git a/src/visp/nn.h b/src/visp/nn.h index 743d05a..27ab87f 100644 --- a/src/visp/nn.h +++ b/src/visp/nn.h @@ -64,7 +64,9 @@ tensor conv_2d_deform( model_ref m, tensor x, tensor weight, tensor offset, tensor mask, int stride, int pad); // `pad` 는 torch 의 ConvTranspose2d padding 과 같은 뜻이다(출력 가장자리를 그만큼 버린다). // ggml 에는 padding 을 받는 conv_transpose 가 없어 여기서 잘라낸다. -tensor conv_transpose_2d(model_ref m, tensor x, int stride, int pad = 0); +// `groups` 는 torch 와 같은 뜻이다. ggml 에 grouped conv_transpose 가 없어 그룹마다 +// 커널·입력을 잘라 돌리고 채널로 이어붙인다. +tensor conv_transpose_2d(model_ref m, tensor x, int stride, int pad = 0, int groups = 1); tensor batch_norm_2d(model_ref, tensor x); // 2D image to patch embedding using convolution and optional norm. CWHN input and output. diff --git a/tools/frontend/mmdet/frcnn_wrap.py b/tools/frontend/mmdet/frcnn_wrap.py index bee83a1..7a23931 100755 --- a/tools/frontend/mmdet/frcnn_wrap.py +++ b/tools/frontend/mmdet/frcnn_wrap.py @@ -294,18 +294,6 @@ def frcnn_cfg(det, size=800): # 두 곳이 갈린다(`calc_sub_regions` 는 정수 절단이 섞여 있어 특히 위험하다). gh = getattr(det.roi_head, "grid_head", None) if gh is not None: - # ⚠️ **grid head 의 deconv 는 grouped + padded 다**(groups=grid_points=9, - # padding=(k-2)//2=1). ggml 이 가진 것은 `ggml_conv_transpose_2d_p0` 하나이고 - # 이름 그대로 padding 0 · groups 1 전용이다. 그대로 태우면 출력이 2px 크고 - # 채널 묶음이 섞여 `add_bias_2d` 에서 `ggml_can_repeat` 로 죽는다. - # **크래시로 두면 "우리 버그" 처럼 보인다** — 왜 안 되는지 여기서 말한다. - d1 = gh.deconv1 - if getattr(d1, "groups", 1) != 1 or any(v != 0 for v in getattr(d1, "padding", (0, 0))): - raise NotImplementedError( - f"GridHead.deconv: groups={getattr(d1, 'groups', 1)} · " - f"padding={tuple(getattr(d1, 'padding', (0, 0)))} — ggml 의 conv_transpose 는 " - "padding 0 · groups 1 만 한다(ggml_conv_transpose_2d_p0). 그룹별로 쪼개 돌리고 " - "가장자리를 잘라내는 분해가 필요하다 — 디코드가 아니라 연산 부족이다") gext = det.roi_head.grid_roi_extractor if isinstance(gext, nn.ModuleList): gext = gext[0] diff --git a/tools/verify/backbone/run_frcnn.cpp b/tools/verify/backbone/run_frcnn.cpp index 1ef3db8..be0e2e7 100644 --- a/tools/verify/backbone/run_frcnn.cpp +++ b/tools/verify/backbone/run_frcnn.cpp @@ -829,10 +829,15 @@ int main(int argc, char** argv) { ix2.push_back(GP - GS + i); iy2.push_back((i + 1) * GS - 1); } - dets[n].x1 = std::min(std::max(vote(ix1, ax), 0.0f), (float)SZ); - dets[n].y1 = std::min(std::max(vote(iy1, ay), 0.0f), (float)SZ); - dets[n].x2 = std::min(std::max(vote(ix2, ax), 0.0f), (float)SZ); - dets[n].y2 = std::min(std::max(vote(iy2, ay), 0.0f), (float)SZ); + // ⚠️ **자르지 않는다.** mmdet 에 clamp 가 있지만 **동작하지 않는다** — + // `bboxes[:, [0, 2]].clamp_(...)` 는 팬시 인덱싱이라 복사본을 자르고 버린다 + // (grid_head.py:482-483). 여기서 진짜로 자르면 경계에 걸친 상자만 + // 어긋난다(실측 801.3 vs 800.0 → 1.30px). 라이브러리의 **실제 동작**이 + // 정본이지 주석이나 의도가 아니다. + dets[n].x1 = vote(ix1, ax); + dets[n].y1 = vote(iy1, ay); + dets[n].x2 = vote(ix2, ax); + dets[n].y2 = vote(iy2, ay); heat_all.insert(heat_all.end(), hm.begin(), hm.end()); } dump_bin(pref + ".gridheat.bin", heat_all); From 92a6a37459cafe9e9562f45b2593cd08fc305c2d Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 18 Aug 2026 15:45:07 +0900 Subject: [PATCH 68/89] =?UTF-8?q?docs:=20grid=5Frcnn=200.15px=20=E2=80=94?= =?UTF-8?q?=2060=EA=B3=84=EC=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit two-stage 28계열(26 at 800 + fpg 1024 + grid_rcnn). 회귀 7/7 통과. '연산 부족' 항목이 비었다 — grouped conv_transpose 를 그룹별 분해로 지원한다. 안 맞는 계열은 12개, 네 갈래다: 표준 앵커 RPN 이 아님(5) · fp16(3) · 경계 아티팩트(double_heads) · 기타(fast_rcnn 가중치 없음 · panoptic_fpn 환경 부족 · tridentnet 원인 미규명). --- docs/mmdet-detectors.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 8acfcf3..10a6150 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -288,8 +288,8 @@ within 0.1 px, and the pre-decode tensors are at relative L1 1.7e-03 on the boxe ## What decodes to boxes -**Fifty-nine families produce the same boxes MMDetection does** — thirty-two single-stage -below, twenty-seven two-stage further down. Assembling a head and decoding its output are +**Sixty families produce the same boxes MMDetection does** — thirty-two single-stage +below, twenty-eight two-stage further down. Assembling a head and decoding its output are separate steps, and a family can pass the first and fail the second. The runner picks a decoder from what the box prediction *is* — a delta against an anchor, a distance from a grid point, a normalised `cxcywh` query — not from the shape of the tower that produced it. YOLOX and RPN build the same tower as RetinaNet and decode @@ -365,7 +365,7 @@ file, so running from anywhere else fails to find it and the family looks broken Two-stage families are measured separately, at 800 and against the detector's own `predict` rather than a head's `predict_by_feat`, because the boxes do not exist until RPN proposals, RoIAlign and the RoI head have run. The harness is `tools/verify/roi/verify_postproc_roi.py` -and the thresholds are the same. Twenty-seven of the forty families with a `roi_head` agree: +and the thresholds are the same. Twenty-eight of the forty families with a `roi_head` agree: | Family | Decoder | Worst box | Worst score | | :--- | :--- | ---: | ---: | @@ -387,6 +387,7 @@ and the thresholds are the same. Twenty-seven of the forty families with a `roi_ | `resnest` | `detect_roi` | 0.12 px | 0.0002 | | `gcnet` | `detect_roi` | 0.14 px | 0.0010 | | `albu_example` | `detect_roi` | 0.14 px | 0.0008 | +| `grid_rcnn` | `detect_roi` (grid heatmap, no reg branch) | 0.15 px | 0.0007 | | `point_rend` | `detect_roi` | 0.15 px | 0.0012 | | `regnet` | `detect_roi` | 0.16 px | 0.0012 | | `scnet` | `detect_roi` (+ global context) | 0.18 px | 0.0020 | @@ -409,7 +410,7 @@ made the failure visible. Treat the old number as unverified rather than wrong. below P5, where 800 stops dividing evenly — a 25-wide map meets a 26-wide one and the export aborts. That is a property of the resolution, not of the family. -The thirteen that do not agree split five ways, and the split matters more than the count: +The twelve that do not agree split four ways, and the split matters more than the count: - **The RPN is not a standard anchor RPN**, so host `rpn_proposals` cannot lay down the priors: `cascade_rpn` refines across stages, `guided_anchoring` predicts anchor shapes, and @@ -417,15 +418,6 @@ The thirteen that do not agree split five ways, and the split matters more than reason — `GenericRoIExtractor` aggregates every level through per-level convolutions, which host RoIAlign cannot express. All five stop at export with a stated reason rather than a wrong number. -- **The graph needs an operator ggml does not have.** `grid_rcnn` regresses boxes in a grid - head whose two transposed convolutions are grouped (`groups=9`) and padded (`padding=1`); - `ggml_conv_transpose_2d_p0` is neither. Everything else for that family is written — the - bbox head has no regression branch at all (`with_reg=False`, so the RoIs are used as boxes - the way mmdet does when `bbox_pred is None`), and the grid decode reads nine heatmap peaks, - maps them into the expanded box and averages each side by score. It stops at export with - the operator named, because "the decoder is missing" and "the operator is missing" are - different problems and should not share a row. Padding alone is now supported (the helper - crops the p0 output); groups is what remains. - **FP16 weights, not a defect.** `dynamic_rcnn` (10.24 px), `pafpn` (8.77 px) and `res2net` (6.81 px) return the right count and the right labels with the coordinates several pixels out. Recompiling with fp32 weights makes all three exact at **0.00 px**, so the gap is the From a208e2b0b88e887b19bdd789ada729a9b54fde31 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 18 Aug 2026 15:54:01 +0900 Subject: [PATCH 69/89] =?UTF-8?q?feat(tridentnet):=20C4=20=EA=B3=84?= =?UTF-8?q?=EC=97=B4=20=EB=91=90=20=EA=B2=B9=20=E2=80=94=200.09px=20(?= =?UTF-8?q?=EB=B0=95=EC=8A=A4=200=EA=B1=B4=EC=9D=B4=EB=8D=98=20=EB=A7=88?= =?UTF-8?q?=EC=A7=80=EB=A7=89=20=EB=AF=B8=EB=B6=84=EB=A5=98=20=EA=B3=84?= =?UTF-8?q?=EC=97=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "러너가 박스를 0건 낸다" 로 오래 남아 있던 계열이고, 원인은 둘이었다. 둘 다 C4 구조 (neck 없음 · 단일 레벨 stride 16)에서만 드러난다. ① **한 레벨에 스케일이 5개다.** FPN RPN 은 레벨마다 스케일이 하나라 `scales[0]` 만 실어도 됐지만, C4 는 `scales=[2,4,8,16,32]` 를 **한 레벨에** 준다. 첫 개만 쓰면 앵커가 15개가 아니라 3개가 되고, RPN cls 채널 15개를 3개로 읽어 **엉뚱한 자리를 objectness 로 오해한다** — proposal 이 전부 빗나가 최종 박스가 0건이 됐다. 실측으로 rpncls 가 50×50×**15**임을 확인하고 짚었다. 목록 전체를 싣고 base_size 는 stride 로 둔다(mmdet `base_sizes` 기본값). 스케일이 하나인 계열은 결과가 완전히 같다 — faster_rcnn 0.10px 그대로. ② **proposal 이 상한보다 적게 나온다.** SubB 는 상한(rpn_max=1000)으로 구워지고 그 안의 flatten 이 `ggml_reshape_2d(…, 2048, 1000)` 으로 **행 수를 상수로 박는다.** 적게 넣으면 `ggml_nelements(a) == ne0*ne1` 로 죽는다. FPN 계열은 후보가 많아 항상 상한을 채우지만 C4 는 레벨이 하나라 NMS 뒤 107개만 남았다. 상한까지 0 으로 채우고 **디코드는 앞 M_real 행만** 쓴다. ①을 고치자 크래시 지점이 ②로 옮겨갔다. 첫 수정 뒤에도 실패하는 것은 다음 원인이 있다는 뜻이지 앞 수정이 틀렸다는 뜻이 아니다 — 오늘만 세 번째다(convT · crowddet · 여기). --- tools/frontend/mmdet/frcnn_wrap.py | 10 +++++++++- tools/verify/backbone/run_frcnn.cpp | 21 ++++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/tools/frontend/mmdet/frcnn_wrap.py b/tools/frontend/mmdet/frcnn_wrap.py index 7a23931..ded6477 100755 --- a/tools/frontend/mmdet/frcnn_wrap.py +++ b/tools/frontend/mmdet/frcnn_wrap.py @@ -333,7 +333,15 @@ def frcnn_cfg(det, size=800): "img_size": int(size), # RPN "rpn_strides": [float(s) for s in strides], - "rpn_scale": float(scales[0]), + # ⚠️ **스케일은 목록 전체를 싣는다.** FPN RPN 은 레벨마다 스케일이 하나라 + # `scales[0]` 으로 충분했지만, C4 계열(TridentNet)은 **한 레벨에 5개**다 + # (`scales=[2,4,8,16,32]`, stride 16). 첫 개만 쓰면 앵커가 15개가 아니라 3개가 되고, + # RPN cls 채널 15개를 3개로 읽어 **엉뚱한 자리를 objectness 로 오해한다**. + # 실측: 그래서 proposal 이 전부 빗나가 최종 박스가 0건이었다. + # base_size 는 stride 로 두고(mmdet 의 `base_sizes` 기본값) 배율을 스케일 목록으로 + # 준다 — 스케일이 하나인 계열은 결과가 완전히 같다. + "rpn_scale": 1.0, + "rpn_scales": [float(v) for v in scales], "rpn_ratios": [float(r) for r in pg.ratios.tolist()], "rpn_means": [float(v) for v in bc.means], "rpn_stds": [float(v) for v in bc.stds], "rpn_nms_pre": int(rpn_c.nms_pre), "rpn_nms_thr": float(rpn_c.nms.iou_threshold), diff --git a/tools/verify/backbone/run_frcnn.cpp b/tools/verify/backbone/run_frcnn.cpp index be0e2e7..9f9dc2c 100644 --- a/tools/verify/backbone/run_frcnn.cpp +++ b/tools/verify/backbone/run_frcnn.cpp @@ -211,6 +211,8 @@ int main(int argc, char** argv) { rpn_params rp; rp.strides = J.arr("rpn_strides"); rp.octave_base_scale = J.num("rpn_scale", 8.0f); + // 한 레벨에 스케일이 여럿인 계열(C4)이 있다. 목록이 있으면 그게 정본이다. + if (std::vector sc = J.arr("rpn_scales"); !sc.empty()) rp.octave_scales = sc; rp.ratios = J.arr("rpn_ratios"); rp.nms_pre = (int)J.num("rpn_nms_pre", 1000); rp.nms_thr = J.num("rpn_nms_thr", 0.7f); @@ -221,9 +223,18 @@ int main(int argc, char** argv) { rp.cls_out_channels = (int)J.num("rpn_cls_out_channels", 1.0f); rp.input_w = rp.input_h = SZ; std::vector props = rpn_proposals(rpn_cls, rpn_box, rpn_hw, rp); - const int M = (int)(props.size() / 4); - fprintf(stderr, "[frcnn] proposal %d 개 (nms %.2f)\n", M, rp.nms_thr); - if (M == 0) { + const int M_real = (int)(props.size() / 4); + // ⚠️ **proposal 이 상한보다 적게 나오는 계열이 있다.** SubB 는 상한(rpn_max)으로 + // 구워지고 그 안의 `flatten` 이 `ggml_reshape_2d(…, 2048, 1000)` 처럼 **행 수를 + // 상수로 박는다** — 적게 넣으면 `ggml_nelements(a) == ne0*ne1` 로 죽는다. + // FPN 계열은 후보가 많아 항상 상한을 채우지만, C4 계열(TridentNet)은 레벨이 + // 하나뿐이라 NMS 뒤 107개만 남았다. 상한까지 0 으로 채우고 **디코드는 앞 + // `M_real` 행만** 쓴다(0 박스는 RoIAlign 이 레벨 0 의 (0,0) 을 읽을 뿐 무해하다). + const int M = std::max(M_real, rp.max_per_img); + props.resize((size_t)M * 4, 0.0f); + fprintf(stderr, "[frcnn] proposal %d 개 (상한 %d 까지 채움, nms %.2f)\n", + M_real, M, rp.nms_thr); + if (M_real == 0) { fprintf(stderr, "proposal 이 0 개다 — RPN 출력/규약 확인\n"); return 4; } @@ -489,7 +500,7 @@ int main(int argc, char** argv) { } struct cand { float x1, y1, x2, y2, score; int roi; }; std::vector cs; - for (int i = 0; i < M; ++i) { + for (int i = 0; i < M_real; ++i) { const float px1 = rois[(size_t)i * 4 + 0], py1 = rois[(size_t)i * 4 + 1]; const float px2 = rois[(size_t)i * 4 + 2], py2 = rois[(size_t)i * 4 + 3]; const float pw = px2 - px1, ph = py2 - py1; @@ -596,7 +607,7 @@ int main(int argc, char** argv) { rp2.input_h = SZ; // 마지막 단계의 박스는 그 단계에 **들어간** RoI 기준이다. 캐스케이드에서 `rois` 는 // 이미 다음 단계용으로 갱신되지 않으므로(마지막 단계는 정제를 건너뛴다) 그대로 쓴다. - dets = detect_roi(prob.data(), box_st[last].data(), rois.data(), M, rp2); + dets = detect_roi(prob.data(), box_st[last].data(), rois.data(), M_real, rp2); } #if defined(ARCH_C) && defined(ARCH_D) From 0ee67aa519d8326a857bf78579cf9728bedb94f3 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 18 Aug 2026 15:54:37 +0900 Subject: [PATCH 70/89] =?UTF-8?q?docs:=20tridentnet=200.09px=20=E2=80=94?= =?UTF-8?q?=2061=EA=B3=84=EC=97=B4,=20=EB=AF=B8=EB=B6=84=EB=A5=98=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EB=B9=84=EC=97=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit two-stage 29계열. '미분류' 로 남아 있던 tridentnet 이 C4 전용 두 겹(한 레벨 5스케일 · proposal 이 상한 미달)이었음을 적었다. 안 맞는 계열은 11개, 네 갈래다. --- docs/mmdet-detectors.md | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 10a6150..4c23d17 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -288,8 +288,8 @@ within 0.1 px, and the pre-decode tensors are at relative L1 1.7e-03 on the boxe ## What decodes to boxes -**Sixty families produce the same boxes MMDetection does** — thirty-two single-stage -below, twenty-eight two-stage further down. Assembling a head and decoding its output are +**Sixty-one families produce the same boxes MMDetection does** — thirty-two single-stage +below, twenty-nine two-stage further down. Assembling a head and decoding its output are separate steps, and a family can pass the first and fail the second. The runner picks a decoder from what the box prediction *is* — a delta against an anchor, a distance from a grid point, a normalised `cxcywh` query — not from the shape of the tower that produced it. YOLOX and RPN build the same tower as RetinaNet and decode @@ -365,7 +365,7 @@ file, so running from anywhere else fails to find it and the family looks broken Two-stage families are measured separately, at 800 and against the detector's own `predict` rather than a head's `predict_by_feat`, because the boxes do not exist until RPN proposals, RoIAlign and the RoI head have run. The harness is `tools/verify/roi/verify_postproc_roi.py` -and the thresholds are the same. Twenty-eight of the forty families with a `roi_head` agree: +and the thresholds are the same. Twenty-nine of the forty families with a `roi_head` agree: | Family | Decoder | Worst box | Worst score | | :--- | :--- | ---: | ---: | @@ -377,6 +377,7 @@ and the thresholds are the same. Twenty-eight of the forty families with a `roi_ | `crowddet` | `detect_roi` (set-NMS, 2 instances) | 0.07 px | 0.0001 | | `ms_rcnn` | `detect_roi` (+ mask-IoU rescoring) | 0.07 px | 0.0015 | | `gn+ws` | `detect_roi` | 0.08 px | 0.0008 | +| `tridentnet` | `detect_roi` (C4, no neck) | 0.09 px | 0.0013 | | `cascade_rcnn` | `detect_roi` (3 stages) | 0.09 px | 0.0025 | | `gn` | `detect_roi` | 0.09 px | 0.0003 | | `empirical_attention` | `detect_roi` | 0.09 px | 0.0003 | @@ -410,7 +411,7 @@ made the failure visible. Treat the old number as unverified rather than wrong. below P5, where 800 stops dividing evenly — a 25-wide map meets a 26-wide one and the export aborts. That is a property of the resolution, not of the family. -The twelve that do not agree split four ways, and the split matters more than the count: +The eleven that do not agree split four ways, and the split matters more than the count: - **The RPN is not a standard anchor RPN**, so host `rpn_proposals` cannot lay down the priors: `cascade_rpn` refines across stages, `guided_anchoring` predicts anchor shapes, and @@ -471,9 +472,16 @@ The twelve that do not agree split four ways, and the split matters more than th longer, and separately the shifted-window attention mask is built by slice assignment that tracing drops, which silently zeroes the mask instead of crashing. -`tridentnet` is the one family that remains unsorted: the runner returns no boxes at all. It -is also the only C4 detector here — no FPN neck — so the level assignment host RoIAlign -performs has nothing to choose between. +Nothing is left unsorted. `tridentnet` used to return no boxes at all, and it took two +different C4-only faults to explain that. Its RPN puts five scales on **one** level +(`scales=[2,4,8,16,32]`, stride 16) where an FPN RPN puts one scale on each of five, so +reading only `scales[0]` built three anchors instead of fifteen and then read a +fifteen-channel objectness map as if it had three — every proposal landed somewhere else. +Fixing that moved the crash rather than removing it: with one level, NMS left 107 proposals +where the RoI graph had been compiled for 1000, and the `flatten` inside it bakes the row +count into a reshape. Proposals are now padded up to the cap and only the real rows are +decoded. A first fix that does not make the symptom go away usually means a second cause, +not a wrong first fix. `rpn` decodes proposals rather than detections, so a worst case over the whole set says little: of 185 proposals the median is 0.09 px and 182 are within 1 px, with one proposal of 186 From 6ca9eaffc2deba2df3409339e41b56abe9264a70 Mon Sep 17 00:00:00 2001 From: eunchae Date: Wed, 19 Aug 2026 07:59:23 +0900 Subject: [PATCH 71/89] =?UTF-8?q?docs:=20condinst=200.19px=20=E2=80=94=206?= =?UTF-8?q?2=EA=B3=84=EC=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 커버리지 정산에서 나왔다. 표들은 하네스가 '돌리는' 74계열을 분류하는데 configs/ 아래는 100이다. 차집합 26 중 24는 정당한 제외였고(트래커 7·마스크 전용 5· 텍스트 조건부 3·이미 분류 5·reid·백본 예제 2) condinst·boxinst 둘만 이유가 없었다. 둘 다 이미 verify_heads.py 손목록에 있었고 체크포인트도 받아져 있었다 — 아무도 안 돌렸다. condinst 는 코드 변경 0으로 통과한다. CondInstBboxHead 가 FCOSHead 를 상속하고 박스 경로를 안 건드린다. controller conv(169채널)와 param_pred/points/strides 는 전부 마스크 갈래로 간다. 하네스가 kind fcos 로 자동 판정했다. boxinst 는 BoxInstDataPreprocessor.__init__ 의 무조건 raise 에 막혔다. skimage 를 실제로 쓰는 자리는 if training: 안 하나뿐이라 추론엔 안 쓴다 — 계열 한계가 아니다. panoptic_fpn 문단은 '환경' 대신 '인터프리터'로 고쳤다. venv 가 둘이고 상보적으로 깨져 있어 어느 파이썬이냐가 답을 결정한다. 지연 검사라 import 로는 못 가른다. Co-Authored-By: Claude Opus 5 (1M context) --- docs/mmdet-detectors.md | 57 +++++++++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 4c23d17..a9adb40 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -288,7 +288,7 @@ within 0.1 px, and the pre-decode tensors are at relative L1 1.7e-03 on the boxe ## What decodes to boxes -**Sixty-one families produce the same boxes MMDetection does** — thirty-two single-stage +**Sixty-two families produce the same boxes MMDetection does** — thirty-three single-stage below, twenty-nine two-stage further down. Assembling a head and decoding its output are separate steps, and a family can pass the first and fail the second. The runner picks a decoder from what the box prediction *is* — a delta against an anchor, a distance from a grid point, a normalised `cxcywh` query — not from the @@ -310,6 +310,7 @@ no label mismatch and no difference in how many boxes survive. | `efficientnet` | `detect_anchor` | 0.15 px | 0.005 | | `pisa` | `detect_anchor` | 0.18 px | 0.005 | | `nas_fcos` | `detect_fcos` | 0.19 px | 0.001 | +| `condinst` | `detect_fcos` (mask branch ignored) | 0.19 px | 0.003 | | `gfl` | `detect_fcos` | 0.23 px | 0.004 | | `yolo` | `detect_yolov3` | 0.23 px | 0.005 | | `reppoints` | `detect_fcos` (xyxy offset) | 0.25 px | 0.006 | @@ -362,6 +363,33 @@ selected by the `with_score_voting` attribute and not by the class name — `lad `teacher_config='configs/gfl/...'`, relative to the working directory rather than to the config file, so running from anywhere else fails to find it and the family looks broken. +`condinst` was added without writing a line of code, and finding it was an accounting exercise +rather than an engineering one. Every table in this chapter classifies the families the two +harnesses *run* — thirty-four single-stage plus forty two-stage — but `configs/` holds a +hundred. Subtracting gives twenty-six that neither harness has ever touched, and a family that +is never run cannot appear as a failure. Most of the twenty-six are legitimately outside the +question: seven trackers that wrap a detector rather than being one, five mask-only families +that emit no boxes at all, the three text-conditioned families, `reid`, and the five already +discussed above. Two were not: `condinst` and `boxinst` both have an ordinary box branch, and +both were already listed in `verify_heads.py` with their checkpoints downloaded. Nobody had +run them. + +`condinst` passes because `CondInstBboxHead` extends `FCOSHead` and leaves the box path alone. +Its `forward_single` adds a fourth branch — a `controller` convolution predicting 169 mask +parameters — and its `_predict_by_feat_single` carries `param_pred`, `points` and `strides` +alongside the boxes, but every one of those feeds the mask head. The decode is the FCOS decode: +`DistancePointBBoxCoder` against grid points, sigmoid centerness as the score factor, +`filter_scores_and_topk`, the standard `_bbox_post_process`. The harness classified it as +`kind fcos` on its own and `detect_fcos` was already right. + +`boxinst` should follow for free — `BoxInstBboxHead` subclasses `CondInstBboxHead` and +overrides neither `forward_single` nor either `predict_by_feat`, so it runs the decoder above +verbatim; only `num_params` changes, from 169 to 593. It is blocked instead by +`BoxInstDataPreprocessor.__init__`, which raises unconditionally when `scikit-image` is absent. +The only use of `skimage` in that class is inside an `if training:` branch, computing LAB colour +similarity for the pseudo-masks that box-supervised *training* needs; inference never reaches +it. That is a constructor guard on a training-only dependency, not a limit of the family. + Two-stage families are measured separately, at 800 and against the detector's own `predict` rather than a head's `predict_by_feat`, because the boxes do not exist until RPN proposals, RoIAlign and the RoI head have run. The harness is `tools/verify/roi/verify_postproc_roi.py` @@ -399,13 +427,26 @@ and the thresholds are the same. Twenty-nine of the forty families with a `roi_h | `dcn` | `detect_roi` | 0.37 px | 0.0002 | | `instaboost` | `detect_roi` | 0.39 px | 0.0079 | -`panoptic_fpn` is not in the table any more, and the reason is worth stating plainly: it -cannot be measured in this environment, because `panopticapi` is not installed and -`init_detector` builds the dataset pipeline before it builds the model. It was previously -recorded at 0.04 px. That number came from a run whose export step had already failed — the -harness checked only that `frcnn.json` existed, so a file left by an earlier run carried it -through. The harness now deletes each stage's outputs before that stage runs, which is what -made the failure visible. Treat the old number as unverified rather than wrong. +`panoptic_fpn` is not in the table any more, and the reason is worth stating precisely: the +interpreter the harness runs under does not have `panopticapi`, and `init_detector` builds the +dataset pipeline before it builds the model. Naming the interpreter matters more than naming +the environment, because two virtualenvs on this machine disagree: the one the harness uses has +a working `import mmdet.models` but no `panopticapi`, while the other has `panopticapi` and +cannot import `mmdet.models` at all — a stale `mmpretrain` install makes its +`reid_data_preprocessor` raise `TypeError` at class-definition time. The harness resolves child +processes through `sys.executable`, so which Python starts it decides the answer. + +Checking this needs the failing call, not an import. `import panopticapi` and even +`import mmdet.datasets.coco_panoptic` both succeed without the package, because the check is +deferred to `LoadPanopticAnnotations.__init__` (`mmdet/datasets/transforms/loading.py:572`). +The discriminating command is +`python -c "from mmdet.datasets.transforms.loading import LoadPanopticAnnotations as L; L()"`. + +`panoptic_fpn` was previously recorded at 0.04 px. That number came from a run whose export +step had already failed — the harness checked only that `frcnn.json` existed, so a file left by +an earlier run carried it through. The harness now deletes each stage's outputs before that +stage runs, which is what made the failure visible. Treat the old number as unverified rather +than wrong. `fpg` is measured at 1024 rather than 800, and the reason is worth stating: it builds levels below P5, where 800 stops dividing evenly — a 25-wide map meets a 26-wide one and the export From 22cd3ce186752eb866e28ace7896daf37eac2c9c Mon Sep 17 00:00:00 2001 From: eunchae Date: Wed, 19 Aug 2026 08:18:37 +0900 Subject: [PATCH 72/89] =?UTF-8?q?docs:=20tood=200.63px=20=C2=B7=20deformab?= =?UTF-8?q?le=5Fdetr=200.26px=20=E2=80=94=2064=EA=B3=84=EC=97=B4,=20dyhead?= =?UTF-8?q?=20=EB=8A=94=20=EB=94=94=EC=BD=94=EB=8D=94=EB=A1=9C=20=EC=A2=81?= =?UTF-8?q?=ED=98=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '디코드 전에 이미 갈린다' 로 묶여 있던 셋을 컴파일러 수정 뒤에 다시 쟀다. 둘은 코드 변경 없이 그냥 통과했다 — tood 0.63px · deformable_detr 0.26px. 기록된 실패는 그때의 나무지 지금의 나무가 아니다. dyhead 는 여전히 안 맞지만 자리가 다르다. 실제 이미지에서 층별로 재니 넥 2e-03 · head 1e-04 로 맞고 박스만 112px 어긋난다(점수는 네 자리까지 동일). 러너의 head 덤프를 mmdet predict_by_feat 에 그대로 먹이면 mmdet 박스가 나온다 — 호스트 디코더 말고는 남는 자리가 없다. coder 상수·strides· center_offset·octave_base_scale 은 config 와 일치하고 같은 앵커 생성기를 쓰는 atss 는 0.14px 로 통과한다. ⚠️ 손으로 재잴 때 짝을 조심하라. tood 를 처음 재쟀을 때 13.89px 가 나왔는데 결함이 아니라 내 짝짓기 실수였다 — verify_heads 는 손목록의 anchor-free config 로 내보내는데 mmdet_families.resolve() 는 anchor-based 를 준다. 짝은 하네스에서 받아야 한다. Co-Authored-By: Claude Opus 5 (1M context) --- docs/mmdet-detectors.md | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index a9adb40..34fccfe 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -288,7 +288,7 @@ within 0.1 px, and the pre-decode tensors are at relative L1 1.7e-03 on the boxe ## What decodes to boxes -**Sixty-two families produce the same boxes MMDetection does** — thirty-three single-stage +**Sixty-four families produce the same boxes MMDetection does** — thirty-five single-stage below, twenty-nine two-stage further down. Assembling a head and decoding its output are separate steps, and a family can pass the first and fail the second. The runner picks a decoder from what the box prediction *is* — a delta against an anchor, a distance from a grid point, a normalised `cxcywh` query — not from the @@ -318,6 +318,7 @@ no label mismatch and no difference in how many boxes survive. | `conditional_detr` | `detect_detr` | 0.27 px | 0.002 | | `dab_detr` | `detect_detr` | 0.28 px | 0.001 | | `dino` | `detect_detr` | 0.28 px | 0.004 | +| `deformable_detr` | `detect_detr` | 0.26 px | 0.002 | | `autoassign` | `detect_fcos` | 0.30 px | 0.006 | | `ld` | `detect_fcos` | 0.30 px | 0.006 | | `fcos` | `detect_fcos` | 0.32 px | 0.004 | @@ -331,6 +332,7 @@ no label mismatch and no difference in how many boxes survive. | `pvt` | `detect_anchor` (PVT-Tiny) | 0.55 px | 0.005 | | `sabl` | `detect_sabl` (buckets) | 0.55 px | 0.003 | | `fsaf` | `detect_anchor` (TBLR coder) | 0.56 px | 0.003 | +| `tood` | `detect_tood` (decoded in-graph) | 0.63 px | 0.003 | | `rtmdet` | `detect_fcos` | 0.68 px | 0.002 | | `lad` | `detect_paa` (score voting) | 0.74 px | 0.001 | | `nas_fpn` | `detect_anchor` | 1.04 px | 0.004 | @@ -531,12 +533,33 @@ box likewise on the boundary. Three groups do not decode, and they fail for different reasons: -- **Something before the decoder already disagrees with torch**, so there is nothing to judge - the decoder against: `tood` (the box branch blows up while the class branch matches at - 2e-3), `deformable_detr`, and `dyhead` (its neck is already at 0.7 relative L1, so the head - never had a chance). `MMDET_DUMP_HEAD` writes the neck output beside the head output for - exactly this split — a family whose `feat` dumps match and whose `cls`/`box` dumps do not is - a head problem, and the reverse is a compiler problem. +- **Something before the decoder already disagrees with torch.** This group is now empty, and + emptying it took no new code — the three families recorded here (`tood`, `deformable_detr`, + `dyhead`) were re-measured after the compiler fixes landed, and two of them simply passed: + `tood` at 0.63 px and `deformable_detr` at 0.26 px. The note that `tood`'s "box branch blows + up" and that `dyhead`'s neck sat at 0.7 relative L1 described a tree that no longer exists. + Re-running a recorded failure after unrelated fixes is cheaper than reading it. + + `dyhead` still disagrees, but not where this entry claimed. Measured level by level on the + real image, its neck agrees at 2e-03 relative L1 and its head at 1e-04; the boxes are still + 112 px out with the score identical to four digits. Feeding the runner's own head dumps into + MMDetection's `predict_by_feat` returns MMDetection's box (66.9, 136.2, 468.1, 474.6 at + 0.4471) rather than the runner's, which places the fault squarely in the host decoder and + nowhere else. It is not the coder constants — `target_stds` (0.1, 0.1, 0.2, 0.2), strides, + `center_offset` and `octave_base_scale` all match the config, and `atss` passes at 0.14 px + with the same anchor generator. A constant shift with an exact score means the right cell won + and was placed at the wrong pixel. + + `MMDET_DUMP_HEAD` writes the neck output beside the head output for exactly this split — a + family whose `feat` dumps match and whose `cls`/`box` dumps do not is a head problem, and the + reverse is a compiler problem. When both match, as here, what is left is the decoder. + +- **Beware the pairing when you re-measure by hand.** `tood` first re-measured at 13.89 px with + a count mismatch, which looked like a live defect and was not: `verify_heads.py` exports from + its hand-written override list (`tood_r50_fpn_1x_coco.py`, the anchor-free variant) while the + config that `mmdet_families.resolve()` returns is the anchor-based one. Compiling from one and + judging against the other compares a graph with someone else's weights. Take the pair from the + harness, never from the config directory. - **The family post-processes its own way.** This group is down to `yolact`, and it is a boundary case rather than a decode failure: fast NMS is implemented and the boxes agree to 0.74 px, but one box sits either side of the harness's own 0.30 cut (mmdet 0.3013, compiled From 82d329e1e899e06ea0a54126811180daf990df48 Mon Sep 17 00:00:00 2001 From: eunchae Date: Wed, 19 Aug 2026 08:50:35 +0900 Subject: [PATCH 73/89] =?UTF-8?q?docs:=20boxinst=200.25px=20=E2=80=94=2065?= =?UTF-8?q?=EA=B3=84=EC=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scikit-image 설치가 전부였다. 코드는 한 줄도 안 고쳤다 — BoxInstBboxHead 는 CondInstBboxHead 의 디코더를 재정의 없이 그대로 탄다. 막고 있던 건 BoxInstDataPreprocessor.__init__ 의 무조건 raise 였고, skimage 를 쓰는 자리는 if training: 안 하나뿐이라 추론은 거기 닿지도 않는다. 학습 전용 의존성에 걸린 생성자 가드는 결과표에서 '미지원 아키텍처' 와 똑같이 보인다 — 둘은 다른 칸에 적어야 한다. Co-Authored-By: Claude Opus 5 (1M context) --- docs/mmdet-detectors.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 34fccfe..b92cca3 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -288,7 +288,7 @@ within 0.1 px, and the pre-decode tensors are at relative L1 1.7e-03 on the boxe ## What decodes to boxes -**Sixty-four families produce the same boxes MMDetection does** — thirty-five single-stage +**Sixty-five families produce the same boxes MMDetection does** — thirty-six single-stage below, twenty-nine two-stage further down. Assembling a head and decoding its output are separate steps, and a family can pass the first and fail the second. The runner picks a decoder from what the box prediction *is* — a delta against an anchor, a distance from a grid point, a normalised `cxcywh` query — not from the @@ -313,6 +313,7 @@ no label mismatch and no difference in how many boxes survive. | `condinst` | `detect_fcos` (mask branch ignored) | 0.19 px | 0.003 | | `gfl` | `detect_fcos` | 0.23 px | 0.004 | | `yolo` | `detect_yolov3` | 0.23 px | 0.005 | +| `boxinst` | `detect_fcos` (mask branch ignored) | 0.25 px | 0.001 | | `reppoints` | `detect_fcos` (xyxy offset) | 0.25 px | 0.006 | | `retinanet` | `detect_anchor` | 0.27 px | 0.000 | | `conditional_detr` | `detect_detr` | 0.27 px | 0.002 | @@ -384,13 +385,15 @@ alongside the boxes, but every one of those feeds the mask head. The decode is t `filter_scores_and_topk`, the standard `_bbox_post_process`. The harness classified it as `kind fcos` on its own and `detect_fcos` was already right. -`boxinst` should follow for free — `BoxInstBboxHead` subclasses `CondInstBboxHead` and +`boxinst` followed for free, at 0.25 px — `BoxInstBboxHead` subclasses `CondInstBboxHead` and overrides neither `forward_single` nor either `predict_by_feat`, so it runs the decoder above -verbatim; only `num_params` changes, from 169 to 593. It is blocked instead by -`BoxInstDataPreprocessor.__init__`, which raises unconditionally when `scikit-image` is absent. -The only use of `skimage` in that class is inside an `if training:` branch, computing LAB colour -similarity for the pseudo-masks that box-supervised *training* needs; inference never reaches -it. That is a constructor guard on a training-only dependency, not a limit of the family. +verbatim; only `num_params` changes, from 169 to 593. What had blocked it was not the family +but `BoxInstDataPreprocessor.__init__`, which raises unconditionally when `scikit-image` is +absent. The only use of `skimage` in that class is inside an `if training:` branch, computing +LAB colour similarity for the pseudo-masks that box-supervised *training* needs; inference +never reaches it. Installing the package was the whole fix — no code changed. A constructor +guard on a training-only dependency reads exactly like an unsupported architecture in a results +table, and the two deserve different columns. Two-stage families are measured separately, at 800 and against the detector's own `predict` rather than a head's `predict_by_feat`, because the boxes do not exist until RPN proposals, From a04e7399c7ff05852f0c716989e8bbc0d4332699 Mon Sep 17 00:00:00 2001 From: eunchae Date: Wed, 19 Aug 2026 08:56:31 +0900 Subject: [PATCH 74/89] =?UTF-8?q?docs:=20panoptic=5Ffpn=200.04px=20?= =?UTF-8?q?=E2=80=94=2066=EA=B3=84=EC=97=B4,=20=EC=98=9B=20=EC=88=AB?= =?UTF-8?q?=EC=9E=90=EA=B0=80=20=EB=A7=9E=EC=95=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit panopticapi 를 하네스 인터프리터에 넣고 재측정하니 0.04px 로 예전 기록과 같은 값이 나왔다. 그 숫자는 stale 산출물로 통과한 run 에서 나온 것이라 '미확인' 으로 내려 뒀었는데, '미확인' 과 '틀림' 은 다른 주장이고 측정에서 살아남은 건 하나뿐이다. 안 재고 지웠으면 맞는 숫자를 버릴 뻔했다. 막고 있던 건 '환경' 이 아니라 '인터프리터' 였다. venv 두 개가 상보적으로 깨져 있어(한쪽은 mmdet.models 가 죽고 다른 쪽은 panopticapi 가 없다) 두 세션이 같은 패키지를 두고 정반대 결론을 냈다. 둘 다 자기 인터프리터에 대해서는 옳았다. Co-Authored-By: Claude Opus 5 (1M context) --- docs/mmdet-detectors.md | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index b92cca3..e919a49 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -288,8 +288,8 @@ within 0.1 px, and the pre-decode tensors are at relative L1 1.7e-03 on the boxe ## What decodes to boxes -**Sixty-five families produce the same boxes MMDetection does** — thirty-six single-stage -below, twenty-nine two-stage further down. Assembling a head and decoding its output are +**Sixty-six families produce the same boxes MMDetection does** — thirty-six single-stage +below, thirty two-stage further down. Assembling a head and decoding its output are separate steps, and a family can pass the first and fail the second. The runner picks a decoder from what the box prediction *is* — a delta against an anchor, a distance from a grid point, a normalised `cxcywh` query — not from the shape of the tower that produced it. YOLOX and RPN build the same tower as RetinaNet and decode @@ -398,11 +398,12 @@ table, and the two deserve different columns. Two-stage families are measured separately, at 800 and against the detector's own `predict` rather than a head's `predict_by_feat`, because the boxes do not exist until RPN proposals, RoIAlign and the RoI head have run. The harness is `tools/verify/roi/verify_postproc_roi.py` -and the thresholds are the same. Twenty-nine of the forty families with a `roi_head` agree: +and the thresholds are the same. Thirty of the forty families with a `roi_head` agree: | Family | Decoder | Worst box | Worst score | | :--- | :--- | ---: | ---: | | `detectors` | `detect_roi` (SAC) | 0.03 px | 0.0006 | +| `panoptic_fpn` | `detect_roi` | 0.04 px | 0.0001 | | `dcnv2` | `detect_roi` | 0.05 px | 0.0006 | | `carafe` | `detect_roi` | 0.06 px | 0.0007 | | `hrnet` | `detect_roi` | 0.06 px | 0.0002 | @@ -432,14 +433,21 @@ and the thresholds are the same. Twenty-nine of the forty families with a `roi_h | `dcn` | `detect_roi` | 0.37 px | 0.0002 | | `instaboost` | `detect_roi` | 0.39 px | 0.0079 | -`panoptic_fpn` is not in the table any more, and the reason is worth stating precisely: the -interpreter the harness runs under does not have `panopticapi`, and `init_detector` builds the -dataset pipeline before it builds the model. Naming the interpreter matters more than naming -the environment, because two virtualenvs on this machine disagree: the one the harness uses has -a working `import mmdet.models` but no `panopticapi`, while the other has `panopticapi` and -cannot import `mmdet.models` at all — a stale `mmpretrain` install makes its +`panoptic_fpn` is back in the table, and the round trip it took is the useful part. It was +recorded at 0.04 px, then removed when the harness started deleting each stage's outputs before +that stage ran: the old run's export had failed and a stale `frcnn.json` from an earlier run had +carried it through, so the number could no longer be trusted. It was marked unverified rather +than wrong. Installing `panopticapi` and re-measuring returns **0.04 px** — the original number +was right all along. "Unverified" and "wrong" are different claims, and only one of them +survived contact with the measurement. + +What blocked the re-measurement was the interpreter, not the environment, and the distinction +matters because two virtualenvs on this machine disagreed: the one the harness uses had a +working `import mmdet.models` but no `panopticapi`, while the other had `panopticapi` and could +not import `mmdet.models` at all — a stale `mmpretrain` install makes its `reid_data_preprocessor` raise `TypeError` at class-definition time. The harness resolves child -processes through `sys.executable`, so which Python starts it decides the answer. +processes through `sys.executable`, so which Python starts it decided the answer. Two sessions +measuring the same package reached opposite conclusions, each correct about its own interpreter. Checking this needs the failing call, not an import. `import panopticapi` and even `import mmdet.datasets.coco_panoptic` both succeed without the package, because the check is @@ -447,17 +455,11 @@ deferred to `LoadPanopticAnnotations.__init__` (`mmdet/datasets/transforms/loadi The discriminating command is `python -c "from mmdet.datasets.transforms.loading import LoadPanopticAnnotations as L; L()"`. -`panoptic_fpn` was previously recorded at 0.04 px. That number came from a run whose export -step had already failed — the harness checked only that `frcnn.json` existed, so a file left by -an earlier run carried it through. The harness now deletes each stage's outputs before that -stage runs, which is what made the failure visible. Treat the old number as unverified rather -than wrong. - `fpg` is measured at 1024 rather than 800, and the reason is worth stating: it builds levels below P5, where 800 stops dividing evenly — a 25-wide map meets a 26-wide one and the export aborts. That is a property of the resolution, not of the family. -The eleven that do not agree split four ways, and the split matters more than the count: +The ten that do not agree split four ways, and the split matters more than the count: - **The RPN is not a standard anchor RPN**, so host `rpn_proposals` cannot lay down the priors: `cascade_rpn` refines across stages, `guided_anchoring` predicts anchor shapes, and From 7a3f9bb98998cb7b871cdea8201d80f84080077a Mon Sep 17 00:00:00 2001 From: eunchae Date: Wed, 19 Aug 2026 10:37:27 +0900 Subject: [PATCH 75/89] =?UTF-8?q?feat(wrapper):=20=EB=9E=98=ED=8D=BC=20?= =?UTF-8?q?=EA=B3=84=EC=97=B4=20=ED=92=80=EA=B8=B0=20=E2=80=94=20soft=5Fte?= =?UTF-8?q?acher=200.42px=20(67=EA=B3=84=EC=97=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 트래커·반지도 래퍼는 검출기를 model.detector 안에 넣고 자기는 껍데기만 갖는다. config 를 벗기는 단계는 dense_head 하네스에만 있었고 roi 하네스엔 없어서 'ConfigDict has no attribute backbone' 으로 export 에서 죽었다. **config 만 벗기면 안 된다.** 저장 접두사는 config 키가 아니라 래퍼가 만든 속성 이름이다 — 트래커는 detector., SoftTeacher 는 self.student/self.teacher 두 벌을 만들어 semi_test_cfg.predict_on 이 고른다(teacher.). 틀리면 한 텐서도 안 실리는데 load 는 조용히 성공하고 랜덤 가중치로 그래프가 구워진다 (562px · 라벨 91건 · 검출 100건=상한). ⚠️ dense_head 하네스가 정확히 그 상태였다. config 만 벗기고 체크포인트는 그대로 넘겨서, 기준값도 같은 bb.pt 에서 나오는 탓에 **랜덤끼리 일치해 PASS** 가 떴다. soft_teacher L1 1.04e-03 PASS 인데 체크포인트 일치는 0/348 이었다. 지금은 348/348 이고 L1 6.45e-03 이다 — 나빠 보이는 쪽이 정직한 숫자다. 현재 표의 계열 중엔 래퍼가 없어 발표된 수치는 무사하지만, 티켓 006(YOLOX 트래커)이 이 경로를 탄다. 접두사가 안 맞을 때는 둘을 갈라야 한다. mmdet 은 트래커용으로 **검출기만** 배포하기도 한다 — deepsort·sort·strongsort 는 이미 평평하다(backbone./neck./…). 'backbone.' 유무로 '이미 평평함'과 '추측이 틀림'을 가르고, 후자는 raise 한다. 8계열 전부 확인: 5개 벗김 · 3개 원본 그대로 · raise 0건. 그 밖에 - two_stage_families 가 래퍼 한 겹 안을 본다(40→45). 안 그러면 통과한 계열이 회귀 검사를 못 받는다. 안 재는 것이 실패보다 위험하다 - unwrap 을 in-process 로 (서브프로세스면 import mmdet 5.95초가 계열마다 붙는다) - 벗긴 .pth 를 fr/ 에 두고 조기 반환에서도 지운다 (계열당 150~500MB) - meta 보존 — init_detector 가 dataset_meta 를 읽고 없으면 COCO 80 으로 조용히 되돌아간다 (YTVIS 40 · 보행자 1 계열이 어긋난다) 회귀: two-stage 40계열 판정 변화 1건(panoptic_fpn 은 panopticapi 설치로 열린 개선) · one-stage 5계열 L1 동일 · 벗긴 .pth 잔여 0개. Co-Authored-By: Claude Opus 5 (1M context) --- docs/mmdet-detectors.md | 26 +++++++-- tools/verify/dense_head/verify_heads.py | 34 ++++++++++-- tools/verify/roi/verify_postproc_roi.py | 70 ++++++++++++++++++++++++- 3 files changed, 120 insertions(+), 10 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index e919a49..4678d4b 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -288,8 +288,8 @@ within 0.1 px, and the pre-decode tensors are at relative L1 1.7e-03 on the boxe ## What decodes to boxes -**Sixty-six families produce the same boxes MMDetection does** — thirty-six single-stage -below, thirty two-stage further down. Assembling a head and decoding its output are +**Sixty-seven families produce the same boxes MMDetection does** — thirty-six single-stage +below, thirty-one two-stage further down. Assembling a head and decoding its output are separate steps, and a family can pass the first and fail the second. The runner picks a decoder from what the box prediction *is* — a delta against an anchor, a distance from a grid point, a normalised `cxcywh` query — not from the shape of the tower that produced it. YOLOX and RPN build the same tower as RetinaNet and decode @@ -398,7 +398,9 @@ table, and the two deserve different columns. Two-stage families are measured separately, at 800 and against the detector's own `predict` rather than a head's `predict_by_feat`, because the boxes do not exist until RPN proposals, RoIAlign and the RoI head have run. The harness is `tools/verify/roi/verify_postproc_roi.py` -and the thresholds are the same. Thirty of the forty families with a `roi_head` agree: +and the thresholds are the same. Thirty-one of the forty-five families with a `roi_head` agree — +forty-five rather than forty because the harness now looks one layer inside wrapper detectors +(see below): | Family | Decoder | Worst box | Worst score | | :--- | :--- | ---: | ---: | @@ -432,6 +434,7 @@ and the thresholds are the same. Thirty of the forty families with a `roi_head` | `fpg` | `detect_roi` (at 1024) | 0.28 px | 0.0036 | | `dcn` | `detect_roi` | 0.37 px | 0.0002 | | `instaboost` | `detect_roi` | 0.39 px | 0.0079 | +| `soft_teacher` | `detect_roi` (semi-supervised wrapper) | 0.42 px | 0.0017 | `panoptic_fpn` is back in the table, and the round trip it took is the useful part. It was recorded at 0.04 px, then removed when the harness started deleting each stage's outputs before @@ -455,6 +458,23 @@ deferred to `LoadPanopticAnnotations.__init__` (`mmdet/datasets/transforms/loadi The discriminating command is `python -c "from mmdet.datasets.transforms.loading import LoadPanopticAnnotations as L; L()"`. +`soft_teacher` is the first family measured through a **wrapper**. Trackers and semi-supervised +detectors are not detectors themselves: they put one inside `model.detector` and keep only their +own machinery at the top level, so reading `model.backbone` raises +`'ConfigDict' object has no attribute 'backbone'` and the export stops. Unwrapping the config +fixes the export, and the harness now also looks one layer inside when it decides which families +are two-stage — otherwise a family that passes is never swept again. + +Unwrapping the config is only half of it, and the other half fails silently. The checkpoint is +keyed by the **attribute name the wrapper class created**, which is not the config key: both +write `model.detector`, but `SoftTeacher` builds `self.student` and `self.teacher`, so its +weights are stored under `teacher.` / `student.` and the config decides which one inference uses +(`semi_test_cfg.predict_on`). Stripping `detector.` from such a checkpoint matches nothing, +`load_state_dict` reports it and carries on, and the graph is built on random weights — which +reads as a decode bug, not a loading bug: boxes 562 px out, 91 labels wrong, 100 detections +against MMDetection's 5. Check the count of unloaded tensors, not whether loading raised. +Trackers do use `detector.`; only the semi-supervised wrapper differs. + `fpg` is measured at 1024 rather than 800, and the reason is worth stating: it builds levels below P5, where 800 stops dividing evenly — a 25-wide map meets a 26-wide one and the export aborts. That is a property of the resolution, not of the family. diff --git a/tools/verify/dense_head/verify_heads.py b/tools/verify/dense_head/verify_heads.py index 04e098f..f00e99e 100644 --- a/tools/verify/dense_head/verify_heads.py +++ b/tools/verify/dense_head/verify_heads.py @@ -537,13 +537,37 @@ def one(fam, rel, ckpt): # `'ConfigDict' object has no attribute 'backbone'` 으로 죽는다. # 저장소에 전처리기가 이미 있다(`mmdet_unwrap_config.py`). 풀 필요 없는 config 는 # 원본을 그대로 돌려주므로 분기 없이 전부 통과시킨다. + # ⚠️ **config 만 벗기면 안 된다 — 체크포인트도 같이 벗겨야 한다.** 여기가 오래 + # 반쪽이었다: 벗긴 config 는 `backbone.…` 을 기대하는데 래퍼 체크포인트는 + # `detector.backbone.…`(트래커) 또는 `teacher.…`(반지도)로 저장돼 있다. + # 이름이 안 맞으면 **한 텐서도 안 실리는데** `load_checkpoint` 는 조용히 넘어가고, + # 이 하네스의 기준값은 그 `bb.pt` 자신이라 **랜덤 가중치끼리 일치해 PASS 가 뜬다.** + # 실제로 soft_teacher 가 L1 1.04e-03 로 통과했는데 체크포인트 일치는 0/348 이었다. + # ⚠️ **체크포인트를 먼저, config 를 나중에.** 접두사는 원본 config 의 + # `semi_test_cfg.predict_on` 이 정하는데 벗긴 config 에는 그 키가 없다. + # 하나라도 실패하면 **둘 다 원본으로 되돌린다**(짝이 어긋나는 게 더 나쁘다). if os.path.exists(UNWRAP): - ru = run([PY, UNWRAP, cfg_path, "-o", os.path.join(d, "cfg.py")], - os.path.dirname(os.path.dirname(os.path.dirname(UNWRAP))), phase="0_unwrap") - if ru is not None and ru.returncode == 0: + base = os.path.dirname(os.path.dirname(os.path.dirname(UNWRAP))) + cfg0, cw0 = cfg_path, cw + + def _unwrap(out_name, *extra): + ru = run([PY, UNWRAP, cfg0, "-o", os.path.join(d, out_name), *extra], + base, phase="0_unwrap") + if ru is None or ru.returncode != 0: + raise RuntimeError((ru.stderr or "").strip().splitlines()[-1:] or "unwrap 실패") lines = (ru.stdout or "").strip().splitlines() - if lines and os.path.exists(lines[-1].strip()): - cfg_path = lines[-1].strip() + last = lines[-1].strip() if lines else "" + if not (last and os.path.exists(last)): + raise RuntimeError(f"경로를 못 받았다: {last!r}") + return last + + try: + cw = _unwrap("ckpt.pth", "--checkpoint", cw0) + cfg_path = _unwrap("cfg.py") + except Exception as e: + cfg_path, cw = cfg0, cw0 + print(f" [unwrap] 실패 — 원본으로 되돌린다: {type(e).__name__}: {e}", + file=sys.stderr) # ⚠️ **먼저 지운다.** export 가 실패해도 지난 실행의 bb.pt/헤더가 남아 있으면 아래 존재 # 검사를 통과해 **낡은 산출물로 계속 간다** — 그러면 고친 것이 반영 안 된 채 통과/실패가 # 나온다(dab_detr 에서 실제로 겪었다: 헤더에 새 플래그가 없는데 조용히 진행됐다). diff --git a/tools/verify/roi/verify_postproc_roi.py b/tools/verify/roi/verify_postproc_roi.py index f189ee9..2a879b7 100644 --- a/tools/verify/roi/verify_postproc_roi.py +++ b/tools/verify/roi/verify_postproc_roi.py @@ -47,6 +47,17 @@ sys.path.insert(0, DH) import mmdet_families as MF # noqa: E402 +# 트래커·반지도 래퍼의 껍데기를 벗기는 전처리기. dense_head 하네스가 쓰는 것과 **같은 파일**이다 +# (`verify.toml` 의 `unwrap`). 둘이 서로 다른 것을 쓰면 같은 계열이 다르게 풀린다. +# ⚠️ **서브프로세스로 부르지 않는다.** 계열마다 파이썬을 새로 띄우면 `import mmdet` 재비용 +# (실측 5.95초)이 그대로 붙는다 — 두 번 부르니 계열당 ~5초, 40계열이면 3분이다. +# 이 하네스는 이미 부모에서 `mmengine.config` 를 쓴다(`two_stage_families()`). +sys.path.insert(0, os.path.join(G2C, "test_script", "mmdet")) +try: + import mmdet_unwrap_config as UW # noqa: E402 +except ImportError: # 전처리기가 없는 트리 + UW = None + # mmpretrain 의 blip 이 이 조합에서 import 시 죽는다 — 하위 프로세스에도 같은 우회를 심는다. STUB = '''import sys, types for n in ("mmpretrain.models.multimodal.blip", "mmpretrain.models.multimodal.blip.language_model"): @@ -166,6 +177,23 @@ def match(ref, got): def one(fam, size, image, workdir, keep, verbose): + """`_one` 을 돌리고, 벗긴 체크포인트를 **어느 경로로 끝나든** 지운다. + + ⚠️ 본문의 `rmtree(fr)` 은 **성공 경로에서만** 닿는다. EXPORT_FAIL·COMPILE_FAIL 등 + 조기 반환이 일곱 군데인데, 래퍼 계열의 벗긴 `.pth` 는 계열당 150~500MB 라 + 실패가 몇 개만 나도 스윕이 수 GB 를 남긴다. + """ + try: + return _one(fam, size, image, workdir, keep, verbose) + finally: + if not keep: + try: + os.remove(os.path.join(workdir, fam, "frcnn", f"{fam}.ckpt.pth")) + except OSError: + pass + + +def _one(fam, size, image, workdir, keep, verbose): import numpy as np t0 = time.time() d = os.path.join(workdir, fam) @@ -182,8 +210,33 @@ def one(fam, size, image, workdir, keep, verbose): if not os.path.exists(ckpt): return fam, "CKPT_MISSING", ckpt_name, None - # ① export — two-stage 를 두 subgraph 로 가른다. 여기서 죽으면 "왜" 를 그대로 옮긴다. + # ⚠️ **트래커·반지도 래퍼는 config 를 한 겹 벗겨야 한다.** `SoftTeacher`·`DeepSORT` 등은 + # 검출기를 `model.detector` 안에 넣고 자기는 껍데기(tracker/reid/semi_train_cfg)만 갖는다 + # → `'ConfigDict' object has no attribute 'backbone'` 으로 export 에서 죽는다. + # dense_head 하네스는 이미 이 단계를 갖고 있었고 여기만 없어서, 안쪽이 이미 통과한 + # 검출기인 8계열이 통째로 못 걸리고 있었다. 풀 필요 없는 config 는 원본을 그대로 + # 돌려주므로 분기 없이 전부 통과시킨다. 체크포인트도 같이 벗긴다(아래 순서 주의). fr = os.path.join(d, "frcnn") + if UW is not None: + # ⚠️ **체크포인트를 먼저, config 를 나중에.** 접두사는 **원본** config 의 + # `semi_test_cfg.predict_on` 이 정하는데 벗긴 config 에는 그 키가 없다. + # 순서를 바꾸면 한 텐서도 안 실리고, 그런데도 로드는 조용히 성공해서 + # 가중치가 랜덤인 채로 박스가 수백 px 어긋난다(soft_teacher 562px 로 겪었다). + # 산출물은 **`fr` 안에** 둔다 — 정리(`rmtree(fr)`)에 같이 쓸려 나가야 한다. + # 계열 밖에 두면 벗긴 체크포인트(계열당 150~500MB)가 실행마다 쌓인다. + # ⚠️ **둘 다 되거나 둘 다 안 되거나여야 한다.** 체크포인트만 벗기고 config 를 + # 못 벗기면(또는 반대면) 짝이 어긋나 위와 똑같은 사고가 난다. 하나라도 실패하면 + # **둘 다 원본으로 되돌린다.** + cfg0, ckpt0 = cfg, ckpt + try: + ckpt = UW.unwrap_checkpoint(cfg0, ckpt0, os.path.join(fr, f"{fam}.ckpt.pth")) + cfg = UW.unwrap_config(cfg0, os.path.join(fr, f"{fam}.cfg.py")) + except Exception as e: + cfg, ckpt = cfg0, ckpt0 + print(f" [unwrap] 실패 — 원본으로 되돌린다: {type(e).__name__}: {e}", + file=sys.stderr) + + # ① export — two-stage 를 두 subgraph 로 가른다. 여기서 죽으면 "왜" 를 그대로 옮긴다. # ⚠️ **단계마다 산출물을 먼저 지운다.** 존재 검사만 하면 이번 실행이 실패해도 지난 # 실행의 것이 남아 있어 **그대로 통과한다** — 고친 코드가 반영 안 된 숫자를 # "통과" 로 보고하게 된다. 공유 코드를 자주 고치는 날에는 이게 제일 위험하다 @@ -339,7 +392,15 @@ def one(fam, size, image, workdir, keep, verbose): def two_stage_families(): - """config 로 판정한다 — 이름으로 짐작하지 않는다. roi_head 가 있으면 two-stage.""" + """config 로 판정한다 — 이름으로 짐작하지 않는다. roi_head 가 있으면 two-stage. + + ⚠️ **래퍼는 한 겹 안을 본다.** 트래커·반지도는 `roi_head` 를 `model.detector` 안에 + 넣으므로 최상위만 보면 통째로 빠진다 — 그러면 통과한 계열이 회귀 검사를 **안 받는다.** + 안 재는 것이 실패보다 위험하다(안 재면 아무도 모른다). + + 안쪽이 one-stage 인 래퍼(YOLOX 기반 bytetrack·ocsort·strongsort)는 여기 안 걸린다. + 그건 dense_head 하네스 몫이다 — 이 함수는 `roi_head` 유무로만 가른다. + """ from mmengine.config import Config out = [] for fam, cfg_rel, _ in MF.families(CFGS): @@ -347,6 +408,11 @@ def two_stage_families(): m = Config.fromfile(os.path.join(CFGS, cfg_rel)).get("model", {}) except Exception: continue + # 안쪽 키 목록은 전처리기와 **한 곳**에서 온다. 여기 따로 적으면 한쪽이 늘 때 + # 다른 쪽이 안 따라가고, 같은 계열이 두 하네스에서 다르게 분류된다. + keys = getattr(UW, "_INNER_KEYS", ("detector",)) if UW else ("detector",) + if not m.get("roi_head") and "backbone" not in m: + m = next((m[k] for k in keys if isinstance(m.get(k), dict)), m) if m.get("roi_head"): out.append(fam) return out From f26192fdfab21e86ff6b6175623be56292c74b8f Mon Sep 17 00:00:00 2001 From: eunchae Date: Wed, 19 Aug 2026 11:09:41 +0900 Subject: [PATCH 76/89] =?UTF-8?q?feat(wrapper):=20=ED=8A=B8=EB=9E=98?= =?UTF-8?q?=EC=BB=A4=207=EA=B3=84=EC=97=B4=20=E2=80=94=2074=EA=B3=84?= =?UTF-8?q?=EC=97=B4.=20data=5Fpreprocessor=20=EA=B0=80=20=EB=9E=98?= =?UTF-8?q?=ED=8D=BC=EC=97=90=EB=A7=8C=20=EC=9E=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 004 로 경로는 뚫렸는데 deepsort 가 **어느 이미지에서도 0건**이었다. 사진 문제가 아니라 트래커 6계열이 data_preprocessor 를 **래퍼에만** 두기 때문이다 (soft_teacher 만 안쪽에 있다 — 하필 004 로 관통시킨 그 계열이 예외라 모른 채 넘어갈 뻔했다). 벗기면 정규화가 사라져 mean 0 / std 1 로 돈다. 내리면서 타입을 DetDataPreprocessor 로 바꾼다. 원본은 TrackDataPreprocessor 라 비디오 배치를 기대하는데, 벗긴 config 는 평범한 FasterRCNN 이다. 지금 경로는 det.predict 로 건너뛰어 안 터지지만 inference_detector 로 열면 죽는다. 학습 전용 batch_augments 도 뗀다. 계열별 시험 이미지는 mmdet_families.test_image() 에 뒀다(두 하네스 공유). MOT 트래커는 보행자 1클래스라 고양이 사진에서 0건이 나온다. 새 자산은 안 들였다 — 이미 있던 bench-image.jpg 로 다섯 계열 2~5건이 나온다. 결과(bench-image.jpg): deepsort 0.04 · sort 0.04 · qdtrack 0.03 · masktrack_rcnn 0.09 · bytetrack 0.06 · ocsort 0.15 · strongsort 0.25px. 입력 크기 1440x800 은 필요 없었다 — YOLOX 는 완전 합성곱이라 512 로 돈다. ⚠️ 정정: 앞선 커밋에서 '양쪽 0건이면 개수차 0 으로 조용히 통과한다' 고 적었는데 **틀렸다.** match() 가 한쪽이 비면 None 을 돌려 EMPTY 로 보고된다. 실제 대가는 헛통과가 아니라 '못 잼, 다만 보임' 이다. 주석·문서를 정정했다. 리뷰 반영: test_image 경로를 저장소 tests/input 에 고정(사용자 --image 디렉토리에서 찾으면 엉뚱한 곳을 가리킨다) · 존재 검사 후 폴백 · 명시적 --image 는 계열별 지정을 무시하고 존중 · 배너가 '계열별 지정 적용' 을 밝힘 · one-stage 하네스는 덮어쓰지 않고 권장 이미지와 다르면 경고. Co-Authored-By: Claude Opus 5 (1M context) --- docs/mmdet-detectors.md | 37 +++++++++++++++++++--- tools/verify/dense_head/mmdet_families.py | 27 ++++++++++++++++ tools/verify/dense_head/verify_postproc.py | 14 ++++++++ tools/verify/roi/verify_postproc_roi.py | 22 +++++++++++-- 4 files changed, 93 insertions(+), 7 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 4678d4b..c7378ee 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -288,8 +288,8 @@ within 0.1 px, and the pre-decode tensors are at relative L1 1.7e-03 on the boxe ## What decodes to boxes -**Sixty-seven families produce the same boxes MMDetection does** — thirty-six single-stage -below, thirty-one two-stage further down. Assembling a head and decoding its output are +**Seventy-four families produce the same boxes MMDetection does** — thirty-nine single-stage +below, thirty-five two-stage further down. Assembling a head and decoding its output are separate steps, and a family can pass the first and fail the second. The runner picks a decoder from what the box prediction *is* — a delta against an anchor, a distance from a grid point, a normalised `cxcywh` query — not from the shape of the tower that produced it. YOLOX and RPN build the same tower as RetinaNet and decode @@ -303,8 +303,10 @@ no label mismatch and no difference in how many boxes survive. | Family | Decoder | Worst box | Worst score | | :--- | :--- | ---: | ---: | | `cornernet` | `detect_corner` (embedding pairing) | 0.03 px | 0.003 | +| `bytetrack` | `detect_yolox` (tracker wrapper) | 0.06 px | 0.007 | | `ddq` | `detect_detr` (distinct queries) | 0.06 px | 0.018 | | `yolof` | `detect_anchor` (ctr_clamp) | 0.12 px | 0.002 | +| `ocsort` | `detect_yolox` (tracker wrapper) | 0.15 px | 0.041 | | `centernet` | `detect_centernet` (heatmap peaks) | 0.13 px | 0.001 | | `atss` | `detect_anchor` | 0.14 px | 0.000 | | `efficientnet` | `detect_anchor` | 0.15 px | 0.005 | @@ -314,6 +316,7 @@ no label mismatch and no difference in how many boxes survive. | `gfl` | `detect_fcos` | 0.23 px | 0.004 | | `yolo` | `detect_yolov3` | 0.23 px | 0.005 | | `boxinst` | `detect_fcos` (mask branch ignored) | 0.25 px | 0.001 | +| `strongsort` | `detect_yolox` (tracker wrapper) | 0.25 px | 0.001 | | `reppoints` | `detect_fcos` (xyxy offset) | 0.25 px | 0.006 | | `retinanet` | `detect_anchor` | 0.27 px | 0.000 | | `conditional_detr` | `detect_detr` | 0.27 px | 0.002 | @@ -398,7 +401,7 @@ table, and the two deserve different columns. Two-stage families are measured separately, at 800 and against the detector's own `predict` rather than a head's `predict_by_feat`, because the boxes do not exist until RPN proposals, RoIAlign and the RoI head have run. The harness is `tools/verify/roi/verify_postproc_roi.py` -and the thresholds are the same. Thirty-one of the forty-five families with a `roi_head` agree — +and the thresholds are the same. Thirty-five of the forty-five families with a `roi_head` agree — forty-five rather than forty because the harness now looks one layer inside wrapper detectors (see below): @@ -406,6 +409,9 @@ forty-five rather than forty because the harness now looks one layer inside wrap | :--- | :--- | ---: | ---: | | `detectors` | `detect_roi` (SAC) | 0.03 px | 0.0006 | | `panoptic_fpn` | `detect_roi` | 0.04 px | 0.0001 | +| `qdtrack` | `detect_roi` (tracker wrapper) | 0.03 px | 0.0003 | +| `deepsort` | `detect_roi` (tracker wrapper) | 0.04 px | 0.0000 | +| `sort` | `detect_roi` (tracker wrapper) | 0.04 px | 0.0000 | | `dcnv2` | `detect_roi` | 0.05 px | 0.0006 | | `carafe` | `detect_roi` | 0.06 px | 0.0007 | | `hrnet` | `detect_roi` | 0.06 px | 0.0002 | @@ -413,6 +419,7 @@ forty-five rather than forty because the harness now looks one layer inside wrap | `crowddet` | `detect_roi` (set-NMS, 2 instances) | 0.07 px | 0.0001 | | `ms_rcnn` | `detect_roi` (+ mask-IoU rescoring) | 0.07 px | 0.0015 | | `gn+ws` | `detect_roi` | 0.08 px | 0.0008 | +| `masktrack_rcnn` | `detect_roi` (tracker wrapper) | 0.09 px | 0.0012 | | `tridentnet` | `detect_roi` (C4, no neck) | 0.09 px | 0.0013 | | `cascade_rcnn` | `detect_roi` (3 stages) | 0.09 px | 0.0025 | | `gn` | `detect_roi` | 0.09 px | 0.0003 | @@ -458,7 +465,8 @@ deferred to `LoadPanopticAnnotations.__init__` (`mmdet/datasets/transforms/loadi The discriminating command is `python -c "from mmdet.datasets.transforms.loading import LoadPanopticAnnotations as L; L()"`. -`soft_teacher` is the first family measured through a **wrapper**. Trackers and semi-supervised +All eight **wrapper** families are now measured: seven trackers and one semi-supervised +detector. Trackers and semi-supervised detectors are not detectors themselves: they put one inside `model.detector` and keep only their own machinery at the top level, so reading `model.backbone` raises `'ConfigDict' object has no attribute 'backbone'` and the export stops. Unwrapping the config @@ -473,7 +481,26 @@ weights are stored under `teacher.` / `student.` and the config decides which on `load_state_dict` reports it and carries on, and the graph is built on random weights — which reads as a decode bug, not a loading bug: boxes 562 px out, 91 labels wrong, 100 detections against MMDetection's 5. Check the count of unloaded tensors, not whether loading raised. -Trackers do use `detector.`; only the semi-supervised wrapper differs. +Trackers mostly use `detector.`, but not all of them: MMDetection ships some tracker weights as +the **detector alone**, already flat (`deepsort`, `sort` and `strongsort` are keyed +`backbone.` / `neck.` / `bbox_head.`). So a prefix that matches nothing means one of two things, +and they need opposite handling — already-flat, which should be used as-is, or a wrong guess, +which must stop. Every detector has a `backbone`, and that is the signature that separates them. + +The other half of a wrapper is its `data_preprocessor`, and it is easy to drop. Six of the seven +trackers declare it on the **wrapper** and give the inner detector none (`soft_teacher` is the +exception, which is why it worked first). Unwrap without carrying it down and normalisation +disappears — mean 0, std 1 — and the detector returns nothing at all. The reference side reads +the same unwrapped config, so both sides return nothing; the harness reports `EMPTY` rather than +a pass, so the failure is visible, but the family cannot be measured until the preprocessor comes +down with it. The wrapper's preprocessor is a `TrackDataPreprocessor`, which expects video-shaped +batches, so it is rewritten to `DetDataPreprocessor` on the way down — same numbers, no trap for +whoever opens the dumped config with `inference_detector`. + +Trackers are trained on pedestrians with `num_classes=1`, so the harness picks a test image per +family (`mmdet_families.test_image`); the cat photo the other families use yields no detections +on either side, which reports as `EMPTY`. The chosen image is printed beside the numbers +whenever it differs from the default, so a zero can be told apart from a wrong photo later. `fpg` is measured at 1024 rather than 800, and the reason is worth stating: it builds levels below P5, where 800 stops dividing evenly — a 25-wide map meets a 26-wide one and the export diff --git a/tools/verify/dense_head/mmdet_families.py b/tools/verify/dense_head/mmdet_families.py index d304ba0..d0562e4 100644 --- a/tools/verify/dense_head/mmdet_families.py +++ b/tools/verify/dense_head/mmdet_families.py @@ -19,6 +19,33 @@ "scratch", "dsdl", "objects365", "lvis", "openimages", "cityscapes", "wider_face", "pascal_voc", "deepfashion", "v3det"} +# 계열별 시험 이미지. **여기 없으면 하네스의 기본 이미지를 쓴다.** +# +# ⚠️ 왜 필요한가 — MOT 트래커는 `num_classes=1`(보행자)로 학습됐다. 고양이 사진을 넣으면 +# mmdet 쪽도 우리 쪽도 **0건**이 나온다. 그러면 하네스가 `EMPTY`("한쪽이 비었다")를 +# 내므로 **조용히 통과하지는 않지만**, 그 계열을 아예 못 재게 된다. 못 잰 것을 +# "대상이 못 한다" 로 적지 않으려면 맞는 사진을 줘야 한다. +# +# `bench-image.jpg` 는 이미 저장소에 있고(새 자산·라이선스 불필요) 다섯 계열 전부에서 +# 2~5건이 나오는 것을 실측했다(2026-08-19, 800px·score>0.30): +# deepsort 2 · sort 2 · qdtrack 3 · masktrack_rcnn 5 · bytetrack 2 +_IMAGE = {f: "bench-image.jpg" for f in + ("bytetrack", "deepsort", "ocsort", "qdtrack", "sort", "strongsort", + "masktrack_rcnn")} +# 이미지는 **저장소 안**에 있다. 사용자가 준 `--image` 의 디렉토리에서 찾으면 +# 엉뚱한 곳(`~/pics/bench-image.jpg`)을 가리키고, 상대경로면 하위 프로세스의 cwd 로 풀린다. +_IMG_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), + "..", "..", "..", "tests", "input")) + + +def test_image(fam, default): + """이 계열로 잴 때 쓸 이미지 경로. 지정이 없거나 파일이 없으면 `default` 그대로.""" + name = _IMAGE.get(fam) + if not name: + return default + p = os.path.join(_IMG_DIR, name) + return p if os.path.exists(p) else default + def pick_cfgs(fam_dir): """대표 config 후보(점수순). `_` 로 시작하거나 `_base` 로 끝나는 뼈대는 뺀다. diff --git a/tools/verify/dense_head/verify_postproc.py b/tools/verify/dense_head/verify_postproc.py index f2f66ed..1386f90 100644 --- a/tools/verify/dense_head/verify_postproc.py +++ b/tools/verify/dense_head/verify_postproc.py @@ -191,6 +191,20 @@ def main(): to_rgb = "c.to_rgb = true" in txt print(f"to_rgb={to_rgb} (파라미터 헤더 기준) · thr={thr} · size={size}") + # ⚠️ **계열에 안 맞는 사진이면 크게 알린다.** MOT 트래커는 보행자 1클래스라 고양이 + # 사진에서는 양쪽 다 0건이 나오고, 그러면 "한쪽이 비어 비교 불가" 로 끝나 그 계열을 + # 아예 못 잰다. 이 도구는 이미지를 **인자로** 받으므로 덮어쓰지 않고 경고만 한다 — + # 부른 사람이 일부러 골랐을 수 있다(roi 하네스는 기본값일 때만 계열별 지정을 쓴다). + try: + import mmdet_families as _MF + fam = os.path.basename(os.path.dirname(os.path.abspath(cfg))) + want = _MF.test_image(fam, image) + if os.path.basename(want) != os.path.basename(image): + print(f" ⚠️ '{fam}' 계열 권장 이미지는 {os.path.basename(want)} 인데 " + f"{os.path.basename(image)} 를 받았다 — 0건이면 사진 탓일 수 있다") + except Exception: # 계열을 못 알아내도 계속 간다 + pass + got = cpp_boxes(gen, image, size, thr) ref = mmdet_boxes(cfg, ckpt, image, size, thr, to_rgb) print(f"\nmmdet {len(ref)}건 · run_mmdet {len(got)}건") diff --git a/tools/verify/roi/verify_postproc_roi.py b/tools/verify/roi/verify_postproc_roi.py index 2a879b7..00e2cb2 100644 --- a/tools/verify/roi/verify_postproc_roi.py +++ b/tools/verify/roi/verify_postproc_roi.py @@ -42,6 +42,7 @@ CFGS = os.path.join(MM, "configs") CKPTS = os.path.join(MM, "checkpoints") BUILD = os.environ.get("VISP_BUILD", os.path.join(V, "build")) +DEFAULT_IMAGE = os.path.join(V, "tests", "input", "cat-and-hat.jpg") PY = sys.executable sys.path.insert(0, DH) @@ -194,6 +195,12 @@ def one(fam, size, image, workdir, keep, verbose): def _one(fam, size, image, workdir, keep, verbose): + # ⚠️ **계열마다 시험 이미지가 다를 수 있다.** MOT 트래커는 보행자 1클래스라 고양이 + # 사진에서는 양쪽 다 0건이 나온다. 그때는 `match()` 가 None 을 돌려 `EMPTY` 로 + # 보고되므로 **조용히 통과하지는 않지만**, 그 계열을 아예 못 재게 된다. + # `image` 가 None 이면 기본값이고, 그때만 계열별 지정이 끼어든다. + default_image = DEFAULT_IMAGE + image = MF.test_image(fam, DEFAULT_IMAGE) if image is None else image import numpy as np t0 = time.time() d = os.path.join(workdir, fam) @@ -382,6 +389,10 @@ def _one(fam, size, image, workdir, keep, verbose): ok = worst_b < BOX_TOL and worst_s < SCORE_TOL and bad_label == 0 and n_gap == 0 note = (f"박스 {worst_b:.2f}px · 점수 {worst_s:.4f} · 라벨 {bad_label} · 개수차 {n_gap}" f" · {len(ref)}/{len(got)}건") + # 계열마다 이미지가 다를 수 있으므로 **무엇으로 쟀는지**를 숫자 옆에 남긴다. + # 안 적으면 나중에 0건이 "대상이 못 한다" 인지 "안 맞는 사진을 넣었다" 인지 못 가른다. + if os.path.basename(image) != os.path.basename(default_image): + note += f" · {os.path.basename(image)}" if verbose and rows: for r, g, db in rows: print(f" {int(r[5]):4d} [{r[0]:6.1f},{r[1]:6.1f},{r[2]:6.1f},{r[3]:6.1f}] {r[4]:.3f}" @@ -423,7 +434,11 @@ def main(): ap.add_argument("families", nargs="*") ap.add_argument("--all", action="store_true", help="two-stage 로 판정된 계열 전부") ap.add_argument("--size", type=int, default=800) - ap.add_argument("--image", default=os.path.join(V, "tests", "input", "cat-and-hat.jpg")) + # ⚠️ 기본값일 때만 계열별 이미지가 끼어든다. 사용자가 `--image` 를 **명시하면** + # 그 뜻을 존중해 전 계열에 그대로 쓴다 — 안 그러면 특정 계열에 다른 사진을 넣어 + # 볼 방법이 없다. + ap.add_argument("--image", default=None, + help="기본: tests/input/cat-and-hat.jpg. 명시하면 계열별 지정을 무시한다") ap.add_argument("--workdir", default="/tmp/visp-postproc-roi") ap.add_argument("--keep", action="store_true", help="중간 산출물(.pt·gguf·러너)을 남긴다") # ⚠️ **이미 통과한 계열을 다시 굽지 마라.** 계열 하나가 40~90초이고 그중 42% 가 g2c @@ -446,7 +461,10 @@ def main(): # 건너뛴 것을 **말한다.** 조용히 줄이면 다음 사람이 "전부 쟀다" 로 읽는다. print(f"이전 PASS {len(done)}계열 건너뜀: {' '.join(done)}\n") os.makedirs(a.workdir, exist_ok=True) - print(f"size={a.size} · image={os.path.basename(a.image)} · thr={THR}" + # 배너가 거짓말하지 않게 — 계열별 지정이 끼어들 수 있으면 그렇다고 적는다. + img_note = os.path.basename(a.image) if a.image else \ + f"{os.path.basename(DEFAULT_IMAGE)} (계열별 지정 적용)" + print(f"size={a.size} · image={img_note} · thr={THR}" f" · 판정: 박스<{BOX_TOL}px 점수<{SCORE_TOL} 라벨0 개수차0") print(f"{len(fams)}계열: {' '.join(fams)}\n") From 79d37d50da1e417dbff551075854260758428204 Mon Sep 17 00:00:00 2001 From: eunchae Date: Wed, 19 Aug 2026 13:32:22 +0900 Subject: [PATCH 77/89] =?UTF-8?q?fix(anchor):=20=EC=95=B5=EC=BB=A4=20?= =?UTF-8?q?=EC=A4=91=EC=8B=AC=EC=9D=98=20base=5Fsize=20=EB=8A=94=20stride?= =?UTF-8?q?=20=EB=8B=A4=20=E2=80=94=20dyhead=20112px=20=E2=86=92=200.21px?= =?UTF-8?q?=20(75=EA=B3=84=EC=97=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gen_anchors 는 중심을 center_offset * base_size 로 잡는데, 호출부가 base_size = stride * octave_base_scale 을 넘기고 있었다. mmdet AnchorGenerator 는 base_sizes = [min(stride)…] 로 두고 octave_base_scale 은 scales 쪽에 넣는다. 앵커 **크기**는 어느 쪽으로 접든 같아서 shape 검사에 안 걸린다. **중심**만 달라진다: stride 32 · obs 8 이면 mmdet 16 vs 우리 128 = 정확히 112px. center_offset 이 0 인 계열(retinanet·atss·gfl…)은 양쪽 다 0 이라 안 드러난다. 전 계열을 훑어 노출된 것은 dyhead·glip 둘뿐이다 — 계열 하나가 앵커 디코더 전체의 결함을 혼자 지고 있었던 셈이다. 회귀: atss 0.14 · fsaf 0.56 · ghm 0.46 · ssd 0.42 · yolof 0.12 · retinanet 0.27px — 전부 기록값과 소수점까지 동일. 그리고 짝짓기 가드를 넣었다. 오늘 두 번(tood 13.89px · retinanet 20.02px) 손으로 고른 짝 때문에 멀쩡한 코드를 결함으로 오해했다. verify_heads 가 used.json 에 구울 때 쓴 config·checkpoint 를 남기고, verify_postproc 가 읽어 다르면 경고한다. retinanet r18 vs r50-caffe 로 실제 잡히는 것을 확인했다. Co-Authored-By: Claude Opus 5 (1M context) --- docs/mmdet-detectors.md | 23 ++++++++++++---------- src/visp/postproc.cpp | 15 ++++++++++++-- tools/verify/dense_head/verify_heads.py | 11 +++++++++++ tools/verify/dense_head/verify_postproc.py | 21 ++++++++++++++++++++ 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index c7378ee..d1a6816 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -288,7 +288,7 @@ within 0.1 px, and the pre-decode tensors are at relative L1 1.7e-03 on the boxe ## What decodes to boxes -**Seventy-four families produce the same boxes MMDetection does** — thirty-nine single-stage +**Seventy-five families produce the same boxes MMDetection does** — forty single-stage below, thirty-five two-stage further down. Assembling a head and decoding its output are separate steps, and a family can pass the first and fail the second. The runner picks a decoder from what the box prediction *is* — a delta against an anchor, a distance from a grid point, a normalised `cxcywh` query — not from the @@ -309,6 +309,7 @@ no label mismatch and no difference in how many boxes survive. | `ocsort` | `detect_yolox` (tracker wrapper) | 0.15 px | 0.041 | | `centernet` | `detect_centernet` (heatmap peaks) | 0.13 px | 0.001 | | `atss` | `detect_anchor` | 0.14 px | 0.000 | +| `dyhead` | `detect_anchor` (center_offset 0.5) | 0.21 px | 0.000 | | `efficientnet` | `detect_anchor` | 0.15 px | 0.005 | | `pisa` | `detect_anchor` | 0.18 px | 0.005 | | `nas_fcos` | `detect_fcos` | 0.19 px | 0.001 | @@ -592,15 +593,17 @@ Three groups do not decode, and they fail for different reasons: up" and that `dyhead`'s neck sat at 0.7 relative L1 described a tree that no longer exists. Re-running a recorded failure after unrelated fixes is cheaper than reading it. - `dyhead` still disagrees, but not where this entry claimed. Measured level by level on the - real image, its neck agrees at 2e-03 relative L1 and its head at 1e-04; the boxes are still - 112 px out with the score identical to four digits. Feeding the runner's own head dumps into - MMDetection's `predict_by_feat` returns MMDetection's box (66.9, 136.2, 468.1, 474.6 at - 0.4471) rather than the runner's, which places the fault squarely in the host decoder and - nowhere else. It is not the coder constants — `target_stds` (0.1, 0.1, 0.2, 0.2), strides, - `center_offset` and `octave_base_scale` all match the config, and `atss` passes at 0.14 px - with the same anchor generator. A constant shift with an exact score means the right cell won - and was placed at the wrong pixel. + `dyhead` closed too, and where it hid is worth keeping. Its neck agreed at 2e-03 relative L1 + and its head at 1e-04, yet the boxes were 112 px out with the score identical to four digits — + the right cell won and was placed at the wrong pixel. The cause was in anchor generation: + `AnchorGenerator` keeps `base_sizes` equal to the strides and puts `octave_base_scale` into + `scales`, but the host folded the two together into `base_size = stride * octave_base_scale`. + Anchor *sizes* come out the same either way, which is why no shape check ever complained; the + *centre* does not, because it is `center_offset * base_size`. At stride 32 with + `octave_base_scale` 8 that is 128 against MMDetection's 16 — exactly the 112 px observed. + Families with `center_offset` 0 (retinanet, atss, gfl, …) are immune, since both readings give + zero, so a single family carried the defect for the whole anchor decoder. Only `dyhead` and + `glip` set it non-zero. Now 0.21 px, with the anchor families re-measured unchanged. `MMDET_DUMP_HEAD` writes the neck output beside the head output for exactly this split — a family whose `feat` dumps match and whose `cls`/`box` dumps do not is a head problem, and the diff --git a/src/visp/postproc.cpp b/src/visp/postproc.cpp index 07c07b7..1c6f0a6 100755 --- a/src/visp/postproc.cpp +++ b/src/visp/postproc.cpp @@ -138,8 +138,19 @@ static std::vector level_anchors(det_params const& p, int l, int fh, int return a; } num_base = (int)(p.octave_scales.size() * p.ratios.size()); - return gen_anchors(fh, fw, stride, stride * p.octave_base_scale, - p.octave_scales, p.ratios, p.center_offset); + // ⚠️ **`base_size` 는 stride 다 — `stride * octave_base_scale` 이 아니다.** + // mmdet `AnchorGenerator` 는 `base_sizes = [min(stride) …]` 로 두고 + // `octave_base_scale` 은 **`scales` 쪽**에 넣는다(`scales = obs * octave_scales`). + // 앵커 **크기**는 어느 쪽으로 접든 같지만 **중심**이 달라진다: + // mmdet center = center_offset * stride + // 접은 식 center = center_offset * stride * octave_base_scale + // `center_offset` 이 0 인 계열(retinanet·atss·gfl…)은 양쪽 다 0 이라 안 드러나고, + // 0.5 인 계열에서만 나온다 — stride 32 · obs 8 이면 **정확히 112px** 어긋난다. + // 노출된 계열은 `dyhead`·`glip` 둘뿐이다(2026-08-19 전 계열 확인). + std::vector scales; + scales.reserve(p.octave_scales.size()); + for (float s : p.octave_scales) scales.push_back(s * p.octave_base_scale); + return gen_anchors(fh, fw, stride, stride, scales, p.ratios, p.center_offset); } // ── 앵커-기반 검출 후처리 (mmdet _predict_by_feat_single) ──────────────────── diff --git a/tools/verify/dense_head/verify_heads.py b/tools/verify/dense_head/verify_heads.py index f00e99e..c2b48e0 100644 --- a/tools/verify/dense_head/verify_heads.py +++ b/tools/verify/dense_head/verify_heads.py @@ -584,6 +584,17 @@ def _unwrap(out_name, *extra): ph = os.path.join(d, "bb.postproc.h") if not os.path.exists(ph): return fam, "PARAMS_NONE", "postproc.h 미생성 (head_type=raw?)" + # ⚠️ **어떤 짝으로 구웠는지 남긴다.** 계열마다 변종이 여럿이라(retinanet r18 vs r50-caffe, + # tood anchor-free vs anchor-based) 나중에 `verify_postproc.py` 를 손으로 부를 때 + # 다른 짝을 주기 쉽다. 그러면 **남의 가중치를 진 그래프**를 대조하게 되고, 결과가 + # 그럴듯하게 틀려서(20px·13px) 결함으로 오해한다 — 오늘만 두 번 겪었다. + # `verify_postproc.py` 가 이 파일을 읽어 어긋나면 알린다. + try: + import json as _json + _json.dump({"family": fam, "config": cfg_path, "checkpoint": cw}, + open(os.path.join(d, "used.json"), "w"), ensure_ascii=False, indent=1) + except OSError: + pass kind = next((l.split(":")[-1].strip() for l in open(ph) if l.startswith("// head_type")), "?") if kind == "raw": # 프론트엔드가 이 head 를 인식하지 못했다 = 조립기가 없다. 여기서 끝낸다 — diff --git a/tools/verify/dense_head/verify_postproc.py b/tools/verify/dense_head/verify_postproc.py index 1386f90..857ebed 100644 --- a/tools/verify/dense_head/verify_postproc.py +++ b/tools/verify/dense_head/verify_postproc.py @@ -191,6 +191,27 @@ def main(): to_rgb = "c.to_rgb = true" in txt print(f"to_rgb={to_rgb} (파라미터 헤더 기준) · thr={thr} · size={size}") + # ⚠️ **이 gen_dir 를 구울 때 쓴 짝과 같은지 본다.** 계열마다 변종이 여럿이라 + # (retinanet r18 vs r50-caffe · tood anchor-free vs anchor-based) 손으로 부를 때 + # 다른 config·체크포인트를 주기 쉽다. 그러면 **남의 가중치를 진 그래프**를 대조하는 + # 것이고, 결과가 그럴듯하게 틀려(20px·13px) 결함으로 오해하게 된다. + for _d in (gen, os.path.dirname(gen) or "."): + _u = os.path.join(_d, "used.json") + if not os.path.exists(_u): + continue + try: + import json as _json + _used = _json.load(open(_u, encoding="utf-8")) + except Exception: + break + for _k, _got in (("config", cfg), ("checkpoint", ckpt)): + _want = _used.get(_k) + if _want and os.path.basename(_want) != os.path.basename(_got): + print(f" ⚠️ **짝이 다르다** — 이 gen_dir 는 {os.path.basename(_want)} 로 " + f"구웠는데 {os.path.basename(_got)} 로 재고 있다. " + f"수치가 크게 틀리면 결함이 아니라 이것부터 의심하라") + break + # ⚠️ **계열에 안 맞는 사진이면 크게 알린다.** MOT 트래커는 보행자 1클래스라 고양이 # 사진에서는 양쪽 다 0건이 나오고, 그러면 "한쪽이 비어 비교 불가" 로 끝나 그 계열을 # 아예 못 잰다. 이 도구는 이미지를 **인자로** 받으므로 덮어쓰지 않고 경고만 한다 — From 4fad6519bec8fff2795ffd850e4b90b6afa51728 Mon Sep 17 00:00:00 2001 From: eunchae Date: Wed, 19 Aug 2026 13:56:15 +0900 Subject: [PATCH 78/89] =?UTF-8?q?feat(no-box):=20=EB=B0=95=EC=8A=A4?= =?UTF-8?q?=EB=A5=BC=20=EC=95=88=20=EB=82=B4=EB=8A=94=205=EA=B3=84?= =?UTF-8?q?=EC=97=B4=20=E2=80=94=20kind:=20no-box=20(80=EA=B3=84=EC=97=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit solo·solov2·maskformer·mask2former·mask2former_vis 가 HEAD_NONE 으로 떨어지고 있었는데 실패가 아니라 **분류가 없었던 것**이다. head 이름이 bbox_head 가 아닐 뿐 (mask_head·panoptic_head·track_head) 백본+넥은 bb.pt 로 이미 잘 나왔다. 이들에게 박스는 판정 기준이 될 수 없으므로(애초에 안 낸다) 그래프 출력을 torch 와 직접 댄다. 러너는 이미 있던 run_dump.cpp 를 그대로 쓴다 — head·decode 없이 out_* 만 덤프한다. 붙는 자리는 head_type==raw → two-stage 실패 → no-box. solov2 6.30e-04 · solo 6.99e-04 · mask2former_vis 1.87e-03 maskformer 1.94e-03 · mask2former 2.19e-03 ⚠️ 이건 **컴파일 범위(백본+넥)** 가 맞다는 뜻이지 마스크 head 까지 검증됐다는 뜻이 아니다. 마스크 head 는 C++ 로 안 옮겼고, maskformer 계열은 neck 도 없어 컴파일 범위가 백본뿐이다. 문서에 그렇게 적었다 — 하네스 한계를 대상 한계로 적지 않는 것과 같은 이유로, 그 반대(한계를 성과로 적는 것)도 하지 않는다. ⚠️ 처음엔 넷 다 rel L1 1.5~2.0 이 나왔다. torch 는 CHW, 러너는 HWC 인데 안 맞춘 것이다 (1.4~2.0 은 무관한 두 텐서의 값이다). 축을 맞추니 6e-04~2e-03. reid 는 no-box 가 아니라 mmpretrain 의존 문제라 012 로 옮겼다. 가중치는 자기 metafile 에 없고 트래커 config 의 reid.init_cfg 가 가리켜서, 받아 손목록에 등록했다. 회귀: retinanet·fcos·atss·yolox·detr·tood·condinst·swin 8계열 L1 전부 동일. Co-Authored-By: Claude Opus 5 (1M context) --- docs/mmdet-detectors.md | 28 ++++++ tools/verify/dense_head/verify_heads.py | 126 +++++++++++++++++++++++- 2 files changed, 152 insertions(+), 2 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index d1a6816..d1de2d6 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -399,6 +399,34 @@ never reaches it. Installing the package was the whole fix — no code changed. guard on a training-only dependency reads exactly like an unsupported architecture in a results table, and the two deserve different columns. +Five families produce no boxes at all and are measured differently. `solo` and `solov2` predict +masks by location, `maskformer` and `mask2former` classify mask embeddings, and +`mask2former_vis` does the same over video; none of them has a `bbox_head`, so a box comparison +has nothing to compare. What they do have is a compiled graph, and that is what is checked: the +harness compiles the backbone and neck as usual, runs `tools/verify/backbone/run_dump.cpp` — +which emits the graph's `out_*` tensors with no head and no decoding — and compares them against +torch at the same relative-L1 threshold the box families use. + +| Family | Head attribute | Worst rel L1 | +| :--- | :--- | ---: | +| `solov2` | `mask_head` | 6.30e-04 | +| `solo` | `mask_head` | 6.99e-04 | +| `mask2former_vis` | `track_head` | 1.87e-03 | +| `maskformer` | `panoptic_head` | 1.94e-03 | +| `mask2former` | `panoptic_head` | 2.19e-03 | + +Read that table for what it is: **the compiled portion agrees with torch**, not "the family runs +end to end". The mask heads were never ported to C++, and `maskformer`/`mask2former` declare no +neck at all, so for those two the compiled portion is the backbone alone. Recording this as +"masks verified" would be the same mistake as recording a harness limitation as a model +limitation. `mask2former_vis` is measured on a single frame; multi-frame tracking is untested, +not unsupported. + +These families were failing as `HEAD_NONE` before, which read like a defect and was not: their +heads are simply named `mask_head`, `panoptic_head` or `track_head` rather than `bbox_head`, and +the export produced a perfectly good `bb.pt` the whole time. A missing classification is not a +missing capability. + Two-stage families are measured separately, at 800 and against the detector's own `predict` rather than a head's `predict_by_feat`, because the boxes do not exist until RPN proposals, RoIAlign and the RoI head have run. The harness is `tools/verify/roi/verify_postproc_roi.py` diff --git a/tools/verify/dense_head/verify_heads.py b/tools/verify/dense_head/verify_heads.py index c2b48e0..5387534 100644 --- a/tools/verify/dense_head/verify_heads.py +++ b/tools/verify/dense_head/verify_heads.py @@ -40,6 +40,12 @@ ("tood", "tood/tood_r50_fpn_1x_coco.py", "tood_r50_fpn_1x_coco_20211210_103425-20e20746.pth"), + # ⚠️ `reid` 는 **자기 metafile 에 가중치가 없다** — metafile 만 보면 `CKPT_NONE` 으로 + # 멈춘다. 학습 가중치는 없는 게 아니라 **다른 데 있다**: 트래커 config 가 + # `reid.init_cfg.checkpoint` 로 가리킨다(deepsort·sort 가 같은 것을 쓴다). + ("reid", "reid/reid_r50_8xb32-6e_mot15train80_test-mot15val20.py", + "tracktor_reid_r50_iter25245-a452f51f.pth"), + # 위 8계열의 head 를 **그대로 상속**한 계열들. 손실이나 백본만 다르므로 조립기는 # 같은 것을 탄다. 새 함수를 쓰기 전에 이런 게 있는지 먼저 본다. ("ghm", "ghm/retinanet_r50_fpn_ghm-1x_coco.py", # RetinaHead @@ -398,6 +404,115 @@ def run(cmd, cwd, env_extra=None, timeout=2400, phase=None): ''' +def _no_box(fam, cfg_path, cw, d): + """**박스를 안 내는 계열**을 그래프 출력으로 검증한다 (`kind: no-box`). + + SOLO·SOLOv2 는 `mask_head`, MaskFormer·Mask2Former 는 `panoptic_head`, + Mask2FormerVideo 는 `track_head`, ReID 는 `head` 를 쓴다. 이름이 `bbox_head` 가 아닐 뿐 + **모델이 못 도는 게 아니다** — 백본+넥은 `bb.pt` 로 이미 잘 나온다. + + 이들에게 박스는 판정 기준이 될 수 없으므로(애초에 안 낸다) **컴파일된 그래프의 출력을 + torch 와 직접 댄다.** 러너는 `run_dump.cpp` — head·decode 없이 `out_*` 만 덤프한다. + 통과 기준은 박스 계열과 같은 상대 L1/L2 다. + + ⚠️ 여기서 재는 것은 **컴파일 범위(백본+넥)** 이지 마스크 head 가 아니다. + 마스크 head 는 C++ 로 옮기지 않았다. "이 계열이 끝까지 돈다" 가 아니라 + "이 계열의 **컴파일된 부분**이 torch 와 같다" 로 읽어야 한다 — 두 문장을 같은 칸에 + 쓰면 다음 사람이 마스크까지 검증된 줄 안다. + """ + import shutil + + # 1) g2c 컴파일 — 박스 계열과 같은 경로다(백본+넥만 굽는다). + r = run([PY, "-c", ''' +import _stub, sys +sys.argv = ["g2c","--model","bb.pt","--name","Fam","--output","out","--input-shape","1,3,%d,%d"] +from shared.compile.pipeline import main; main() +''' % (SZ, SZ)], d, {"PYTHONPATH": f"{d}:{P}:{FE}:{GGUF_PY}"}, phase="2_g2c_nb") + gen = os.path.join(d, "out") + if not os.path.exists(os.path.join(gen, "Fam.gguf")): + return fam, "COMPILE_FAIL", _last_error(r.stderr)[:70] + + # 2) run_dump 빌드 — head 를 안 링크한다. + inc = os.path.join(gen, "inc", "visp", "arch") + os.makedirs(inc, exist_ok=True) + shutil.copy(os.path.join(gen, "Fam.h"), inc) + b = run(["g++", "-std=c++20", OPT, "-DARCH=Fam", + "-DVISP_ARCH_HEADER=\"visp/arch/Fam.h\"", + "-I" + os.path.join(gen, "inc"), "-I" + V + "/src", "-I" + V + "/include", + "-I" + V + "/depend/llama/ggml/include", + V + "/tools/verify/backbone/run_dump.cpp", os.path.join(gen, "Fam.cpp"), + "-L" + V + "/build/lib", "-lvisioncpp", "-lggml", "-lggml-base", "-lggml-cpu", + "-Wl,-rpath," + V + "/build/lib", "-o", os.path.join(gen, "run_dump")], + d, phase="3_build_nb") + if not os.path.exists(os.path.join(gen, "run_dump")): + return fam, "BUILD_FAIL", _last_error(b.stderr)[:70] + + # 3) 기준값 — 같은 `bb.pt` 를 torch 로 돌려 넥 출력을 받는다. + open(os.path.join(d, "ref_nb.py"), "w").write(REF_NOBOX % {"FE": FE, "SZ": SZ}) + q = run([PY, "ref_nb.py"], d, {"PYTHONPATH": f"{d}:{FE}"}, phase="4_ref_nb") + if not os.path.exists(os.path.join(d, "ref.out.0.bin")): + return fam, "REF_FAIL", _last_error(q.stderr)[:70] + + # 4) 러너 실행 — 기준값과 **같은 입력**(`in.bin`)을 준다. + # run_dump: [size] + x = run([os.path.join(gen, "run_dump"), os.path.join(gen, "Fam.gguf"), + os.path.join(d, "in.bin"), os.path.join(d, "cpp"), str(SZ)], + d, phase="5_run_nb") + outs = sorted(f for f in os.listdir(d) if f.startswith("ref.out.") and f.endswith(".bin")) + if not outs: + return fam, "RUN_FAIL", _last_error(x.stderr)[:70] + + import numpy as np + sp = os.path.join(d, "ref.shapes.txt") + shapes = [[int(v) for v in ln.split()] + for ln in open(sp).read().splitlines() if ln.strip()] if os.path.exists(sp) else [] + worst_l1 = worst_l2 = 0.0 + for i in range(len(outs)): + pr, pc = os.path.join(d, f"ref.out.{i}.bin"), os.path.join(d, f"cpp.out.{i}.bin") + if not os.path.exists(pc): + return fam, "RUN_FAIL", f"러너가 out_{i} 를 안 냈다" + a = np.fromfile(pr, dtype="float32") + bnp = np.fromfile(pc, dtype="float32") + if a.size != bnp.size: + return fam, "SHAPE_MISMATCH", f"out_{i}: torch {a.size} vs 러너 {bnp.size}" + # ⚠️ **축 순서가 다르다.** torch 는 CHW, 러너(ggml)는 HWC 로 쓴다. 이걸 안 맞추면 + # 값이 아니라 배치가 어긋나 rel L1 이 1.4~2.0(= 무관한 두 텐서)으로 나온다 — + # "모델이 안 맞는다" 로 읽히지만 대조기 탓이다(tood 에서 같은 걸 겪었다). + if i < len(shapes) and len(shapes[i]) == 3: + c, h, w = shapes[i] + bnp = bnp.reshape(h, w, c).transpose(2, 0, 1).reshape(-1) + den = max(float(np.abs(a).sum()), 1e-9) + worst_l1 = max(worst_l1, float(np.abs(a - bnp).sum()) / den) + worst_l2 = max(worst_l2, float(np.linalg.norm(a - bnp)) / max(float(np.linalg.norm(a)), 1e-9)) + ok = worst_l1 < L1_TOL and worst_l2 < L2_TOL + return (fam, "PASS" if ok else "FAIL", + f"L1 {worst_l1:.2e} · L2 {worst_l2:.2e} · kind no-box · 출력 {len(outs)}") + + +# 기준값 스크립트 — `bb.pt`(백본+넥)를 torch 로 돌려 출력을 그대로 덤프한다. +# ⚠️ **`bb.pt` 를 로드한다.** config 로 다시 init 하면 랜덤이 새로 굴러 GGUF 와 달라진다. +REF_NOBOX = r''' +import _stub, os, sys, numpy as np, torch +sys.path.insert(0, "%(FE)s") +import mmdet_wrap; mmdet_wrap.trace_friendly_ops() +SZ = %(SZ)d +mod = torch.load("bb.pt", weights_only=False).cpu(); mod.eval() +x = np.random.randn(1, 3, SZ, SZ).astype("float32") +with torch.no_grad(): + outs = mod(torch.from_numpy(x)) +if isinstance(outs, torch.Tensor): + outs = [outs] +shapes = [] +for i, t in enumerate(outs): + a = t[0].detach().numpy() + np.ascontiguousarray(a).tofile("ref.out.%%d.bin" %% i) + shapes.append(list(a.shape)) +open("ref.shapes.txt", "w").write("\n".join(" ".join(map(str, s)) for s in shapes)) +np.ascontiguousarray(x[0].transpose(1, 2, 0)).tofile("in.bin") # 러너 입력(cwhn) +print("REF_NOBOX_OK", len(outs)) +''' + + def _two_stage(fam, cfg_path, cw, d): """two-stage(Faster/Mask R-CNN 계열)를 **2패스**로 검증한다. @@ -599,8 +714,15 @@ def _unwrap(out_name, *extra): if kind == "raw": # 프론트엔드가 이 head 를 인식하지 못했다 = 조립기가 없다. 여기서 끝낸다 — # 계속 가면 러너에서 크래시로 나타나 "버그" 처럼 보인다. - # dense head 가 아니면 two-stage 경로를 태워 본다 — 거기서도 아니면 HEAD_NONE. - return _two_stage(fam, cfg_path, cw, d) + # dense head 가 아니면 two-stage 경로를 태워 본다 — 거기서도 아니면 no-box. + r2 = _two_stage(fam, cfg_path, cw, d) + if r2[1] != "HEAD_NONE": + return r2 + # **박스를 안 내는 계열**(SOLO·MaskFormer·Mask2Former·ReID)은 실패가 아니다. + # head 이름이 `bbox_head`/`rpn_head` 가 아닐 뿐이고(mask_head·panoptic_head· + # track_head·head), 백본+넥은 이미 `bb.pt` 로 잘 나왔다. 이들에게는 박스가 + # 판정 기준이 될 수 없으므로 **그래프 출력 자체**를 torch 와 댄다. + return _no_box(fam, cfg_path, cw, d) # 2) g2c 컴파일 (backbone+neck 만). **g2c 는 main 원본 그대로 쓴다.** r = run([PY, "-c", ''' From fe2a4509bed1e868de3171cc781d856eff5e2c86 Mon Sep 17 00:00:00 2001 From: eunchae Date: Wed, 19 Aug 2026 14:45:26 +0900 Subject: [PATCH 79/89] =?UTF-8?q?feat(fast=5Frcnn):=20=EC=99=B8=EB=B6=80?= =?UTF-8?q?=20proposal=20=EA=B2=BD=EB=A1=9C=20=E2=80=94=200.01px=20(81?= =?UTF-8?q?=EA=B3=84=EC=97=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fast_rcnn 은 proposal 을 밖에서 받는 것이 **정의**다. rpn_head 도 test_cfg.rpn 도 없어서 export 가 AttributeError 로 죽고 있었는데, 미지원이 아니라 다른 계약이다. SubA 가 RPN 없이 feats 만 내고, 러너는 FRCNN_PROPOSALS 파일에서 proposal 을 읽는다. 기준값도 **같은 파일**을 읽어 roi_head.predict 에 직접 넣는다 — 각자 만들면 proposal 생성기를 재게 된다. proposal 은 **고정 8×8 격자 64개**(사용자 결정). RPN 출력을 쓰면 백본·넥·RoI head 가 faster_rcnn 과 전부 같아져 'faster_rcnn 을 다른 이름으로 재는 것'이 된다. 격자는 결정적이고, RoIAlign+RoI head 를 proposal 생성기와 분리해서 본다. ⚠️ 개수는 계약이다. SubB 가 proposal 행 수를 그래프에 상수로 굽는다(flatten 이 reshape 로 박힌다) — frcnn_wrap.EXTERNAL_PROPOSAL_COUNT 를 하네스가 읽어 정확히 그만큼 만든다. 처음엔 rpn_max=0 으로 둬서 러너가 죽었다. 그리고 짝 목록을 mmdet_families.OVERRIDE 로 옮겨 **두 하네스가 같은 것을 보게** 했다. 예전엔 one-stage 는 손목록, two-stage 는 metafile 이라 같은 계열이 다르게 풀렸다 — 오늘 그것 때문에 두 번(tood 13.89px · retinanet 20.02px) 멀쩡한 코드를 결함으로 봤다. 회귀: two-stage 45계열, 기존 38계열 판정 변화 0건. PASS 28 → 35 (늘어난 7 = 래퍼 5 + fast_rcnn + panoptic_fpn, 전부 이번 작업분). Co-Authored-By: Claude Opus 5 (1M context) --- tools/frontend/mmdet/frcnn_wrap.py | 73 +++++++++--- tools/verify/backbone/run_frcnn.cpp | 24 +++- tools/verify/dense_head/mmdet_families.py | 135 ++++++++++++++++++++++ tools/verify/dense_head/verify_heads.py | 107 +---------------- tools/verify/roi/verify_postproc_roi.py | 45 ++++++-- 5 files changed, 252 insertions(+), 132 deletions(-) diff --git a/tools/frontend/mmdet/frcnn_wrap.py b/tools/frontend/mmdet/frcnn_wrap.py index ded6477..f78ce65 100755 --- a/tools/frontend/mmdet/frcnn_wrap.py +++ b/tools/frontend/mmdet/frcnn_wrap.py @@ -37,7 +37,11 @@ def __init__(self, det): # ⚠️ **neck 이 항상 있다고 보면 안 된다.** C4 계열(TridentFasterRCNN)은 FPN 없이 # 백본 C4 를 그대로 쓰고 `shared_head`(ResLayer)가 C5 역할을 한다. self.neck = getattr(det, "neck", None) - self.rpn_head = det.rpn_head + # ⚠️ **RPN 이 없는 계열이 있다.** `FastRCNN` 은 proposal 을 **밖에서** 받는 구조라 + # `rpn_head` 를 아예 안 갖는다(config 에 `test_cfg.rpn` 도 없다). 없는 게 + # 결함이 아니라 그 계열의 정의다 — SubA 는 백본+넥까지만 내고, proposal 은 + # 러너가 파일에서 읽는다. + self.rpn_head = getattr(det, "rpn_head", None) self.n_roi = _n_roi_levels(det) # HTC 계열의 **시맨틱 갈래**. FPN 위에 FCN 을 따로 돌려 나온 융합 feature 를 # RoIAlign 해서 `bbox_feats` 에 더한다(htc_roi_head.py:99-104). @@ -54,8 +58,10 @@ def forward(self, x): feats = self.backbone(x) if getattr(self, "neck", None) is not None: feats = self.neck(feats) # tuple len 5: P2..P6 - rpn_cls, rpn_bbox = self.rpn_head(feats) # (listL, listL) - out = tuple(feats[:getattr(self, "n_roi", 4)]) + tuple(rpn_cls) + tuple(rpn_bbox) + out = tuple(feats[:getattr(self, "n_roi", 4)]) + if getattr(self, "rpn_head", None) is not None: + rpn_cls, rpn_bbox = self.rpn_head(feats) # (listL, listL) + out = out + tuple(rpn_cls) + tuple(rpn_bbox) if getattr(self, "semantic_head", None) is not None: # `(mask_preds, fused)` 중 **fused** 가 bbox 융합에 쓰이는 쪽이다. _, sem = self.semantic_head(feats) @@ -212,20 +218,28 @@ def forward(self, grid_feat): return self.grid_head(grid_feat)["fused"] +# 외부 proposal 계열(FastRCNN)에 넣을 proposal 개수. 8×8 격자 = 64. +# 러너·기준값·SubB 가 **이 하나의 숫자**를 공유해야 한다. +EXTERNAL_PROPOSAL_COUNT = 64 + + def frcnn_cfg(det, size=800): """host 부품(rpn_proposals/roi_align/detect_roi)용 config 추출 → .frcnn.json.""" - rh = det.rpn_head + rh = getattr(det, "rpn_head", None) + # ⚠️ **RPN 이 아예 없는 계열이 있다.** `FastRCNN` 은 proposal 을 밖에서 받는 것이 + # 정의이므로 `rpn_head` 도 `test_cfg.rpn` 도 없다. 이건 미지원이 아니라 **다른 계약**이다 + # — 러너가 proposal 을 파일에서 읽도록 표시만 하고 나머지(RoIAlign·RoI head)는 같다. # ⚠️ RPN 이 표준 `AnchorGenerator` 를 안 쓰는 계열이 있다. 호스트 `rpn_proposals` 는 # (strides, scale, ratios) 로 앵커를 깔아 디코드하는 구조라 그대로는 못 쓴다. # **`AttributeError` 로 흘려보내지 말고** 왜 안 되는지 말한다 — 그래야 "미지원" 과 # "우리 버그" 가 구분된다. - if not hasattr(rh, "prior_generator"): + if rh is not None and not hasattr(rh, "prior_generator"): raise NotImplementedError( f"{type(rh).__name__}: 표준 앵커 RPN 이 아니다. " "CascadeRPNHead=다단계 정제(stages), GARPNHead=앵커 모양을 예측, " "EmbeddingRPNHead=학습된 proposal — 각각 호스트 proposal 생성이 달라진다") - pg = rh.prior_generator - bc = rh.bbox_coder + pg = rh.prior_generator if rh is not None else None + bc = rh.bbox_coder if rh is not None else None ext = det.roi_head.bbox_roi_extractor # 캐스케이드는 extractor 도 ModuleList 다(단계마다 하나). 설정은 전부 같으므로 0번을 쓴다. if isinstance(ext, nn.ModuleList): @@ -242,9 +256,27 @@ def frcnn_cfg(det, size=800): # **마지막 단계**를 쓴다(최종 박스를 내는 단계라 디코드 규약이 거기 맞춰져 있다). if isinstance(bh, nn.ModuleList): bh = bh[-1] - rpn_c, rcnn_c = det.test_cfg.rpn, det.test_cfg.rcnn - strides = [s[0] if isinstance(s, (tuple, list)) else int(s) for s in pg.strides] - scales = pg.scales.tolist() if hasattr(pg.scales, "tolist") else list(pg.scales) + rcnn_c = det.test_cfg.rcnn + if rh is not None: + rpn_c = det.test_cfg.rpn + strides = [s[0] if isinstance(s, (tuple, list)) else int(s) for s in pg.strides] + scales = pg.scales.tolist() if hasattr(pg.scales, "tolist") else list(pg.scales) + rpn_means, rpn_stds = [float(v) for v in bc.means], [float(v) for v in bc.stds] + rpn_nms_pre, rpn_nms_thr = int(rpn_c.nms_pre), float(rpn_c.nms.iou_threshold) + rpn_max = int(rpn_c.max_per_img) + else: + # **외부 proposal 계약**(FastRCNN). RPN 파라미터가 존재하지 않으므로 만들지 않는다 — + # 0 이나 기본값을 채워 넣으면 나중에 "RPN 설정이 이상하다" 로 읽힌다. 러너는 + # `external_proposals` 를 보고 proposal 을 파일에서 읽는다. + strides = [float(v) for v in (det.roi_head.bbox_roi_extractor.featmap_strides + if not isinstance(det.roi_head.bbox_roi_extractor, nn.ModuleList) + else det.roi_head.bbox_roi_extractor[0].featmap_strides)] + scales, rpn_means, rpn_stds = [], [], [] + rpn_nms_pre, rpn_nms_thr = 0, 0.0 + # ⚠️ **개수는 계약이다.** SubB 는 proposal 행 수를 그래프에 상수로 굽는다 + # (`flatten` 이 `reshape(…, N)` 으로 박힌다) — 다른 개수를 넣으면 러너가 죽는다. + # 그래서 여기서 정한 값을 하네스가 읽어 **정확히 그만큼** 만든다. + rpn_max = EXTERNAL_PROPOSAL_COUNT mask = {} if getattr(det.roi_head, "with_mask", False): mext = det.roi_head.mask_roi_extractor @@ -342,21 +374,26 @@ def frcnn_cfg(det, size=800): # 준다 — 스케일이 하나인 계열은 결과가 완전히 같다. "rpn_scale": 1.0, "rpn_scales": [float(v) for v in scales], - "rpn_ratios": [float(r) for r in pg.ratios.tolist()], - "rpn_means": [float(v) for v in bc.means], "rpn_stds": [float(v) for v in bc.stds], - "rpn_nms_pre": int(rpn_c.nms_pre), "rpn_nms_thr": float(rpn_c.nms.iou_threshold), - "rpn_max": int(rpn_c.max_per_img), + "rpn_ratios": [float(r) for r in pg.ratios.tolist()] if rh is not None else [], + "rpn_means": rpn_means, "rpn_stds": rpn_stds, + "rpn_nms_pre": rpn_nms_pre, "rpn_nms_thr": rpn_nms_thr, + "rpn_max": rpn_max, + # ⚠️ proposal 을 밖에서 받는 계열인가(FastRCNN). 러너는 이 값이 참이면 + # RPN 계산을 건너뛰고 proposal 파일을 읽는다. + "external_proposals": rh is None, # ⚠️ 아래 셋은 **기본값이 아닌 계열이 있다**(crowddet 이 셋 다 다르다). # 전부 shape 를 안 바꾸므로 빼먹으면 크래시 없이 proposal 만 달라진다. # `centers` — 주어지면 그 값이 base anchor 중심이고 center_offset 은 무시된다. - "rpn_centers": [float(v) for c in (getattr(pg, "centers", None) or []) for v in c], + "rpn_centers": [float(v) for c in (getattr(pg, "centers", None) or []) for v in c] + if rh is not None else [], # `clip_border` — false 면 proposal 을 이미지로 안 자른다(800 입력에 1054 가 나온다). - "rpn_clip_border": 1.0 if getattr(bc, "clip_border", True) else 0.0, + "rpn_clip_border": (1.0 if getattr(bc, "clip_border", True) else 0.0) + if rh is not None else 0.0, # `min_bbox_size` — NMS **앞에서** 작은 상자를 버린다. - "rpn_min_bbox_size": float(getattr(rpn_c, "min_bbox_size", 0) or 0), + "rpn_min_bbox_size": float(getattr(rpn_c, "min_bbox_size", 0) or 0) if rh is not None else 0.0, # `cls_out_channels` — RPNHead 는 보통 1(sigmoid)이지만 loss_cls 에 # use_sigmoid 를 안 적으면 2(softmax, 전경 index 0)가 된다. **모듈에서 읽는다.** - "rpn_cls_out_channels": int(getattr(rh, "cls_out_channels", 1) or 1), + "rpn_cls_out_channels": int(getattr(rh, "cls_out_channels", 1) or 1) if rh is not None else 0, # RoIAlign # ⚠️ **RoI feature 채널을 256 으로 박으면 안 된다.** FPN 계열은 256 이지만 # C4 계열(TridentNet)은 neck 이 없어 백본 C4 채널(1024)이 그대로 온다. diff --git a/tools/verify/backbone/run_frcnn.cpp b/tools/verify/backbone/run_frcnn.cpp index 9f9dc2c..79daaf4 100644 --- a/tools/verify/backbone/run_frcnn.cpp +++ b/tools/verify/backbone/run_frcnn.cpp @@ -179,7 +179,11 @@ int main(int argc, char** argv) { // (프론트엔드의 `_n_roi_levels` 와 같은 근거를 쓴다). std::vector> all_hw; auto outs = read_outputs(g0, 64, &all_hw); - const int L = (int)J.arr("rpn_strides").size(); + // ⚠️ **RPN 이 없는 계열이 있다.** `FastRCNN` 은 proposal 을 밖에서 받는 것이 정의라 + // `rpn_head` 도 `test_cfg.rpn` 도 없다 — SubA 는 feats 만 낸다. 미지원이 아니라 + // **다른 계약**이므로 proposal 을 파일에서 읽고 RPN 계산을 건너뛴다. + const bool ext_props = J.num("external_proposals", 0.0f) != 0.0f; + const int L = ext_props ? 0 : (int)J.arr("rpn_strides").size(); const int NF = (int)J.arr("roi_strides").size(); if ((int)outs.size() < NF + 2 * L) { fprintf(stderr, "SubA 출력 %zu 개 < 기대 %d (feats %d + rpn %d×2)\n", @@ -222,7 +226,23 @@ int main(int argc, char** argv) { rp.min_bbox_size = J.num("rpn_min_bbox_size", 0.0f); rp.cls_out_channels = (int)J.num("rpn_cls_out_channels", 1.0f); rp.input_w = rp.input_h = SZ; - std::vector props = rpn_proposals(rpn_cls, rpn_box, rpn_hw, rp); + std::vector props; + if (ext_props) { + // proposal 파일: float32 x1,y1,x2,y2 반복. 기준값 쪽과 **같은 파일**을 읽어야 + // 비교가 성립한다 — 각자 만들면 proposal 생성기를 재게 된다. + const char* pp = getenv("FRCNN_PROPOSALS"); + if (!pp) { fprintf(stderr, "external_proposals 인데 FRCNN_PROPOSALS 가 없다\n"); return 3; } + std::ifstream f(pp, std::ios::binary); + if (!f) { fprintf(stderr, "proposal 파일을 못 연다: %s\n", pp); return 3; } + f.seekg(0, std::ios::end); + const size_t n = (size_t)f.tellg() / sizeof(float); + f.seekg(0); + props.resize(n); + f.read(reinterpret_cast(props.data()), (std::streamsize)(n * sizeof(float))); + rp.max_per_img = (int)(n / 4); + } else { + props = rpn_proposals(rpn_cls, rpn_box, rpn_hw, rp); + } const int M_real = (int)(props.size() / 4); // ⚠️ **proposal 이 상한보다 적게 나오는 계열이 있다.** SubB 는 상한(rpn_max)으로 // 구워지고 그 안의 `flatten` 이 `ggml_reshape_2d(…, 2048, 1000)` 처럼 **행 수를 diff --git a/tools/verify/dense_head/mmdet_families.py b/tools/verify/dense_head/mmdet_families.py index d0562e4..2999dd4 100644 --- a/tools/verify/dense_head/mmdet_families.py +++ b/tools/verify/dense_head/mmdet_families.py @@ -115,3 +115,138 @@ def families(configs_root): if cfg: out.append((name, cfg, ckpt)) return out + +# ── 손으로 고른 대표 config·체크포인트 ──────────────────────────────────────── +# ⚠️ **두 하네스가 같은 목록을 봐야 한다.** 예전엔 one-stage 는 이 손목록을, two-stage 는 +# metafile 을 봐서 **같은 계열이 다르게 풀렸다.** 그러면 한쪽으로 굽고 다른 쪽으로 재게 +# 되고, 남의 가중치를 진 그래프를 대조하면서 수치가 그럴듯하게 틀린다(retinanet 20px · +# tood 13.89px 를 그렇게 결함으로 오해했다). 짝은 여기 한 곳에서만 온다. +# +# metafile 이 고르는 것과 **13계열이 갈린다**. 대표적으로 retinanet(r18 vs r50-caffe)· +# tood(anchor-free vs anchor-based)·pvt(PVT-Tiny vs PVTv2-B5, 아예 다른 아키텍처)다. +_OVERRIDE_LIST = [ + ("retinanet", "retinanet/retinanet_r18_fpn_1x_coco.py", + "retinanet_r18_fpn_1x_coco_20220407_171055-614fd399.pth"), + ("atss", "atss/atss_r50_fpn_1x_coco.py", + "atss_r50_fpn_1x_coco_20200209-985f7bd0.pth"), + ("paa", "paa/paa_r50_fpn_1x_coco.py", + "paa_r50_fpn_1x_coco_20200821-936edec3.pth"), + ("fcos", "fcos/fcos_r50-caffe_fpn_gn-head_1x_coco.py", + "fcos_r50_caffe_fpn_gn-head_1x_coco-821213aa.pth"), + ("gfl", "gfl/gfl_r50_fpn_1x_coco.py", + "gfl_r50_fpn_1x_coco_20200629_121244-25944287.pth"), + ("vfnet", "vfnet/vfnet_r50_fpn_1x_coco.py", + "vfnet_r50_fpn_1x_coco_20201027-38db6f58.pth"), + ("reppoints", "reppoints/reppoints-moment_r50_fpn_1x_coco.py", + "reppoints_moment_r50_fpn_1x_coco_20200330-b73db8d1.pth"), + ("tood", "tood/tood_r50_fpn_1x_coco.py", + "tood_r50_fpn_1x_coco_20211210_103425-20e20746.pth"), + + # ⚠️ `reid` 는 **자기 metafile 에 가중치가 없다** — metafile 만 보면 `CKPT_NONE` 으로 + # 멈춘다. 학습 가중치는 없는 게 아니라 **다른 데 있다**: 트래커 config 가 + # `reid.init_cfg.checkpoint` 로 가리킨다(deepsort·sort 가 같은 것을 쓴다). + ("reid", "reid/reid_r50_8xb32-6e_mot15train80_test-mot15val20.py", + "tracktor_reid_r50_iter25245-a452f51f.pth"), + + # ⚠️ `fast_rcnn` 도 자기 가중치가 없다. **못 하는 게 아니라 metafile 이 안 낸다** — + # 이 계열은 미리 뽑은 proposal 을 입력으로 받는 구조라 단독 배포가 없다. + # 구조는 `faster_rcnn` 에서 RPN 만 뺀 것과 **같다**(ResNet-50 + FPN + + # StandardRoIHead + Shared2FCBBoxHead) — 백본·넥·RoI head 이름이 그대로 맞으므로 + # 그 가중치를 빌린다. 남는 `rpn_head.*` 는 안 붙고 버려진다. + ("fast_rcnn", "fast_rcnn/fast-rcnn_r50_fpn_1x_coco.py", + "faster_rcnn_r50_fpn_iou_1x_coco_20200506_095954-938e81f0.pth"), + + # 위 8계열의 head 를 **그대로 상속**한 계열들. 손실이나 백본만 다르므로 조립기는 + # 같은 것을 탄다. 새 함수를 쓰기 전에 이런 게 있는지 먼저 본다. + ("ghm", "ghm/retinanet_r50_fpn_ghm-1x_coco.py", # RetinaHead + "retinanet_ghm_r50_fpn_1x_coco_20200130-a437fda3.pth"), + ("pvt", "pvt/retinanet_pvt-t_fpn_1x_coco.py", # RetinaHead + "retinanet_pvt-t_fpn_1x_coco_20210831_103110-17b566bd.pth"), + ("free_anchor", "free_anchor/freeanchor_r50_fpn_1x_coco.py", # RetinaHead 상속 + "retinanet_free_anchor_r50_fpn_1x_coco_20200130-0f67375f.pth"), + ("fsaf", "fsaf/fsaf_r50_fpn_1x_coco.py", # RetinaHead 상속 + "fsaf_r50_fpn_1x_coco-94ccc51f.pth"), + ("dyhead", "dyhead/atss_r50-caffe_fpn_dyhead_1x_coco.py", # ATSSHead + "atss_r50_fpn_dyhead_for_reproduction_4x4_1x_coco_20220107_213939-162888e6.pth"), + ("nas_fcos", "nas_fcos/nas-fcos_r50-caffe_fpn_nashead-gn-head_4xb4-1x_coco.py", # FCOSHead + "nas_fcos_nashead_r50_caffe_fpn_gn-head_4x4_1x_coco_20200520-1bdba3ce.pth"), + ("ld", "ld/ld_r50-gflv1-r101_fpn_1x_coco.py", # GFLHead 상속 + "ld_r50_gflv1_r101_fpn_coco_1x_20220629_145355-8dc5bad8.pth"), + ("lad", "lad/lad_r101-paa-r50_fpn_2xb8_coco_1x.py", # PAAHead 상속 + "lad_r101_paa_r50_fpn_coco_1x_20220708_124357-9407ac54.pth"), + + + # ── 텍스트+이미지 계열 ───────────────────────────────────────────────── + # 언어 모델(BERT)이 함께 들어 있다. head 는 ATSS/DINO 계열을 상속하므로 조립기가 + # 있을 수도 있는데, **재본 적이 없어서** 결과를 말할 수 없었다 → 체크포인트를 받아 등록한다. + ("glip", "glip/glip_atss_swin-t_a_fpn_dyhead_pretrain_obj365.py", + "glip_tiny_a_mmdet-b3654169.pth"), + ("grounding_dino", "grounding_dino/grounding_dino_swin-t_finetune_16xb2_1x_coco.py", + "groundingdino_swint_ogc_mmdet-822d7e9d.pth"), + ("mm_grounding_dino", "mm_grounding_dino/grounding_dino_swin-t_pretrain_obj365.py", + "grounding_dino_swin-t_pretrain_obj365_goldg_grit9m_v3det_20231204_095047-b448804b.pth"), + + # ── 아직 조립기가 없는 계열 ───────────────────────────────────────────── + # 여기 있다고 지원한다는 뜻이 아니다. **어디서 어떻게 막히는지 재려고** 둔다 — + # 실패도 기록해야 다음 사람이 같은 걸 다시 조사하지 않는다. + ("ddod", "ddod/ddod_r50_fpn_1x_coco.py", + "ddod_r50_fpn_1x_coco_20220523_223737-29b2fc67.pth"), + ("autoassign", "autoassign/autoassign_r50-caffe_fpn_1x_coco.py", + "auto_assign_r50_fpn_1x_coco_20210413_115540-5e17991f.pth"), + ("foveabox", "foveabox/fovea_r50_fpn_4xb4-1x_coco.py", + "fovea_r50_fpn_4x4_1x_coco_20200219-ee4d5303.pth"), + ("yolof", "yolof/yolof_r50-c5_8xb8-1x_coco.py", + "yolof_r50_c5_8x8_1x_coco_20210425_024427-8e864411.pth"), + ("efficientnet", "efficientnet/retinanet_effb3_fpn_8xb4-crop896-1x_coco.py", + "retinanet_effb3_fpn_crop896_8x4_1x_coco_20220322_234806-615a0dda.pth"), + ("nas_fpn", "nas_fpn/retinanet_r50_fpn_crop640-50e_coco.py", + "retinanet_r50_fpn_crop640_50e_coco-9b953d76.pth"), + ("ssd", "ssd/ssd300_coco.py", + "ssd300_coco_20210803_015428-d231a06e.pth"), + ("yolo", "yolo/yolov3_d53_8xb8-320-273e_coco.py", + "yolov3_d53_320_273e_coco-421362b6.pth"), + ("yolox", "yolox/yolox_s_8xb8-300e_coco.py", + "yolox_s_8x8_300e_coco_20211121_095711-4592a793.pth"), + ("rtmdet", "rtmdet/rtmdet_tiny_8xb32-300e_coco.py", + "rtmdet_tiny_8xb32-300e_coco_20220902_112414-78e30dcc.pth"), + ("centernet", "centernet/centernet_r18-dcnv2_8xb16-crop512-140e_coco.py", + "centernet_resnet18_dcnv2_140e_coco_20210702_155131-c8cd631f.pth"), + ("cornernet", "cornernet/cornernet_hourglass104_10xb5-crop511-210e-mstest_coco.py", + "cornernet_hourglass104_mstest_10x5_210e_coco_20200824_185720-5fefbf1c.pth"), + ("centripetalnet", "centripetalnet/centripetalnet_hourglass104_16xb6-crop511-210e-mstest_coco.py", + "centripetalnet_hourglass104_mstest_16x6_210e_coco_20200915_204804-3ccc61e5.pth"), + ("yolact", "yolact/yolact_r50_1xb8-55e_coco.py", + "yolact_r50_1x8_coco_20200908-f38d58df.pth"), + ("condinst", "condinst/condinst_r50_fpn_ms-poly-90k_coco_instance.py", + "condinst_r50_fpn_ms-poly-90k_coco_instance_20221129_125223-4c186406.pth"), + ("boxinst", "boxinst/boxinst_r50_fpn_ms-90k_coco.py", + "boxinst_r50_fpn_ms-90k_coco_20221228_163052-6add751a.pth"), + + # DETR 계열 — transformer decoder 라 conv 타워 구조 자체가 없다. 별개 작업이다. + ("detr", "detr/detr_r50_8xb2-150e_coco.py", + "detr_r50_8xb2-150e_coco_20221023_153551-436d03e8.pth"), + ("conditional_detr", "conditional_detr/conditional-detr_r50_8xb2-50e_coco.py", + "conditional-detr_r50_8xb2-50e_coco_20221121_180202-c83a1dc0.pth"), + ("dab_detr", "dab_detr/dab-detr_r50_8xb2-50e_coco.py", + "dab-detr_r50_8xb2-50e_coco_20221122_120837-c1035c8c.pth"), + ("deformable_detr", "deformable_detr/deformable-detr_r50_16xb2-50e_coco.py", + "deformable-detr_r50_16xb2-50e_coco_20221029_210934-6bc7d21b.pth"), + ("dino", "dino/dino-4scale_r50_8xb2-12e_coco.py", + "dino-4scale_r50_8xb2-12e_coco_20221202_182705-55b2bba2.pth"), + ("ddq", "ddq/ddq-detr-4scale_r50_8xb2-12e_coco.py", + "ddq-detr-4scale_r50_8xb2-12e_coco_20230809_170711-42528127.pth"), +] +OVERRIDE = {f: (c, k) for f, c, k in _OVERRIDE_LIST} + + +def resolve_pair(configs_root, fam): + """(config 절대경로, 체크포인트 파일명) — **하네스가 실제로 쓰는 짝.** + + 손목록이 있으면 그것, 없으면 metafile. 두 하네스 다 이걸 부른다. + """ + import os as _os + if fam in OVERRIDE: + c, k = OVERRIDE[fam] + return _os.path.join(configs_root, c), k + c, k, _ = resolve(configs_root, fam) + return (_os.path.join(configs_root, c) if c else None), k diff --git a/tools/verify/dense_head/verify_heads.py b/tools/verify/dense_head/verify_heads.py index 5387534..3fe189c 100644 --- a/tools/verify/dense_head/verify_heads.py +++ b/tools/verify/dense_head/verify_heads.py @@ -22,110 +22,7 @@ # (계열, config, 학습된 체크포인트). **랜덤 초기화로 재지 않는다** — 항등 초기값이 # 빠진 연산을 덮어 검증을 통과시킨다(group_norm affine 이 실제로 그랬다). -FAMILIES = [ - ("retinanet", "retinanet/retinanet_r18_fpn_1x_coco.py", - "retinanet_r18_fpn_1x_coco_20220407_171055-614fd399.pth"), - ("atss", "atss/atss_r50_fpn_1x_coco.py", - "atss_r50_fpn_1x_coco_20200209-985f7bd0.pth"), - ("paa", "paa/paa_r50_fpn_1x_coco.py", - "paa_r50_fpn_1x_coco_20200821-936edec3.pth"), - ("fcos", "fcos/fcos_r50-caffe_fpn_gn-head_1x_coco.py", - "fcos_r50_caffe_fpn_gn-head_1x_coco-821213aa.pth"), - ("gfl", "gfl/gfl_r50_fpn_1x_coco.py", - "gfl_r50_fpn_1x_coco_20200629_121244-25944287.pth"), - ("vfnet", "vfnet/vfnet_r50_fpn_1x_coco.py", - "vfnet_r50_fpn_1x_coco_20201027-38db6f58.pth"), - ("reppoints", "reppoints/reppoints-moment_r50_fpn_1x_coco.py", - "reppoints_moment_r50_fpn_1x_coco_20200330-b73db8d1.pth"), - ("tood", "tood/tood_r50_fpn_1x_coco.py", - "tood_r50_fpn_1x_coco_20211210_103425-20e20746.pth"), - - # ⚠️ `reid` 는 **자기 metafile 에 가중치가 없다** — metafile 만 보면 `CKPT_NONE` 으로 - # 멈춘다. 학습 가중치는 없는 게 아니라 **다른 데 있다**: 트래커 config 가 - # `reid.init_cfg.checkpoint` 로 가리킨다(deepsort·sort 가 같은 것을 쓴다). - ("reid", "reid/reid_r50_8xb32-6e_mot15train80_test-mot15val20.py", - "tracktor_reid_r50_iter25245-a452f51f.pth"), - - # 위 8계열의 head 를 **그대로 상속**한 계열들. 손실이나 백본만 다르므로 조립기는 - # 같은 것을 탄다. 새 함수를 쓰기 전에 이런 게 있는지 먼저 본다. - ("ghm", "ghm/retinanet_r50_fpn_ghm-1x_coco.py", # RetinaHead - "retinanet_ghm_r50_fpn_1x_coco_20200130-a437fda3.pth"), - ("pvt", "pvt/retinanet_pvt-t_fpn_1x_coco.py", # RetinaHead - "retinanet_pvt-t_fpn_1x_coco_20210831_103110-17b566bd.pth"), - ("free_anchor", "free_anchor/freeanchor_r50_fpn_1x_coco.py", # RetinaHead 상속 - "retinanet_free_anchor_r50_fpn_1x_coco_20200130-0f67375f.pth"), - ("fsaf", "fsaf/fsaf_r50_fpn_1x_coco.py", # RetinaHead 상속 - "fsaf_r50_fpn_1x_coco-94ccc51f.pth"), - ("dyhead", "dyhead/atss_r50-caffe_fpn_dyhead_1x_coco.py", # ATSSHead - "atss_r50_fpn_dyhead_for_reproduction_4x4_1x_coco_20220107_213939-162888e6.pth"), - ("nas_fcos", "nas_fcos/nas-fcos_r50-caffe_fpn_nashead-gn-head_4xb4-1x_coco.py", # FCOSHead - "nas_fcos_nashead_r50_caffe_fpn_gn-head_4x4_1x_coco_20200520-1bdba3ce.pth"), - ("ld", "ld/ld_r50-gflv1-r101_fpn_1x_coco.py", # GFLHead 상속 - "ld_r50_gflv1_r101_fpn_coco_1x_20220629_145355-8dc5bad8.pth"), - ("lad", "lad/lad_r101-paa-r50_fpn_2xb8_coco_1x.py", # PAAHead 상속 - "lad_r101_paa_r50_fpn_coco_1x_20220708_124357-9407ac54.pth"), - - - # ── 텍스트+이미지 계열 ───────────────────────────────────────────────── - # 언어 모델(BERT)이 함께 들어 있다. head 는 ATSS/DINO 계열을 상속하므로 조립기가 - # 있을 수도 있는데, **재본 적이 없어서** 결과를 말할 수 없었다 → 체크포인트를 받아 등록한다. - ("glip", "glip/glip_atss_swin-t_a_fpn_dyhead_pretrain_obj365.py", - "glip_tiny_a_mmdet-b3654169.pth"), - ("grounding_dino", "grounding_dino/grounding_dino_swin-t_finetune_16xb2_1x_coco.py", - "groundingdino_swint_ogc_mmdet-822d7e9d.pth"), - ("mm_grounding_dino", "mm_grounding_dino/grounding_dino_swin-t_pretrain_obj365.py", - "grounding_dino_swin-t_pretrain_obj365_goldg_grit9m_v3det_20231204_095047-b448804b.pth"), - - # ── 아직 조립기가 없는 계열 ───────────────────────────────────────────── - # 여기 있다고 지원한다는 뜻이 아니다. **어디서 어떻게 막히는지 재려고** 둔다 — - # 실패도 기록해야 다음 사람이 같은 걸 다시 조사하지 않는다. - ("ddod", "ddod/ddod_r50_fpn_1x_coco.py", - "ddod_r50_fpn_1x_coco_20220523_223737-29b2fc67.pth"), - ("autoassign", "autoassign/autoassign_r50-caffe_fpn_1x_coco.py", - "auto_assign_r50_fpn_1x_coco_20210413_115540-5e17991f.pth"), - ("foveabox", "foveabox/fovea_r50_fpn_4xb4-1x_coco.py", - "fovea_r50_fpn_4x4_1x_coco_20200219-ee4d5303.pth"), - ("yolof", "yolof/yolof_r50-c5_8xb8-1x_coco.py", - "yolof_r50_c5_8x8_1x_coco_20210425_024427-8e864411.pth"), - ("efficientnet", "efficientnet/retinanet_effb3_fpn_8xb4-crop896-1x_coco.py", - "retinanet_effb3_fpn_crop896_8x4_1x_coco_20220322_234806-615a0dda.pth"), - ("nas_fpn", "nas_fpn/retinanet_r50_fpn_crop640-50e_coco.py", - "retinanet_r50_fpn_crop640_50e_coco-9b953d76.pth"), - ("ssd", "ssd/ssd300_coco.py", - "ssd300_coco_20210803_015428-d231a06e.pth"), - ("yolo", "yolo/yolov3_d53_8xb8-320-273e_coco.py", - "yolov3_d53_320_273e_coco-421362b6.pth"), - ("yolox", "yolox/yolox_s_8xb8-300e_coco.py", - "yolox_s_8x8_300e_coco_20211121_095711-4592a793.pth"), - ("rtmdet", "rtmdet/rtmdet_tiny_8xb32-300e_coco.py", - "rtmdet_tiny_8xb32-300e_coco_20220902_112414-78e30dcc.pth"), - ("centernet", "centernet/centernet_r18-dcnv2_8xb16-crop512-140e_coco.py", - "centernet_resnet18_dcnv2_140e_coco_20210702_155131-c8cd631f.pth"), - ("cornernet", "cornernet/cornernet_hourglass104_10xb5-crop511-210e-mstest_coco.py", - "cornernet_hourglass104_mstest_10x5_210e_coco_20200824_185720-5fefbf1c.pth"), - ("centripetalnet", "centripetalnet/centripetalnet_hourglass104_16xb6-crop511-210e-mstest_coco.py", - "centripetalnet_hourglass104_mstest_16x6_210e_coco_20200915_204804-3ccc61e5.pth"), - ("yolact", "yolact/yolact_r50_1xb8-55e_coco.py", - "yolact_r50_1x8_coco_20200908-f38d58df.pth"), - ("condinst", "condinst/condinst_r50_fpn_ms-poly-90k_coco_instance.py", - "condinst_r50_fpn_ms-poly-90k_coco_instance_20221129_125223-4c186406.pth"), - ("boxinst", "boxinst/boxinst_r50_fpn_ms-90k_coco.py", - "boxinst_r50_fpn_ms-90k_coco_20221228_163052-6add751a.pth"), - - # DETR 계열 — transformer decoder 라 conv 타워 구조 자체가 없다. 별개 작업이다. - ("detr", "detr/detr_r50_8xb2-150e_coco.py", - "detr_r50_8xb2-150e_coco_20221023_153551-436d03e8.pth"), - ("conditional_detr", "conditional_detr/conditional-detr_r50_8xb2-50e_coco.py", - "conditional-detr_r50_8xb2-50e_coco_20221121_180202-c83a1dc0.pth"), - ("dab_detr", "dab_detr/dab-detr_r50_8xb2-50e_coco.py", - "dab-detr_r50_8xb2-50e_coco_20221122_120837-c1035c8c.pth"), - ("deformable_detr", "deformable_detr/deformable-detr_r50_16xb2-50e_coco.py", - "deformable-detr_r50_16xb2-50e_coco_20221029_210934-6bc7d21b.pth"), - ("dino", "dino/dino-4scale_r50_8xb2-12e_coco.py", - "dino-4scale_r50_8xb2-12e_coco_20221202_182705-55b2bba2.pth"), - ("ddq", "ddq/ddq-detr-4scale_r50_8xb2-12e_coco.py", - "ddq-detr-4scale_r50_8xb2-12e_coco_20230809_170711-42528127.pth"), -] +# 손목록은 `mmdet_families.OVERRIDE` 로 옮겼다 — two-stage 하네스도 같은 것을 봐야 한다. # 설정은 **파일**에서 온다(`verify.toml`). 환경변수로 받으면 어떤 값으로 잰 숫자인지 # 로그에 안 남아 재현이 안 된다. 덮어쓰려면 `--set run.workers=2` 처럼 준다 — 그것도 찍힌다. # ⚠️ **파이프로 보내면 블록 버퍼링**이라 30분간 아무것도 안 보이고, 중간에 죽으면 통째로 @@ -152,7 +49,7 @@ def _all_families(): - override = {f[0]: f for f in FAMILIES} + override = {f: (f, c, k) for f, (c, k) in mmdet_families.OVERRIDE.items()} out = [] for name, cfg, ckpt in mmdet_families.families(MM): if name in override: diff --git a/tools/verify/roi/verify_postproc_roi.py b/tools/verify/roi/verify_postproc_roi.py index 00e2cb2..b3b00a4 100644 --- a/tools/verify/roi/verify_postproc_roi.py +++ b/tools/verify/roi/verify_postproc_roi.py @@ -106,7 +106,19 @@ def last_error(text): ds = DetDataSample(); ds.set_metainfo(meta) with torch.no_grad(): try: - res = det.predict(t, [ds], rescale=False)[0].pred_instances + if getattr(det, "rpn_head", None) is None: + # ⚠️ **proposal 을 밖에서 받는 계열**(FastRCNN). `predict` 는 proposal 을 + # 데이터에서 기대하므로 못 부른다. 러너와 **같은 파일**을 읽어 + # `roi_head.predict` 에 직접 넣는다 — 각자 만들면 proposal 생성기를 재게 된다. + from mmengine.structures import InstanceData + pb = np.fromfile(os.environ["FRCNN_PROPOSALS"], dtype="float32").reshape(-1, 4) + pr = InstanceData(metainfo=meta) + pr.bboxes = torch.from_numpy(pb.copy()) + pr.scores = torch.ones(len(pb)) + pr.labels = torch.zeros(len(pb), dtype=torch.long) + res = det.roi_head.predict(det.extract_feat(t), [pr], [ds], rescale=False)[0] + else: + res = det.predict(t, [ds], rescale=False)[0].pred_instances except AttributeError: # ⚠️ **`predict` 가 박스를 안 내는 계열이 있다.** PanopticFPN 은 파놉틱 융합까지 하고 # `pred_panoptic_seg` 만 남겨 `pred_instances` 가 없다. 그렇다고 "측정 불가" 로 @@ -207,12 +219,13 @@ def _one(fam, size, image, workdir, keep, verbose): os.makedirs(d, exist_ok=True) open(os.path.join(d, "_stub.py"), "w").write(STUB) - cfg_rel, ckpt_name, _ = MF.resolve(CFGS, fam) - if not cfg_rel: + # ⚠️ **짝은 공용 자리에서 받는다**(`mmdet_families.resolve_pair`). 예전엔 이 하네스만 + # metafile 을 보고 one-stage 는 손목록을 봐서 **같은 계열이 다르게 풀렸다.** + cfg, ckpt_name = MF.resolve_pair(CFGS, fam) + if not cfg: return fam, "CONFIG_NONE", "-", None - cfg = os.path.join(CFGS, cfg_rel) if not ckpt_name: - return fam, "CKPT_NONE", f"metafile 에 가중치 없음 ({cfg_rel})", None + return fam, "CKPT_NONE", "가중치 없음 (metafile·손목록 둘 다)", None ckpt = os.path.join(CKPTS, ckpt_name) if not os.path.exists(ckpt): return fam, "CKPT_MISSING", ckpt_name, None @@ -260,6 +273,23 @@ def _one(fam, size, image, workdir, keep, verbose): kind = "UNSUPPORTED" if "NotImplementedError" in (r.stderr or "") else "EXPORT_FAIL" return fam, kind, err[:110], None J = json.load(open(os.path.join(fr, "frcnn.json"))) + # ⚠️ **proposal 을 밖에서 받는 계열**(FastRCNN)은 우리가 넣어 줘야 한다. mmdet 은 + # proposal 파일을 안 배포하고, 이 계열은 RPN 설정 자체가 없다. + # **RPN 출력을 쓰면 그 순간 faster_rcnn 을 재는 것**이 되므로(같은 백본·넥·RoI head), + # 독립적인 증거가 되도록 **고정 격자**를 쓴다. 결정적이라 재현되고, + # RoIAlign+RoI head 를 proposal 생성기와 분리해서 본다. + # ⚠️ 러너와 기준값이 **같은 파일**을 읽는다 — 각자 만들면 생성기를 재게 된다. + prop_env = {} + if J.get("external_proposals"): + import numpy as _np + # 개수는 `frcnn.json` 이 정한다(SubB 가 그 수로 구워졌다). 격자 한 변은 √N. + want = int(J.get("rpn_max") or 64) + n = int(round(want ** 0.5)); step = size / n + boxes = [[c * step, rr * step, (c + 2) * step, (rr + 2) * step] + for rr in range(n) for c in range(n)][:want] + pf = os.path.join(fr, "proposals.bin") + _np.asarray(boxes, dtype="float32").clip(0, size).tofile(pf) + prop_env = {"FRCNN_PROPOSALS": pf} O, RC = int(J["roi_out"]), int(J.get("roi_channels", 256)) MX = int(J["rpn_max"]) # Double-Head 는 (cls용, reg용) 두 벌을 배치로 이어 넣는다 → 배치가 2배다. @@ -363,7 +393,7 @@ def _one(fam, size, image, workdir, keep, verbose): while len(argv) < 9: argv.append("") argv.append("out_GridRCNN_SubE/GridRCNN_SubE.gguf") - rr = run(argv, fr, {"VISP_BACKEND": "cpu"}) + rr = run(argv, fr, {"VISP_BACKEND": "cpu", **prop_env}) if not os.path.exists(pref + ".boxes.bin"): return fam, "RUN_FAIL", last_error(rr.stderr)[:110], None got = np.fromfile(pref + ".boxes.bin", dtype="float32").reshape(-1, 6) @@ -372,7 +402,8 @@ def _one(fam, size, image, workdir, keep, verbose): # ⑥ mmdet 기준값 open(os.path.join(d, "ref.py"), "w").write(REF % {"FE": FE}) refnpy = os.path.join(d, "ref.npy") - q = run([PY, "ref.py", cfg, ckpt, str(size), npy, refnpy], d, {"PYTHONPATH": f"{d}:{FE}"}) + q = run([PY, "ref.py", cfg, ckpt, str(size), npy, refnpy], d, + {"PYTHONPATH": f"{d}:{FE}", **prop_env}) if "REF_OK" not in (q.stdout or ""): return fam, "REF_FAIL", last_error(q.stderr)[:110], None ref = np.load(refnpy) From 510411c8c9e35755e5f046f9483be31cbc5f789f Mon Sep 17 00:00:00 2001 From: eunchae Date: Wed, 19 Aug 2026 15:10:01 +0900 Subject: [PATCH 80/89] =?UTF-8?q?feat(groie):=20GenericRoIExtractor=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=E2=80=94=20=EB=82=A8=EC=9D=80=20=EB=B2=BD?= =?UTF-8?q?=EC=9D=80=20=EB=A0=8C=EB=8D=94=EB=9F=AC=EC=9D=98=205D=20broadca?= =?UTF-8?q?st?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit groie 는 RPN·RoI head 가 둘 다 표준이고 extractor 만 다르다. 레벨을 고르는 대신 **전 레벨에 RoIAlign 을 걸고** 레벨마다 5x5 conv+ReLU 를 태운 뒤 더한다. conv+ReLU 가 비선형이라 '합쳐서 한 번' 으로 접을 수 없다. 그래프 입력이 하나뿐이므로 Double-Head 와 같은 수법을 썼다 — 러너가 레벨별 RoI feature 를 배치로 이어붙이고, SubB 가 그 안에서 pre→합산→post 를 한다. 레벨 수·레벨당 RoI 수는 export 시점 상수로 박는다(n_half 와 같은 이유 — shape 에서 뽑으면 렌더러가 슬라이스 시작을 몰라 offset 0 으로 폴백한다). roi_align_params.force_level 레벨을 고르지 않고 강제 (-1 이면 평소대로) frcnn.json groie_levels 러너·하네스가 배치 배수를 안다 SubB groie_pre/post pre 는 레벨 공통이라 (L*N) 배치에 한 번만 생성 코드 확인: sl14 / sl15(offset 1000) / sl17(2000) / sl19(3000) 을 차례로 더한다 — 이 부분은 정확히 렌더링된다. WARN 아직 통과 못 한다. 남은 벽은 extractor 가 아니라 GeneralizedAttention(post)의 위치 임베딩 broadcast 가 5차원이라는 것이다. ggml 은 4D 까지라 렌더러가 ggml_cont 로 떨어뜨리고(생성 코드에 repeat [1000,6,7,4,42] 주석이 남는다) 뒤의 add 가 GGML_ASSERT(ggml_can_repeat) 로 죽는다. 이건 하네스가 아니라 컴파일러(렌더러) 작업이다. 회귀: faster_rcnn 0.10 / htc 0.12 / fast_rcnn 0.01px 동일, double_heads 도 기록대로. Co-Authored-By: Claude Opus 5 (1M context) --- src/visp/postproc.cpp | 1 + src/visp/postproc.h | 4 +++ tools/frontend/mmdet/frcnn_wrap.py | 40 +++++++++++++++++++++---- tools/verify/backbone/run_frcnn.cpp | 23 ++++++++++++-- tools/verify/roi/verify_postproc_roi.py | 7 ++++- 5 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/visp/postproc.cpp b/src/visp/postproc.cpp index 1c6f0a6..fe02fd2 100755 --- a/src/visp/postproc.cpp +++ b/src/visp/postproc.cpp @@ -669,6 +669,7 @@ std::vector roi_align( int lvl = (int)std::floor(std::log2(scale / p.finest_scale + 1e-6f)); if (lvl < 0) lvl = 0; if (lvl > L - 1) lvl = L - 1; + if (p.force_level >= 0) lvl = std::min(p.force_level, L - 1); float ss = 1.0f / p.strides[lvl]; int H = feat_hw[lvl].first, W = feat_hw[lvl].second; float const* feat = feats[lvl].data(); diff --git a/src/visp/postproc.h b/src/visp/postproc.h index 87fd70b..12598eb 100755 --- a/src/visp/postproc.h +++ b/src/visp/postproc.h @@ -371,6 +371,10 @@ struct roi_align_params { float finest_scale = 56.0f; bool aligned = true; int sampling_ratio = 0; // 0 → ceil(roi/out) 적응 + // ⚠️ **레벨을 고르지 않고 강제한다**(-1 이면 평소대로 박스 크기로 고른다). + // GenericRoIExtractor(groie)는 박스마다 레벨 하나를 고르는 게 아니라 + // **전 레벨에 다 RoIAlign 을 걸고 더한다** — 러너가 레벨마다 한 번씩 부른다. + int force_level = -1; }; // feats[l] = [C, W, H] CWHN flat · rois[M*4] 이미지좌표. // 반환 roi_feat [M*C*out*out] = NCHW flat(idx=((m*C+c)*out+ph)*out+pw). (SubB 입력용으로 permute 는 러너가) diff --git a/tools/frontend/mmdet/frcnn_wrap.py b/tools/frontend/mmdet/frcnn_wrap.py index f78ce65..a2b9d51 100755 --- a/tools/frontend/mmdet/frcnn_wrap.py +++ b/tools/frontend/mmdet/frcnn_wrap.py @@ -101,6 +101,22 @@ def __init__(self, det, stage=None): # 폴백**한다 — 두 슬라이스가 같은 자리를 읽어 확대판 RoI 가 무시된다(L1 0.740). # proposal 개수는 `test_cfg.rpn.max_per_img` 로 이미 정해져 있다. self.n_half = int(det.test_cfg.rpn.max_per_img) if self.two_in else 0 + # ⚠️ **GenericRoIExtractor(groie)는 레벨을 고르지 않는다** — 전 레벨에 RoIAlign 을 + # 걸고, 레벨마다 5x5 conv+ReLU 를 통과시킨 뒤 **더한다**. conv+ReLU 는 비선형이라 + # 합과 교환이 안 되므로 "합쳐서 한 번" 으로 접을 수 없다. + # 그래프 입력이 하나뿐이므로 Double-Head 와 **같은 수법**을 쓴다 — 러너가 레벨별 + # RoI feature 를 **배치로 이어붙여** 넣고 여기서 가른다. + # pre 는 레벨 공통(가중치 하나)이라 (L*N) 배치에 한 번만 걸면 된다. + ext = det.roi_head.bbox_roi_extractor + if isinstance(ext, nn.ModuleList): + ext = ext[0] + self.groie_pre = getattr(ext, "pre_module", None) if getattr(ext, "with_pre", False) else None + self.groie_post = getattr(ext, "post_module", None) if getattr(ext, "with_post", False) else None + self.groie_agg = getattr(ext, "aggregation", None) if type(ext).__name__ == "GenericRoIExtractor" else None + # 레벨 수와 레벨당 RoI 수를 **export 시점 상수**로 박는다. shape 에서 뽑으면 + # trace 가 실행 중 값으로 봐서 렌더러가 슬라이스 시작을 모른다(n_half 와 같은 함정). + self.groie_lvls = len(getattr(ext, "featmap_strides", []) or []) if self.groie_agg else 0 + self.groie_n = int(det.test_cfg.rpn.max_per_img) if self.groie_agg else 0 _fold_normed_linear(self.bbox_head) def forward(self, roi_feat): @@ -109,6 +125,17 @@ def forward(self, roi_feat): # 불러오기는 **새 서브프로세스**(새 코드)가 한다. 코드를 고치는 순간 그 사이가 # 갈라져 `AttributeError: no attribute 'two_in'` 이 난다(스윕 도중 실측). # 새 속성은 항상 `getattr(..., 기본값)` 으로 읽는다. + # groie: (L*N, C, 7, 7) 로 들어온다 → pre 한 번 → 레벨별로 갈라 합산 → post + if getattr(self, "groie_agg", None) is not None: + if getattr(self, "groie_pre", None) is not None: + roi_feat = self.groie_pre(roi_feat) + n, L = self.groie_n, self.groie_lvls + acc = roi_feat[:n] + for i in range(1, L): + acc = acc + roi_feat[i * n:(i + 1) * n] + roi_feat = acc + if getattr(self, "groie_post", None) is not None: + roi_feat = self.groie_post(roi_feat) if getattr(self, "shared_head", None) is not None: roi_feat = self.shared_head(roi_feat) if getattr(self, "two_in", False): @@ -247,10 +274,8 @@ def frcnn_cfg(det, size=800): # ⚠️ bbox extractor 가 `GenericRoIExtractor` 면 **레벨을 고르지 않고 전 레벨을 합친다** # (게다가 groie 는 레벨마다 5x5 conv + GeneralizedAttention 을 건다). 호스트 # RoIAlign 은 그걸 표현 못 한다 — 조용히 레벨 선택으로 떨어뜨리면 값만 틀린다. - if type(ext).__name__ == "GenericRoIExtractor": - raise NotImplementedError( - "GenericRoIExtractor(bbox): 전 레벨 집계 + pre/post 모듈이라 호스트 RoIAlign 으로 " - "표현 못 한다. 레벨별 conv 가 그래프에 들어가야 한다") + # GenericRoIExtractor(groie)는 이제 지원한다 — 러너가 레벨별로 RoIAlign 을 걸어 + # 배치로 이어붙이고, SubB 가 pre→합산→post 를 그래프 안에서 한다. bh = det.roi_head.bbox_head # 캐스케이드는 bbox_head 도 ModuleList 다. 클래스 수·coder 종류는 단계 공통이라 # **마지막 단계**를 쓴다(최종 박스를 내는 단계라 디코드 규약이 거기 맞춰져 있다). @@ -381,6 +406,9 @@ def frcnn_cfg(det, size=800): # ⚠️ proposal 을 밖에서 받는 계열인가(FastRCNN). 러너는 이 값이 참이면 # RPN 계산을 건너뛰고 proposal 파일을 읽는다. "external_proposals": rh is None, + # groie: 러너가 레벨마다 RoIAlign 을 걸어 배치로 이어붙여야 한다(0 이면 평소대로). + "groie_levels": len(getattr(ext, "featmap_strides", []) or []) + if type(ext).__name__ == "GenericRoIExtractor" else 0, # ⚠️ 아래 셋은 **기본값이 아닌 계열이 있다**(crowddet 이 셋 다 다르다). # 전부 shape 를 안 바꾸므로 빼먹으면 크래시 없이 proposal 만 달라진다. # `centers` — 주어지면 그 값이 base anchor 중심이고 center_offset 은 무시된다. @@ -402,7 +430,9 @@ def frcnn_cfg(det, size=800): "roi_channels": int(ext.out_channels), "roi_out": int(ext.roi_layers[0].output_size[0]), "roi_strides": [int(s) for s in ext.featmap_strides], - "roi_finest_scale": int(ext.finest_scale), + # ⚠️ `GenericRoIExtractor` 에는 `finest_scale` 이 없다 — 레벨을 고르지 않으니 + # 있을 이유가 없다(마스크 경로 314줄에 같은 함정이 이미 적혀 있다). + "roi_finest_scale": int(getattr(ext, "finest_scale", 56)), "roi_sampling_ratio": int(ext.roi_layers[0].sampling_ratio), "roi_aligned": bool(ext.roi_layers[0].aligned), # RCNN decode (detect_roi) diff --git a/tools/verify/backbone/run_frcnn.cpp b/tools/verify/backbone/run_frcnn.cpp index 79daaf4..af30a49 100644 --- a/tools/verify/backbone/run_frcnn.cpp +++ b/tools/verify/backbone/run_frcnn.cpp @@ -268,7 +268,24 @@ int main(int argc, char** argv) { ap.finest_scale = J.num("roi_finest_scale", 56.0f); ap.sampling_ratio = (int)J.num("roi_sampling_ratio", 0); ap.aligned = J.num("roi_aligned", 1.0f) != 0.0f; - std::vector roi = roi_align(feats, feat_hw, props.data(), M, ap); + // ⚠️ **groie 는 레벨을 고르지 않는다.** GenericRoIExtractor 는 전 레벨에 RoIAlign 을 + // 걸고, 레벨마다 5x5 conv+ReLU 를 태운 뒤 더한다(conv+ReLU 가 비선형이라 합과 + // 교환이 안 되므로 "합쳐서 한 번" 으로 못 접는다). 그래프 입력이 하나뿐이라 + // Double-Head 와 같은 수법으로 **레벨별 결과를 배치로 이어붙여** 넘기고, + // pre→합산→post 는 SubB 가 그래프 안에서 한다. + const int groie_lv = (int)J.num("groie_levels", 0.0f); + std::vector roi; + if (groie_lv > 0) { + roi.reserve((size_t)groie_lv * M * ap.channels * ap.output_size * ap.output_size); + for (int l = 0; l < groie_lv; ++l) { + roi_align_params lp = ap; + lp.force_level = l; + std::vector one = roi_align(feats, feat_hw, props.data(), M, lp); + roi.insert(roi.end(), one.begin(), one.end()); + } + } else { + roi = roi_align(feats, feat_hw, props.data(), M, ap); + } // ── HTC: 시맨틱 feature 를 RoIAlign 해서 bbox_feats 에 **더한다** ────────── // mmdet `htc_roi_head.py:99-104`. 안 더하면 크래시 없이 박스만 밀린다 @@ -382,7 +399,9 @@ int main(int argc, char** argv) { fuse_glbctx(base_st, M); } std::vector roi_st = with_reg_half(base_st, (st == 0) ? props : rois); - const int MB = (RSF > 0.0f) ? 2 * M : M; // SubB 에 넣는 행 수 + // SubB 에 넣는 행 수. Double-Head 는 2배(cls+reg), groie 는 레벨 배다 — + // 둘 다 "그래프 입력이 하나뿐이라 배치로 이어붙인다" 는 같은 이유다. + const int MB = (RSF > 0.0f) ? 2 * M : (groie_lv > 0 ? groie_lv * M : M); model_file fb = model_load(gbs[st].c_str()); model_weights wb = model_init(fb.n_tensors()); diff --git a/tools/verify/roi/verify_postproc_roi.py b/tools/verify/roi/verify_postproc_roi.py index b3b00a4..179c8fd 100644 --- a/tools/verify/roi/verify_postproc_roi.py +++ b/tools/verify/roi/verify_postproc_roi.py @@ -302,7 +302,12 @@ def _one(fam, size, image, workdir, keep, verbose): # batch=1 로 구우면 1000개를 넣을 때 reshape 이 안 맞아 죽는다. jobs = [("FRCNN_SubA", "FRCNN_SubA", "out_FRCNN_SubA", f"1,3,{size},{size}")] # 캐스케이드는 단계마다 가중치만 다르므로 그래프 이름을 subs[0] 으로 통일해 gguf 만 갈아 낀다. - jobs += [(s, subs[0], "out_" + s, f"{MX},{RC},{O},{O}") for s in subs] + # ⚠️ **groie 는 배치가 레벨 배다.** GenericRoIExtractor 는 전 레벨에 RoIAlign 을 걸어 + # 레벨별 결과를 배치로 이어붙여 넘긴다(SubB 안에서 pre→합산→post). N 으로 구우면 + # 슬라이스가 빈 텐서가 되어 "tensor a (1000) vs b (0)" 로 죽는다. + GL = int(J.get("groie_levels") or 0) + MB = MX * GL if GL > 0 else MX + jobs += [(s, subs[0], "out_" + s, f"{MB},{RC},{O},{O}") for s in subs] # Mask Scoring R-CNN 은 점수를 마스크 IoU 로 다시 매긴다 → 그래프가 둘 더 필요하다. # SubC = mask head (1, 256, 14, 14) → 마스크 로짓 (1, 80, 28, 28) # SubD = mask-IoU head (1, 257, 14, 14) → 클래스별 IoU (1, 80) From 3bb837c9075b533b490408b8b2be0f00262d80af Mon Sep 17 00:00:00 2001 From: eunchae Date: Wed, 19 Aug 2026 15:21:18 +0900 Subject: [PATCH 81/89] =?UTF-8?q?feat(proposal):=20=EB=B9=84=ED=91=9C?= =?UTF-8?q?=EC=A4=80=20RPN=20=EB=91=90=20=EA=B3=84=EC=97=B4=20=E2=80=94=20?= =?UTF-8?q?guided=5Fanchoring=200.02px=20/=20cascade=5Frpn=200.03px=20(83?= =?UTF-8?q?=EA=B3=84=EC=97=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GARPNHead(앵커 모양 예측)·CascadeRPNHead(다단계 정제)는 호스트 rpn_proposals 로 앵커를 못 깐다. 그런데 **RoI head 는 표준**이라 proposal 만 밖에서 받으면 나머지 경로는 그대로 잰다 — 거절하는 대신 외부 proposal 계약으로 돌린다. proposal 은 그 계열 **자신의 rpn_head** 가 torch 에서 낸 것을 쓴다. fast_rcnn 처럼 고정 격자를 쓰면 안 된다 — 거긴 RPN 이 아예 없어서 낼 주체가 없었고, 여긴 있다. frcnn.json 의 own_rpn 이 그 둘을 가른다. WARN 이 숫자는 'RPN 까지 검증됐다'가 아니다. RPN 은 torch 에서 돈다 — 검증된 것은 백본·넥·RoIAlign·RoI head·디코드다. 문서에 그렇게 적는다. MaskedConv2d 를 CPU 등가로 대체했다. GARPNHead 가 쓰는데 CUDA 커널만 있어 masked_im2col_forward_impl 로 죽는다. 이 연산은 정확도가 아니라 속도 최적화라 (마스크 1인 자리만 계산, 나머지 0) dense conv * mask 와 값이 같다. CascadeRPNHead 는 meta 에 pad_shape 를 요구한다 — 다른 RPN 은 안 읽어서 여태 없었다. WARN 도중에 내 원칙을 내 코드가 어겼다. 기준값이 proposal 파일을 안 읽고 자기 RPN 을 다시 돌려 **양쪽이 다른 proposal 로 재고** 있었다(7.22px). rpn_head 유무로 가르던 조건을 '파일이 있으면 무조건'으로 고치니 0.02px. sparse_rcnn/queryinst 은 이 경로로 안 열린다 — test_cfg 가 {rcnn: {max_per_img: 100}} 뿐이고(score_thr·NMS 없음) SparseRoIHead 6단계 DIIHead 가 DETR 처럼 디코드한다. 새 조립기가 필요하다. Co-Authored-By: Claude Opus 5 (1M context) --- tools/frontend/mmdet/frcnn_wrap.py | 22 ++++++-- tools/frontend/mmdet/mmdet_compat.py | 33 ++++++++++++ tools/verify/roi/verify_postproc_roi.py | 69 ++++++++++++++++++++++--- 3 files changed, 111 insertions(+), 13 deletions(-) diff --git a/tools/frontend/mmdet/frcnn_wrap.py b/tools/frontend/mmdet/frcnn_wrap.py index a2b9d51..e22f682 100755 --- a/tools/frontend/mmdet/frcnn_wrap.py +++ b/tools/frontend/mmdet/frcnn_wrap.py @@ -41,7 +41,10 @@ def __init__(self, det): # `rpn_head` 를 아예 안 갖는다(config 에 `test_cfg.rpn` 도 없다). 없는 게 # 결함이 아니라 그 계열의 정의다 — SubA 는 백본+넥까지만 내고, proposal 은 # 러너가 파일에서 읽는다. - self.rpn_head = getattr(det, "rpn_head", None) + rh = getattr(det, "rpn_head", None) + # 표준 앵커 RPN 이 아니면 그래프에 안 넣는다(호스트가 디코드를 못 하므로 무의미하다). + # proposal 은 밖에서 온다. `frcnn_cfg` 의 판정과 **같은 조건**을 쓴다. + self.rpn_head = rh if (rh is not None and hasattr(rh, "prior_generator")) else None self.n_roi = _n_roi_levels(det) # HTC 계열의 **시맨틱 갈래**. FPN 위에 FCN 을 따로 돌려 나온 융합 feature 를 # RoIAlign 해서 `bbox_feats` 에 더한다(htc_roi_head.py:99-104). @@ -260,11 +263,17 @@ def frcnn_cfg(det, size=800): # (strides, scale, ratios) 로 앵커를 깔아 디코드하는 구조라 그대로는 못 쓴다. # **`AttributeError` 로 흘려보내지 말고** 왜 안 되는지 말한다 — 그래야 "미지원" 과 # "우리 버그" 가 구분된다. + # ⚠️ 표준 앵커 RPN 이 아니면 **거절하지 않고 외부 proposal 로 돌린다.** + # CascadeRPNHead=다단계 정제 · GARPNHead=앵커 모양 예측 · EmbeddingRPNHead=학습된 + # proposal — 셋 다 호스트 `rpn_proposals` 로는 못 깐다. 그런데 **RoI head 는 표준**이라 + # proposal 만 밖에서 받으면 나머지 경로는 그대로 잰다. proposal 은 그 계열 **자신의 + # rpn_head** 가 torch 에서 낸 것을 쓰고, 러너와 기준값이 **같은 파일**을 읽는다. + # ⚠️ 그래서 이 계열의 숫자는 "RPN 까지 검증됐다" 가 아니다 — RPN 은 torch 에서 돈다. + # 갈래가 둘이다 — RPN 이 **없는** 계열(FastRCNN)과 **비표준인** 계열(GA·CascadeRPN). + # 앞은 낼 주체가 없어 고정 격자를, 뒤는 자기 rpn_head 가 낸 것을 써야 한다. + has_own_rpn = rh is not None if rh is not None and not hasattr(rh, "prior_generator"): - raise NotImplementedError( - f"{type(rh).__name__}: 표준 앵커 RPN 이 아니다. " - "CascadeRPNHead=다단계 정제(stages), GARPNHead=앵커 모양을 예측, " - "EmbeddingRPNHead=학습된 proposal — 각각 호스트 proposal 생성이 달라진다") + rh = None # 아래부터 외부 proposal 계약으로 간다 pg = rh.prior_generator if rh is not None else None bc = rh.bbox_coder if rh is not None else None ext = det.roi_head.bbox_roi_extractor @@ -406,6 +415,9 @@ def frcnn_cfg(det, size=800): # ⚠️ proposal 을 밖에서 받는 계열인가(FastRCNN). 러너는 이 값이 참이면 # RPN 계산을 건너뛰고 proposal 파일을 읽는다. "external_proposals": rh is None, + # 비표준 RPN 이라 외부로 돌린 것인가(참) vs RPN 이 아예 없는 것인가(거짓). + # 참이면 하네스가 **그 계열 자신의 rpn_head** 로 proposal 을 뽑는다. + "own_rpn": rh is None and has_own_rpn, # groie: 러너가 레벨마다 RoIAlign 을 걸어 배치로 이어붙여야 한다(0 이면 평소대로). "groie_levels": len(getattr(ext, "featmap_strides", []) or []) if type(ext).__name__ == "GenericRoIExtractor" else 0, diff --git a/tools/frontend/mmdet/mmdet_compat.py b/tools/frontend/mmdet/mmdet_compat.py index d99b9c8..f376dfc 100644 --- a/tools/frontend/mmdet/mmdet_compat.py +++ b/tools/frontend/mmdet/mmdet_compat.py @@ -157,9 +157,42 @@ def sigmoid_geometric_mean(x, y): _patch_carafe() _patch_swin_mask() _patch_sac_dilation() + _patch_masked_conv() +def _patch_masked_conv(): + """`MaskedConv2d` 를 CPU 에서도 돌게 한다 (guided_anchoring 의 GARPNHead). + + ⚠️ `masked_conv2d` 는 **CUDA 커널만** 있어 CPU 에서 + `masked_im2col_forward_impl: implementation for device cpu not found` 로 죽는다. + 그런데 이 연산은 정확도를 위한 게 아니라 **속도 최적화**다 — 마스크가 1인 자리만 + 계산하고 나머지는 0으로 둔다. 즉 `dense conv * mask` 와 값이 같다. + 등가 대체이므로 수치가 달라지지 않는다(달라지면 그건 이 가정이 틀린 것이다). + + mask 가 None 이면 원래도 평범한 Conv2d 로 떨어지므로 그 경로는 안 건드린다. + """ + try: + from mmcv.ops import MaskedConv2d + except ImportError: + return + if getattr(MaskedConv2d.forward, "__module__", "") == __name__: + return + + import torch.nn as _nn + + def forward(self, x, mask=None): + out = _nn.Conv2d.forward(self, x) + if mask is None: + return out + # mask: (B, H, W) 또는 (B, 1, H, W) — 채널 축으로 브로드캐스트한다. + m = mask if mask.dim() == 4 else mask.unsqueeze(1) + return out * m.to(out.dtype) + + forward.__module__ = __name__ + MaskedConv2d.forward = forward + + def _patch_sac_dilation(): """SAC(DetectoRS)의 dilation-3 deform conv 를 **오프셋 상수 이동**으로 등가 변환한다. diff --git a/tools/verify/roi/verify_postproc_roi.py b/tools/verify/roi/verify_postproc_roi.py index 179c8fd..8f8f67a 100644 --- a/tools/verify/roi/verify_postproc_roi.py +++ b/tools/verify/roi/verify_postproc_roi.py @@ -106,7 +106,11 @@ def last_error(text): ds = DetDataSample(); ds.set_metainfo(meta) with torch.no_grad(): try: - if getattr(det, "rpn_head", None) is None: + # ⚠️ **proposal 파일이 있으면 무조건 그것을 쓴다.** rpn_head 유무로 가르면, + # 비표준 RPN 계열(GA·CascadeRPN)에서 기준값만 자기 RPN 을 다시 돌려 + # **양쪽이 다른 proposal 로 재게 된다**(실측: guided_anchoring 7.22px). + # 러너와 기준값은 같은 파일을 읽어야 비교가 성립한다. + if os.environ.get("FRCNN_PROPOSALS"): # ⚠️ **proposal 을 밖에서 받는 계열**(FastRCNN). `predict` 는 proposal 을 # 데이터에서 기대하므로 못 부른다. 러너와 **같은 파일**을 읽어 # `roi_head.predict` 에 직접 넣는다 — 각자 만들면 proposal 생성기를 재게 된다. @@ -164,6 +168,45 @@ def last_error(text): ''' +# 계열 자신의 rpn_head 로 proposal 을 뽑는다. 러너·기준값이 **이 파일 하나**를 읽는다. +PROPS = r''' +import _stub, os, sys, numpy as np, torch +from PIL import Image +cfg, ckpt, size, img, out, want = sys.argv[1], sys.argv[2], int(sys.argv[3]), sys.argv[4], sys.argv[5], int(sys.argv[6]) +sys.path.insert(0, "%(FE)s") +try: + import mmdet_wrap; mmdet_wrap.allow_mmengine_checkpoint_globals() + # ⚠️ GARPNHead 의 MaskedConv2d 는 CUDA 커널만 있다 — CPU 등가 대체를 건다. + mmdet_wrap.trace_friendly_ops() +except Exception: + pass +from mmdet.apis import init_detector +from frcnn_to_pt import _desync_norm +from mmdet.structures import DetDataSample +det = init_detector(_desync_norm(cfg), ckpt, device="cpu"); det.eval() +dp = det.data_preprocessor +mean = dp.mean.view(3).numpy() if hasattr(dp, "mean") else np.zeros(3, "float32") +std = dp.std.view(3).numpy() if hasattr(dp, "std") else np.ones(3, "float32") +to_rgb = bool(getattr(dp, "_channel_conversion", False)) +im = np.asarray(Image.open(img).convert("RGB").resize((size, size), Image.BILINEAR), dtype="float32") +x = im if to_rgb else im[:, :, ::-1] +t = torch.from_numpy(np.ascontiguousarray((np.ascontiguousarray(x) - mean) / std).astype("float32")).permute(2,0,1)[None] +# ⚠️ `CascadeRPNHead` 는 `pad_shape` 를 읽는다 — 다른 RPN 은 안 읽어서 여태 없었다. +# 우리는 정사각 한 번 리사이즈라 패딩이 없으므로 img_shape 과 같다. +ds = DetDataSample(); ds.set_metainfo({"img_shape": (size, size), "ori_shape": (size, size), + "pad_shape": (size, size), "scale_factor": (1.0, 1.0), "batch_input_shape": (size, size)}) +with torch.no_grad(): + feats = det.extract_feat(t) + pr = det.rpn_head.predict(feats, [ds], rescale=False)[0] +b = pr.bboxes.numpy()[:want] +# ⚠️ **개수를 상한까지 채운다.** SubB 가 그 행 수로 구워져 있어 모자라면 reshape 이 죽는다. +if len(b) < want: + b = np.vstack([b, np.zeros((want - len(b), 4), "float32")]) +np.ascontiguousarray(b, dtype="float32").tofile(out) +print("PROPS_OK", len(b)) +''' + + def match(ref, got): """`dense_head/verify_postproc.match` 와 **같은 짝짓기**. 두 도구의 숫자를 나란히 놓기 위해서다. @@ -281,14 +324,24 @@ def _one(fam, size, image, workdir, keep, verbose): # ⚠️ 러너와 기준값이 **같은 파일**을 읽는다 — 각자 만들면 생성기를 재게 된다. prop_env = {} if J.get("external_proposals"): - import numpy as _np - # 개수는 `frcnn.json` 이 정한다(SubB 가 그 수로 구워졌다). 격자 한 변은 √N. - want = int(J.get("rpn_max") or 64) - n = int(round(want ** 0.5)); step = size / n - boxes = [[c * step, rr * step, (c + 2) * step, (rr + 2) * step] - for rr in range(n) for c in range(n)][:want] pf = os.path.join(fr, "proposals.bin") - _np.asarray(boxes, dtype="float32").clip(0, size).tofile(pf) + want = int(J.get("rpn_max") or 64) + if J.get("own_rpn"): + # **그 계열 자신의 RPN** 이 낸 proposal 을 쓴다(GARPNHead·CascadeRPNHead). + # 호스트가 그 RPN 을 못 깔 뿐, proposal 자체는 이 모델의 것이 정본이다. + open(os.path.join(d, "props.py"), "w").write(PROPS % {"FE": FE}) + rp = run([PY, "props.py", cfg, ckpt, str(size), image, pf, str(want)], d, + {"PYTHONPATH": f"{d}:{FE}"}) + if not os.path.exists(pf): + return fam, "PROPS_FAIL", last_error(rp.stderr)[:110], None + else: + # RPN 이 **아예 없는** 계열(FastRCNN). 낼 주체가 없으므로 고정 격자를 쓴다 — + # 다른 계열의 RPN 을 빌리면 그 계열을 재는 것이 된다. + import numpy as _np + n = int(round(want ** 0.5)); step = size / n + boxes = [[c * step, rr * step, (c + 2) * step, (rr + 2) * step] + for rr in range(n) for c in range(n)][:want] + _np.asarray(boxes, dtype="float32").clip(0, size).tofile(pf) prop_env = {"FRCNN_PROPOSALS": pf} O, RC = int(J["roi_out"]), int(J.get("roi_channels", 256)) MX = int(J["rpn_max"]) From 92fc69c9ba908629a64cff84f0342273e1d84639 Mon Sep 17 00:00:00 2001 From: eunchae Date: Wed, 19 Aug 2026 15:55:28 +0900 Subject: [PATCH 82/89] =?UTF-8?q?fix(refusal):=20=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EA=B3=84=EC=97=B4=EC=9D=B4=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=B4=EC=9C=A0=EB=A5=BC=20=EB=A7=90=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=E2=80=94=20=EB=82=B4=EA=B0=80=20=EB=A7=8C=EB=93=A0=20=ED=87=B4?= =?UTF-8?q?=EB=B3=B4=EB=A5=BC=20=EB=90=98=EB=8F=8C=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 비표준 RPN 을 외부 proposal 로 돌리면서 NotImplementedError 가드를 통째로 걷어냈다. 그 결과 회귀에서 셋이 나빠졌다: groie UNSUPPORTED(이유 명시) -> RUN_FAIL(스택 주소만) sparse_rcnn/queryinst UNSUPPORTED(이유 명시) -> EXPORT_FAIL(no attribute 'score_thr') 이 저장소가 바로 그 자리에 적어 둔 원칙을 어긴 것이다 — "AttributeError 로 흘려보내지 말고 왜 안 되는지 말한다. 그래야 미지원과 우리 버그가 구분된다." 외부 proposal 로 돌릴 수 있는 건 **RoI head 가 표준일 때뿐**이다: GARPNHead / CascadeRPNHead RPN 만 다르다 -> 외부 proposal 로 열림 EmbeddingRPNHead 디코드 규약이 다르다 -> 거절 (SparseRoIHead 6단계 DIIHead, score_thr/NMS 없이 DETR 식 top-k) groie 도 집계 경로는 되지만 post 의 GeneralizedAttention 이 5D broadcast 라 못 굽는다 — 러너 크래시로 흘리지 말고 여기서 말한다. 렌더러 작업이지 하네스 작업이 아니다. 그리고 grounding_dino 계열의 num_cp(fairscale gradient checkpointing)를 끈다. 학습용 메모리 최적화라 추론값이 안 바뀐다 - SyncBN->BN 과 같은 부류다. 끄고 나니 텍스트 3계열이 전부 같은 벽에 도달한다: transformers 미설치. 회귀: PASS 28 -> 37. 바뀐 7계열은 전부 이번 작업분 (cascade_rpn/guided_anchoring/fast_rcnn/panoptic_fpn 통과, groie/sparse_rcnn/queryinst 는 위 거절 복구로 UNSUPPORTED 유지). Co-Authored-By: Claude Opus 5 (1M context) --- tools/frontend/mmdet/frcnn_wrap.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/tools/frontend/mmdet/frcnn_wrap.py b/tools/frontend/mmdet/frcnn_wrap.py index e22f682..0b4498b 100755 --- a/tools/frontend/mmdet/frcnn_wrap.py +++ b/tools/frontend/mmdet/frcnn_wrap.py @@ -273,6 +273,18 @@ def frcnn_cfg(det, size=800): # 앞은 낼 주체가 없어 고정 격자를, 뒤는 자기 rpn_head 가 낸 것을 써야 한다. has_own_rpn = rh is not None if rh is not None and not hasattr(rh, "prior_generator"): + # ⚠️ **외부 proposal 로 돌릴 수 있는 건 RoI head 가 표준일 때뿐이다.** + # `EmbeddingRPNHead`(sparse_rcnn·queryinst)는 RPN 만 다른 게 아니라 + # `SparseRoIHead` 6단계 DIIHead 로 **디코드 규약 자체가 다르다** + # (`test_cfg.rcnn` 에 score_thr·NMS 가 없고 DETR 처럼 top-k 를 쓴다). + # proposal 을 넣어줘도 안 열리므로 **왜 안 되는지 말하고 멈춘다** — + # 그냥 두면 저 아래에서 `AttributeError: no attribute 'score_thr'` 로 죽어 + # "미지원" 인지 "우리 버그" 인지 구분이 안 된다. + if type(rh).__name__ == "EmbeddingRPNHead": + raise NotImplementedError( + f"{type(rh).__name__}: 학습된 proposal + SparseRoIHead 6단계 DIIHead. " + "RPN 만의 문제가 아니라 디코드 규약이 다르다(score_thr·NMS 없이 DETR 식 top-k) " + "— 조립기를 새로 짜야 한다") rh = None # 아래부터 외부 proposal 계약으로 간다 pg = rh.prior_generator if rh is not None else None bc = rh.bbox_coder if rh is not None else None @@ -283,8 +295,18 @@ def frcnn_cfg(det, size=800): # ⚠️ bbox extractor 가 `GenericRoIExtractor` 면 **레벨을 고르지 않고 전 레벨을 합친다** # (게다가 groie 는 레벨마다 5x5 conv + GeneralizedAttention 을 건다). 호스트 # RoIAlign 은 그걸 표현 못 한다 — 조용히 레벨 선택으로 떨어뜨리면 값만 틀린다. - # GenericRoIExtractor(groie)는 이제 지원한다 — 러너가 레벨별로 RoIAlign 을 걸어 - # 배치로 이어붙이고, SubB 가 pre→합산→post 를 그래프 안에서 한다. + # GenericRoIExtractor(groie): **집계 경로는 지원한다** — 러너가 레벨별로 RoIAlign 을 + # 걸어 배치로 이어붙이고, SubB 가 pre→합산→post 를 그래프 안에서 한다. + # ⚠️ 다만 `post_cfg` 가 `GeneralizedAttention` 이면 아직 못 굽는다. 위치 임베딩 + # broadcast 가 **5차원**인데 ggml 은 4D 까지라, 렌더러가 `ggml_cont` 로 떨어뜨리고 + # (생성 코드에 `repeat [N,6,7,4,42]` 주석이 남는다) 뒤의 add 가 + # `GGML_ASSERT(ggml_can_repeat)` 로 죽는다. 러너 크래시로 흘려보내지 말고 + # **여기서 이유를 말한다** — 렌더러 작업이지 하네스 작업이 아니다. + if type(ext).__name__ == "GenericRoIExtractor" and getattr(ext, "with_post", False): + if type(getattr(ext, "post_module", None)).__name__ == "GeneralizedAttention": + raise NotImplementedError( + "GenericRoIExtractor + GeneralizedAttention: 집계는 되지만 post 의 위치 임베딩 " + "broadcast 가 5차원이라 ggml(4D)로 못 편다 — g2c 렌더러 작업이다") bh = det.roi_head.bbox_head # 캐스케이드는 bbox_head 도 ModuleList 다. 클래스 수·coder 종류는 단계 공통이라 # **마지막 단계**를 쓴다(최종 박스를 내는 단계라 디코드 규약이 거기 맞춰져 있다). From 8dcec80be52e37cea7d837b5e588e214b41b4657 Mon Sep 17 00:00:00 2001 From: eunchae Date: Wed, 19 Aug 2026 16:01:21 +0900 Subject: [PATCH 83/89] =?UTF-8?q?feat(sparse):=20SPARSE=5FSubB=20=EB=9E=98?= =?UTF-8?q?=ED=8D=BC=20=E2=80=94=20DIIHead=20=EA=B0=80=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=EB=A1=9C=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=EB=90=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sparse_rcnn/queryinst 정찰 결과, 막힐 줄 알았던 자리가 안 막힌다. EmbeddingRPNHead 가중치 두 개뿐 (init_proposal_bboxes 100x4, init_proposal_features 100x256) — 이미지와 무관한 상수다 DIIHead (bbox_feats 100x256x7x7, object_feats 1x100x256) -> cls 1x100x80 / bbox 1x100x4 / object_feats 1x100x256 DynamicConv 이름과 달리 conv 가 아니라 torch.bmm 이다. 가중치가 데이터에서 나오지만 연산은 표준 배치 행렬곱이라 정적 그래프로 표현된다 래퍼는 Double-Head 와 같은 수법을 쓴다 — 그래프 입력이 하나뿐이라 RoI feature 와 query 를 배치로 이어붙여 받고 안에서 가른다. 출력은 셋(cls/bbox/object_feats)이고 object_feats 를 러너가 다음 단계로 실어 나른다. proposal 수는 export 시점 상수다. 검증 두 단계: 1) torch 대조 — cls/bbox/object_feats 세 출력 모두 최대차 0.00e+00 2) g2c 컴파일 — 통과. 40 텐서 25.2MB, unhandled op 0, TODO 는 레이아웃 주석 하나(다른 통과 계열에도 있는 그것) DynamicConv 의 bmm 이 ggml_mul_mat 으로 정상 렌더 즉 groie 와 달리 **렌더러 벽이 없다.** 남은 것은 러너 쪽이다 — 6단계 루프(박스+object_feats 를 함께 실어 나른다)와 DETR 식 최종 디코드 (sigmoid -> query x class top-k, NMS 없음). Co-Authored-By: Claude Opus 5 (1M context) --- tools/frontend/mmdet/frcnn_wrap.py | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tools/frontend/mmdet/frcnn_wrap.py b/tools/frontend/mmdet/frcnn_wrap.py index 0b4498b..d04aec6 100755 --- a/tools/frontend/mmdet/frcnn_wrap.py +++ b/tools/frontend/mmdet/frcnn_wrap.py @@ -76,6 +76,39 @@ def forward(self, x): return out +class SPARSE_SubB(nn.Module): + """SparseR-CNN / QueryInst 의 한 단계 = `DIIHead`. + + 보통 SubB 와 다른 점이 셋이다: + + 1. **입력이 둘이다** — RoI feature 와 `object_feats`(학습된 query). 그래프 입력은 + 하나뿐이라 Double-Head 와 **같은 수법**으로 배치에 이어붙여 받는다. 앞 N 행이 + RoI feature, 뒤 N 행의 (0,0) 자리에 query 가 실려 온다. + 2. **출력이 셋이다** — cls·bbox 에 더해 `object_feats` 를 돌려준다. 다음 단계가 + 그걸 받아야 하므로 러너가 단계 사이로 실어 나른다. + 3. **가중치가 단계마다 다르다** — 캐스케이드와 같아서 `FRCNN_SubB{i}` 기존 구조를 탄다. + + ⚠️ `DynamicConv` 은 이름과 달리 conv 가 아니라 **`torch.bmm`** 이다(query 에서 만든 + 행렬을 곱한다). 가중치가 데이터에서 나오지만 연산 자체는 표준 배치 행렬곱이라 + 정적 그래프로 표현된다 — DETR 계열이 쓰는 `render_matmul` 과 같은 경로다. + """ + def __init__(self, det, stage): + super().__init__() + self.bbox_head = det.roi_head.bbox_head[stage] + # 레벨당 proposal 수. **export 시점 상수**로 박는다(n_half 와 같은 이유 — + # shape 에서 뽑으면 렌더러가 슬라이스 시작을 몰라 offset 0 으로 폴백한다). + self.n_prop = int(det.rpn_head.num_proposals) + + def forward(self, roi_feat): + n = self.n_prop + feats = roi_feat[:n] # (N, C, 7, 7) + # 뒤 N 행의 (0,0) 자리에 query 가 들어 있다 → (1, N, C) + obj = roi_feat[n:2 * n][:, :, 0, 0].unsqueeze(0) + cls_score, bbox_pred, object_feats = self.bbox_head(feats, obj)[:3] + # object_feats 를 (N, C, 1, 1) 로 펴서 돌려준다 — 러너가 다음 단계 입력에 심는다. + return cls_score, bbox_pred, object_feats[0].unsqueeze(-1).unsqueeze(-1) + + class FRCNN_SubB(nn.Module): """RoIAlign feat (N,256,7,7) → (cls_score (N,81), bbox_pred (N,320)). From 514dca046a0df4b23ead0560850bdcd4ec616cab Mon Sep 17 00:00:00 2001 From: eunchae Date: Wed, 19 Aug 2026 16:31:46 +0900 Subject: [PATCH 84/89] =?UTF-8?q?wip(sparse):=20SparseR-CNN=206=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8?= =?UTF-8?q?=EC=9D=B4=20=EB=81=9D=EA=B9=8C=EC=A7=80=20=EB=8F=88=EB=8B=A4=20?= =?UTF-8?q?=E2=80=94=20=EB=82=A8=EC=9D=80=20=EA=B2=83=EC=9D=80=20=EA=B7=B8?= =?UTF-8?q?=EB=9E=98=ED=94=84=20=EC=88=98=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sparse_rcnn/queryinst 의 배관을 전부 붙였다. 러너가 6단계를 완주하고 100건을 낸다. 아직 통과는 아니다(우리 최고점 0.2356 vs mmdet 0.8068). 붙인 것 SPARSE_SubB RoI feature + query 를 배치로 이어붙여 받고(Double-Head 수법), cls/bbox/query 셋을 낸다. torch 대조 최대차 0.00e+00 frcnn_to_pt SparseRoIHead 면 단계마다 SPARSE_SubB 를 저장 frcnn.json sparse_stages · num_proposals · rcnn_clip_border 추가 proposal 수는 rpn_max 가 아니라 학습된 query 개수(100)다 run_frcnn.cpp query 를 단계 사이로 나른다(뒤 M행의 (0,0) 자리에 심고 세 번째 출력에서 받는다) + DETR 식 최종 디코드(sigmoid -> top-k, NMS 없음. test_cfg.rcnn 에 score_thr 도 NMS 도 없다) FRCNN_DEBUG=1 단계별 cls/query 를 찍는다 — 이 문제를 좁힌 도구다 좁혀진 것 (다음 세션은 여기서 시작하면 된다) query 나르기는 **정상이다** — 단계별 |mean| 이 torch 와 거의 일치 (0.7844/0.7670 · 0.7946/0.8053 · 0.7995/0.7995 · 0.7887/0.7754) RoIAlign 파라미터도 일치 (out 7 · strides [4,8,16,32] · sampling 2 · aligned) clip_border 도 반영했다 (sparse 는 False — 자르면 다음 단계가 다른 feature 를 읽는다) 그런데 **stage 0 부터 다르다**: 우리 sigmoid 0.5368 vs torch 0.6739. 단계가 갈수록 벌어진다(0.179 / 0.220 / 0.246 / 0.329 / 0.236 vs torch 0.65~0.81). 래퍼는 torch 에서 정확히 일치했으므로 **굽고 난 그래프가 torch 와 다르다**는 뜻이다. 다음 할 일: 컴파일된 SPARSE_SubB 를 동일 입력으로 torch 와 직접 대조한다. 후보 — fp16 가중치(gguf 가 fp16 이다) · attention/bmm 렌더링. Co-Authored-By: Claude Opus 5 (1M context) --- tools/frontend/mmdet/frcnn_to_pt.py | 9 ++- tools/frontend/mmdet/frcnn_wrap.py | 29 +++++--- tools/verify/backbone/run_frcnn.cpp | 89 +++++++++++++++++++++++-- tools/verify/roi/verify_postproc_roi.py | 22 +++++- 4 files changed, 133 insertions(+), 16 deletions(-) diff --git a/tools/frontend/mmdet/frcnn_to_pt.py b/tools/frontend/mmdet/frcnn_to_pt.py index 17c1d5d..3d05743 100755 --- a/tools/frontend/mmdet/frcnn_to_pt.py +++ b/tools/frontend/mmdet/frcnn_to_pt.py @@ -79,9 +79,14 @@ def main(argv=None): if getattr(det, "neck", None) is not None: feats = det.neck(feats) torch.save(FRCNN_SubA(det).eval(), f"{a.out}/FRCNN_SubA.pt") # 이미지 → 14 출력 - from frcnn_wrap import num_bbox_stages + from frcnn_wrap import num_bbox_stages, SPARSE_SubB ns = num_bbox_stages(det) - if ns == 1: + # SparseR-CNN/QueryInst 은 단계마다 query 를 함께 나르므로 전용 래퍼를 쓴다. + # 파일 이름은 캐스케이드와 같게 둔다 — 러너·하네스가 같은 경로를 탄다. + if type(det.roi_head).__name__ == "SparseRoIHead": + for i in range(ns): + torch.save(SPARSE_SubB(det, i).eval(), f"{a.out}/FRCNN_SubB{i}.pt") + elif ns == 1: torch.save(FRCNN_SubB(det).eval(), f"{a.out}/FRCNN_SubB.pt") # roi_feat → cls/bbox else: # 캐스케이드: 단계마다 따로 낸다. 러너가 사이에 호스트 RoIAlign 을 끼워 돈다. diff --git a/tools/frontend/mmdet/frcnn_wrap.py b/tools/frontend/mmdet/frcnn_wrap.py index d04aec6..87a0fed 100755 --- a/tools/frontend/mmdet/frcnn_wrap.py +++ b/tools/frontend/mmdet/frcnn_wrap.py @@ -313,11 +313,9 @@ def frcnn_cfg(det, size=800): # proposal 을 넣어줘도 안 열리므로 **왜 안 되는지 말하고 멈춘다** — # 그냥 두면 저 아래에서 `AttributeError: no attribute 'score_thr'` 로 죽어 # "미지원" 인지 "우리 버그" 인지 구분이 안 된다. - if type(rh).__name__ == "EmbeddingRPNHead": - raise NotImplementedError( - f"{type(rh).__name__}: 학습된 proposal + SparseRoIHead 6단계 DIIHead. " - "RPN 만의 문제가 아니라 디코드 규약이 다르다(score_thr·NMS 없이 DETR 식 top-k) " - "— 조립기를 새로 짜야 한다") + # `EmbeddingRPNHead` 는 proposal 이 **학습된 상수**다(이미지 무관). 외부 proposal + # 경로로 받되, RoI head 가 SparseRoIHead(6단계 DIIHead)라 단계마다 query 를 + # 함께 실어 날라야 한다 — `sparse_stages` 로 러너에 알린다. rh = None # 아래부터 외부 proposal 계약으로 간다 pg = rh.prior_generator if rh is not None else None bc = rh.bbox_coder if rh is not None else None @@ -364,8 +362,10 @@ def frcnn_cfg(det, size=800): rpn_nms_pre, rpn_nms_thr = 0, 0.0 # ⚠️ **개수는 계약이다.** SubB 는 proposal 행 수를 그래프에 상수로 굽는다 # (`flatten` 이 `reshape(…, N)` 으로 박힌다) — 다른 개수를 넣으면 러너가 죽는다. - # 그래서 여기서 정한 값을 하네스가 읽어 **정확히 그만큼** 만든다. - rpn_max = EXTERNAL_PROPOSAL_COUNT + # SparseR-CNN 은 그 수가 **학습된 query 개수**로 이미 정해져 있다(100). + # RPN 이 아예 없는 계열(FastRCNN)만 우리가 정한 격자 수를 쓴다. + _np = int(getattr(getattr(det, "rpn_head", None), "num_proposals", 0) or 0) + rpn_max = _np if _np else EXTERNAL_PROPOSAL_COUNT mask = {} if getattr(det.roi_head, "with_mask", False): mext = det.roi_head.mask_roi_extractor @@ -473,6 +473,10 @@ def frcnn_cfg(det, size=800): # 비표준 RPN 이라 외부로 돌린 것인가(참) vs RPN 이 아예 없는 것인가(거짓). # 참이면 하네스가 **그 계열 자신의 rpn_head** 로 proposal 을 뽑는다. "own_rpn": rh is None and has_own_rpn, + # SparseR-CNN/QueryInst: 단계마다 query(object_feats)를 함께 나른다. + # 0 이면 평범한 캐스케이드다. + "sparse_stages": ns if type(det.roi_head).__name__ == "SparseRoIHead" else 0, + "num_proposals": int(getattr(det.rpn_head, "num_proposals", 0) or 0), # groie: 러너가 레벨마다 RoIAlign 을 걸어 배치로 이어붙여야 한다(0 이면 평소대로). "groie_levels": len(getattr(ext, "featmap_strides", []) or []) if type(ext).__name__ == "GenericRoIExtractor" else 0, @@ -507,6 +511,15 @@ def frcnn_cfg(det, size=800): "rcnn_means": [float(v) for v in bh.bbox_coder.means], "rcnn_stds": [float(v) for v in bh.bbox_coder.stds], "class_agnostic": bool(getattr(bh, "reg_class_agnostic", False)), - "rcnn_score_thr": float(rcnn_c.score_thr), "rcnn_nms_thr": float(rcnn_c.nms.iou_threshold), + # ⚠️ SparseR-CNN 은 `test_cfg.rcnn` 이 `{max_per_img}` 뿐이다 — score_thr 도 NMS 도 + # 없다. DETR 처럼 sigmoid 뒤 (query x class) top-k 로 끝내기 때문이다. + # `getattr` 로 방어하고, 러너는 `sparse_stages` 를 보고 디코드를 가른다. + "rcnn_score_thr": float(getattr(rcnn_c, "score_thr", 0.0) or 0.0), + "rcnn_nms_thr": float(getattr(getattr(rcnn_c, "nms", None), "iou_threshold", 0.0) or 0.0), "rcnn_max": int(rcnn_c.max_per_img), + # ⚠️ **RoI 단계 정제에서 박스를 이미지로 자를지.** SparseR-CNN 의 coder 는 + # `clip_border=False` 라 자르지 않는다(실측: torch stage 0 박스가 820.5 까지 간다). + # 우리가 자르면 다음 단계가 **다른 feature 를 읽어** 점수가 무너진다 + # (실측: stage 1 에서 0.54 → 0.18). 크래시는 없고 조용히 틀린다. + "rcnn_clip_border": 1.0 if getattr(bh.bbox_coder, "clip_border", True) else 0.0, } diff --git a/tools/verify/backbone/run_frcnn.cpp b/tools/verify/backbone/run_frcnn.cpp index af30a49..a2bbef9 100644 --- a/tools/verify/backbone/run_frcnn.cpp +++ b/tools/verify/backbone/run_frcnn.cpp @@ -387,6 +387,25 @@ int main(int argc, char** argv) { return rf; }; + // ── SparseR-CNN / QueryInst ──────────────────────────────────────────── + // ⚠️ 캐스케이드와 달리 **query(object_feats)를 단계 사이로 나른다.** 그래프 입력이 + // 하나뿐이라 RoI feature 뒤에 query 를 배치로 이어붙여 넣고(앞 N=feature, + // 뒤 N 의 (0,0) 자리=query), SubB 가 세 번째 출력으로 갱신된 query 를 돌려준다. + // 초기 query 는 `EmbeddingRPNHead` 의 학습된 상수라 파일로 받는다. + const int SPARSE = (int)J.num("sparse_stages", 0.0f); + const bool RCLIP = J.num("rcnn_clip_border", 1.0f) != 0.0f; + std::vector qfeat; // (M, C) — 단계 사이 상태 + if (SPARSE > 0) { + const char* pp = getenv("FRCNN_PROPOSALS"); + std::string qp = std::string(pp ? pp : "") + ".q"; + std::ifstream qf(qp, std::ios::binary); + if (!qf) { fprintf(stderr, "sparse 인데 query 파일이 없다: %s\n", qp.c_str()); return 3; } + qf.seekg(0, std::ios::end); + qfeat.resize((size_t)qf.tellg() / sizeof(float)); + qf.seekg(0); + qf.read(reinterpret_cast(qfeat.data()), (std::streamsize)(qfeat.size() * sizeof(float))); + } + for (int st = 0; st < NS; ++st) { // ⚠️ 시맨틱 융합은 **단계마다** 건다. mmdet 의 `_bbox_forward(stage, …, semantic_feat)` // 가 매 단계 부른다 — 0단계만 더하면 뒤 단계가 융합 없이 돈다. @@ -401,7 +420,10 @@ int main(int argc, char** argv) { std::vector roi_st = with_reg_half(base_st, (st == 0) ? props : rois); // SubB 에 넣는 행 수. Double-Head 는 2배(cls+reg), groie 는 레벨 배다 — // 둘 다 "그래프 입력이 하나뿐이라 배치로 이어붙인다" 는 같은 이유다. - const int MB = (RSF > 0.0f) ? 2 * M : (groie_lv > 0 ? groie_lv * M : M); + // Double-Head=2배(cls+reg) · groie=레벨배 · sparse=2배(feature+query). + // 셋 다 "그래프 입력이 하나뿐이라 배치로 이어붙인다" 는 같은 이유다. + const int MB = (RSF > 0.0f) ? 2 * M + : (groie_lv > 0 ? groie_lv * M : (SPARSE > 0 ? 2 * M : M)); model_file fb = model_load(gbs[st].c_str()); model_weights wb = model_init(fb.n_tensors()); @@ -420,10 +442,37 @@ int main(int argc, char** argv) { for (int x = 0; x < O; ++x) roi_cwhn[(((size_t)n * O + y) * O + x) * C + c] = roi_st[(((size_t)n * C + c) * O + y) * O + x]; + // sparse: 뒤 M 행의 (0,0) 자리에 query 를 심는다(래퍼가 거기서 꺼낸다). + if (SPARSE > 0) { + for (int n = 0; n < M; ++n) + for (int c = 0; c < C; ++c) + roi_cwhn[((((size_t)(M + n)) * O + 0) * O + 0) * C + c] = + qfeat[(size_t)n * C + c]; + } transfer_to_backend(rin, std::span(roi_cwhn.data(), roi_cwhn.size())); compute(g1, backend); auto bo = read_outputs(g1, 8, nullptr); + // sparse: 세 번째 출력이 갱신된 query 다 — 다음 단계로 나른다. + // ⚠️ **여기서 안 받으면 6단계가 전부 같은 초기 query 로 돈다.** 크래시는 없고 + // 박스만 덜 정제된다 — 조용히 틀리는 부류라 개수를 확인하고 받는다. + if (SPARSE > 0) { + if (bo.size() < 3) { + fprintf(stderr, "sparse 인데 SubB 출력이 %zu 개다(cls/bbox/query 셋 필요)\n", bo.size()); + return 5; + } + if (getenv("FRCNN_DEBUG")) { + float mx = -1e9f; + for (float v : bo[0]) mx = std::max(mx, v); + float qm = 0.0f; + for (float v : bo[2]) qm += std::fabs(v); + fprintf(stderr, "[sparse] stage %d: cls logit max %.4f (sigmoid %.4f) · " + "query |mean| %.4f · out %zu개\n", st, mx, 1.0f/(1.0f+std::exp(-mx)), + qm / (float)bo[2].size(), bo.size()); + } + qfeat = bo[2]; + bo.resize(2); // 아래 로직은 (cls, bbox) 쌍만 본다 + } if (bo.size() == 1) { // ⚠️ **회귀 분기가 없는 bbox head 가 있다.** Grid R-CNN 은 `with_reg=False` // 로 박스를 아예 예측하지 않고 격자 head 가 나중에 다시 낸다. mmdet 도 @@ -490,10 +539,14 @@ int main(int argc, char** argv) { const float pw = x2 - x1, ph = y2 - y1; const float cx = x1 + pw * 0.5f + d[0] * pw, cy = y1 + ph * 0.5f + d[1] * ph; const float w = pw * std::exp(d[2]), h = ph * std::exp(d[3]); - nb[(size_t)i * 4 + 0] = std::max(0.0f, cx - w * 0.5f); - nb[(size_t)i * 4 + 1] = std::max(0.0f, cy - h * 0.5f); - nb[(size_t)i * 4 + 2] = std::min((float)SZ, cx + w * 0.5f); - nb[(size_t)i * 4 + 3] = std::min((float)SZ, cy + h * 0.5f); + // ⚠️ coder 가 `clip_border=False` 면 **자르지 않는다**(SparseR-CNN). + // 자르면 다음 단계가 다른 feature 를 읽어 점수가 무너진다. + const float bx1 = cx - w * 0.5f, by1 = cy - h * 0.5f; + const float bx2 = cx + w * 0.5f, by2 = cy + h * 0.5f; + nb[(size_t)i * 4 + 0] = RCLIP ? std::max(0.0f, bx1) : bx1; + nb[(size_t)i * 4 + 1] = RCLIP ? std::max(0.0f, by1) : by1; + nb[(size_t)i * 4 + 2] = RCLIP ? std::min((float)SZ, bx2) : bx2; + nb[(size_t)i * 4 + 3] = RCLIP ? std::min((float)SZ, by2) : by2; } rois.swap(nb); } @@ -649,6 +702,32 @@ int main(int argc, char** argv) { dets = detect_roi(prob.data(), box_st[last].data(), rois.data(), M_real, rp2); } + // ── SparseR-CNN / QueryInst: NMS 가 없다 ────────────────────────────────── + // ⚠️ `test_cfg.rcnn` 이 `{max_per_img}` 뿐이다 — score_thr 도 NMS 도 없다. + // DETR 처럼 **sigmoid 뒤 (query x class) 전체에서 top-k** 를 뽑는다. + // 박스는 마지막 단계가 이미 정제해 `rois` 에 들어 있다(단계마다 갱신된다). + // NMS 경로로 흘리면 임계값이 0 이라 전부 걸러져 **0건**이 나온다(실측). + if (SPARSE > 0) { + const int NCLS2 = (int)(cls_st[NS - 1].size() / M) ; // sigmoid head — 배경 없음 + const int KMAX = (int)J.num("rcnn_max", 100.0f); + struct cand { float sc; int q, c; }; + std::vector all; + all.reserve((size_t)M * NCLS2); + for (int i = 0; i < M_real; ++i) + for (int c = 0; c < NCLS2; ++c) { + const float z = cls_st[NS - 1][(size_t)i * NCLS2 + c]; + all.push_back({1.0f / (1.0f + std::exp(-z)), i, c}); + } + const int k = std::min(KMAX, (int)all.size()); + std::partial_sort(all.begin(), all.begin() + k, all.end(), + [](cand const& a, cand const& b) { return a.sc > b.sc; }); + dets.clear(); + for (int i = 0; i < k; ++i) { + const float* b = rois.data() + (size_t)all[i].q * 4; + dets.push_back({b[0], b[1], b[2], b[3], all[i].sc, all[i].c}); + } + } + #if defined(ARCH_C) && defined(ARCH_D) // ── 패스 2: 마스크 IoU 로 점수를 다시 매긴다 (Mask Scoring R-CNN) ─────── // mmdet 은 `score * mask_iou[label]` 로 점수를 낮춘다(maskiou_head.predict_by_feat). diff --git a/tools/verify/roi/verify_postproc_roi.py b/tools/verify/roi/verify_postproc_roi.py index 8f8f67a..f18a559 100644 --- a/tools/verify/roi/verify_postproc_roi.py +++ b/tools/verify/roi/verify_postproc_roi.py @@ -120,6 +120,12 @@ def last_error(text): pr.bboxes = torch.from_numpy(pb.copy()) pr.scores = torch.ones(len(pb)) pr.labels = torch.zeros(len(pb), dtype=torch.long) + # ⚠️ `SparseRoIHead` 는 proposal 에 **query(features)** 가 붙어 있길 기대한다 + # (`res.pop('features')`). 러너와 **같은 파일**을 읽어야 비교가 성립한다. + _qp = os.environ["FRCNN_PROPOSALS"] + ".q" + if os.path.exists(_qp): + _q = np.fromfile(_qp, dtype="float32").reshape(len(pb), -1) + pr.features = torch.from_numpy(_q.copy()) res = det.roi_head.predict(det.extract_feat(t), [pr], [ds], rescale=False)[0] else: res = det.predict(t, [ds], rescale=False)[0].pred_instances @@ -199,6 +205,11 @@ def last_error(text): feats = det.extract_feat(t) pr = det.rpn_head.predict(feats, [ds], rescale=False)[0] b = pr.bboxes.numpy()[:want] +# ⚠️ `EmbeddingRPNHead` 는 박스와 **query(features)** 를 같이 낸다 — SparseRoIHead 가 +# 단계마다 그 query 를 갱신하며 나르므로 초기값을 러너에 넘겨야 한다. +if "features" in pr: + np.ascontiguousarray(pr.features.numpy()[:want], dtype="float32").tofile(out + ".q") + print("QUERIES_OK", pr.features.shape) # ⚠️ **개수를 상한까지 채운다.** SubB 가 그 행 수로 구워져 있어 모자라면 reshape 이 죽는다. if len(b) < want: b = np.vstack([b, np.zeros((want - len(b), 4), "float32")]) @@ -359,7 +370,16 @@ def _one(fam, size, image, workdir, keep, verbose): # 레벨별 결과를 배치로 이어붙여 넘긴다(SubB 안에서 pre→합산→post). N 으로 구우면 # 슬라이스가 빈 텐서가 되어 "tensor a (1000) vs b (0)" 로 죽는다. GL = int(J.get("groie_levels") or 0) - MB = MX * GL if GL > 0 else MX + # ⚠️ SparseR-CNN 은 RoI feature 와 **query 를 배치로 이어붙여** 받는다(2N). + # proposal 수도 rpn_max 가 아니라 `num_proposals`(학습된 query 개수)다. + SP = int(J.get("sparse_stages") or 0) + if SP > 0: + NP = int(J.get("num_proposals") or MX) + MB = 2 * NP + elif GL > 0: + MB = MX * GL + else: + MB = MX jobs += [(s, subs[0], "out_" + s, f"{MB},{RC},{O},{O}") for s in subs] # Mask Scoring R-CNN 은 점수를 마스크 IoU 로 다시 매긴다 → 그래프가 둘 더 필요하다. # SubC = mask head (1, 256, 14, 14) → 마스크 로짓 (1, 80, 28, 28) From df97dc17f888750a7236fc4e433e64e2f13243d4 Mon Sep 17 00:00:00 2001 From: eunchae Date: Thu, 20 Aug 2026 08:32:01 +0900 Subject: [PATCH 85/89] =?UTF-8?q?feat(text):=20=ED=85=8D=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=A1=B0=EA=B1=B4=EB=B6=80=203=EA=B3=84=EC=97=B4=20?= =?UTF-8?q?=E2=80=94=20transformers=20=EC=84=A4=EC=B9=98=20+=20no-box=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20(86=EA=B3=84=EC=97=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit glip / grounding_dino / mm_grounding_dino 가 열렸다. glip L1 2.60e-03 grounding_dino L1 2.54e-03 mm_grounding_dino L1 2.15e-03 계획은 이것을 "BERT 인코더를 새로 들이는 큰 일(5~10일)" 로 잡았는데 틀렸다. 백본이 Swin 이라 이미 지원되고(swin 0.22px), 언어 모델은 torch 쪽에서 돈다. 실제로 막던 것은 둘뿐이었다: 1) num_cp=6 (fairscale gradient checkpointing) — 학습용이라 추론 무관, 껐다 2) transformers 미설치 WARN 이 셋은 bbox_head(feats) 로 못 부른다 — 텍스트 임베딩이 함께 들어가야 한다 (ATSSVLFusionHead.forward() missing 1 required positional argument). 박스를 판정 기준으로 삼을 수 없으므로 박스 없는 계열과 **같은 자**로 잰다: 컴파일된 그래프를 torch 와 댄다. config 에 language_model 이 있으면 no-box 경로로 간다. 즉 이 숫자는 "이 계열이 검증됐다" 가 아니라 "컴파일 범위가 맞다" 는 뜻이다. 설치는 하네스 venv 에 했다. 앞서 "6계열이 위험한 설치 하나에 걸려 있다" 고 적었는데 과했다 — 2026-08-03 사고는 mmpretrain 이 blip->transformers 를 끌어온 것이고, 이 venv 엔 mmpretrain 이 없어 그 경로가 안 열린다. transformers 단독은 다른 일이다. 버전 핀을 세 번 맞췄다(transformers<5 · tokenizers 0.22.2 · huggingface-hub<1.0). 검증: 기존 패키지 버전 변화 0건 · numpy 2.5.1 그대로 · import mmdet.models OK. 회귀: retinanet/fcos/atss/yolox/detr/tood/condinst/swin/solo/maskformer 10계열 L1 전부 동일. 남은 3 - convnext / timm_example / reid 는 mmpretrain 이 필요하다(격리 venv 필요). Co-Authored-By: Claude Opus 5 (1M context) --- docs/mmdet-detectors.md | 6 ++++-- tools/verify/dense_head/verify_heads.py | 11 +++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index d1de2d6..4bf5d04 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -288,8 +288,10 @@ within 0.1 px, and the pre-decode tensors are at relative L1 1.7e-03 on the boxe ## What decodes to boxes -**Seventy-five families produce the same boxes MMDetection does** — forty single-stage -below, thirty-five two-stage further down. Assembling a head and decoding its output are +**Eighty-six families are verified against MMDetection** — forty-one single-stage and +thirty-eight two-stage agree on boxes; seven more are checked at the compiled-graph level +because they emit no boxes to compare (five mask-only families and three text-conditioned ones, +one of which is also single-stage). Assembling a head and decoding its output are separate steps, and a family can pass the first and fail the second. The runner picks a decoder from what the box prediction *is* — a delta against an anchor, a distance from a grid point, a normalised `cxcywh` query — not from the shape of the tower that produced it. YOLOX and RPN build the same tower as RetinaNet and decode diff --git a/tools/verify/dense_head/verify_heads.py b/tools/verify/dense_head/verify_heads.py index 3fe189c..d64c66c 100644 --- a/tools/verify/dense_head/verify_heads.py +++ b/tools/verify/dense_head/verify_heads.py @@ -608,6 +608,17 @@ def _unwrap(out_name, *extra): except OSError: pass kind = next((l.split(":")[-1].strip() for l in open(ph) if l.startswith("// head_type")), "?") + # ⚠️ **텍스트 조건부 계열은 `bbox_head(feats)` 로 못 부른다.** GLIP·GroundingDINO 는 + # 이미지 feature 말고 **텍스트 임베딩**을 같이 받는다(`ATSSVLFusionHead.forward() + # missing 1 required positional argument`). 박스를 판정 기준으로 삼을 수가 없으므로 + # 박스 없는 계열과 같은 자로 잰다 — **컴파일된 그래프**를 torch 와 댄다. + # ⚠️ 그래서 이 숫자는 "이 계열이 검증됐다" 가 아니라 "컴파일 범위가 맞다" 는 뜻이다. + try: + from mmengine.config import Config as _C + if (_C.fromfile(cfg_path).get("model") or {}).get("language_model"): + return _no_box(fam, cfg_path, cw, d) + except Exception: + pass if kind == "raw": # 프론트엔드가 이 head 를 인식하지 못했다 = 조립기가 없다. 여기서 끝낸다 — # 계속 가면 러너에서 크래시로 나타나 "버그" 처럼 보인다. From 629d8a240ddbecfe0f0337bdfbf5d16c39423e90 Mon Sep 17 00:00:00 2001 From: eunchae Date: Thu, 20 Aug 2026 08:36:05 +0900 Subject: [PATCH 86/89] =?UTF-8?q?wip(sparse):=20=EC=9B=90=EC=9D=B8?= =?UTF-8?q?=EC=9D=84=20=EC=BB=B4=ED=8C=8C=EC=9D=BC=EB=90=9C=20=EA=B7=B8?= =?UTF-8?q?=EB=9E=98=ED=94=84=EB=A1=9C=20=ED=99=95=EC=A0=95=20=E2=80=94=20?= =?UTF-8?q?=EB=B0=B0=EA=B4=80=C2=B7=EA=B0=80=EC=A4=91=EC=B9=98=C2=B7fp16?= =?UTF-8?q?=20=EC=9D=80=20=EB=AC=B4=EC=A3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FRCNN_DEBUG=2 로 stage 0 의 입력과 출력을 덤프해 torch 와 직접 대조했다. 같은 입력에서 torch cls max 0.7256 (sigmoid 0.6738) 그래프 cls max 0.1475 (sigmoid 0.5368) 최대차 6.67 torch 가 러너의 입력만으로 mmdet 의 stage-0 값(0.6739)을 **정확히 재현한다.** 따라서 무죄로 갈린 것: - RoIAlign feature (러너가 만든 그대로 넣어 맞았다) - query packing (뒤 100행의 (0,0) 자리에 심는 계약이 맞다) - 가중치 (fp16 왕복 0.7261 vs fp32 0.7256 — fp16 이 원인이 아니다) - 그래프 구조 (sl6=feature 는 52줄, sl7=query 는 21줄에서 각각 쓰인다) 남은 것은 **컴파일된 그래프의 수치**뿐이다. g2c 쪽 조사라 지시대로 여기서 멈춘다. groie(5D broadcast)와 같은 칸에 들어간다. 다음 사람이 이어갈 지점: FRCNN_DEBUG=2 로 덤프를 만들고 SPARSE_SubB 를 torch 로 돌린 값과 중간 텐서를 층별로 좁힌다. 후보는 attention(MultiheadAttention)과 DynamicConv 의 bmm 두 곳이다. Co-Authored-By: Claude Opus 5 (1M context) --- tools/verify/backbone/run_frcnn.cpp | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tools/verify/backbone/run_frcnn.cpp b/tools/verify/backbone/run_frcnn.cpp index a2bbef9..e2b1506 100644 --- a/tools/verify/backbone/run_frcnn.cpp +++ b/tools/verify/backbone/run_frcnn.cpp @@ -461,6 +461,21 @@ int main(int argc, char** argv) { fprintf(stderr, "sparse 인데 SubB 출력이 %zu 개다(cls/bbox/query 셋 필요)\n", bo.size()); return 5; } + // FRCNN_DEBUG=2 면 stage 0 의 **입력과 출력**을 통째로 남긴다. + // 같은 입력을 torch 래퍼에 먹여 대조하면 "그래프가 틀렸나 배관이 틀렸나" 가 갈린다. + if (st == 0 && getenv("FRCNN_DEBUG") && std::string(getenv("FRCNN_DEBUG")) == "2") { + auto dump = [](const char* nm, std::vector const& v) { + std::ofstream f(nm, std::ios::binary); + f.write(reinterpret_cast(v.data()), + (std::streamsize)(v.size() * sizeof(float))); + }; + dump("dbg.in.bin", roi_cwhn); + dump("dbg.cls.bin", bo[0]); + dump("dbg.box.bin", bo[1]); + dump("dbg.obj.bin", bo[2]); + fprintf(stderr, "[sparse] stage0 덤프: in %zu · cls %zu · box %zu · obj %zu\n", + roi_cwhn.size(), bo[0].size(), bo[1].size(), bo[2].size()); + } if (getenv("FRCNN_DEBUG")) { float mx = -1e9f; for (float v : bo[0]) mx = std::max(mx, v); From be02ac50e4f4d178425721d788efdef703c63614 Mon Sep 17 00:00:00 2001 From: eunchae Date: Thu, 20 Aug 2026 10:47:51 +0900 Subject: [PATCH 87/89] =?UTF-8?q?fix(verify):=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=EB=AC=BC=EC=9D=B4=20=EC=97=86=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EB=A7=81=EC=BB=A4=20=EB=8C=80=EC=8B=A0=20=ED=95=98?= =?UTF-8?q?=EB=84=A4=EC=8A=A4=EA=B0=80=20=EC=9D=B4=EC=9C=A0=EB=A5=BC=20?= =?UTF-8?q?=EB=A7=90=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 신규 클론에서 vision.cpp 빌드 전에 하네스를 돌리면 계열마다 `BUILD_FAIL … ld returned 1 exit status` 만 나온다. 링커는 무엇이 없는지 못 말하므로 저장소가 깨진 것으로 읽힌다 — 실제로 그렇게 읽었다. 두 하네스 모두 시작 전에 libvisioncpp.so / libggml.so 를 확인하고, 없으면 찾은 경로와 빌드 명령을 찍고 멈춘다. --- tools/verify/dense_head/verify_heads.py | 10 ++++++++++ tools/verify/roi/verify_postproc_roi.py | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/tools/verify/dense_head/verify_heads.py b/tools/verify/dense_head/verify_heads.py index d64c66c..9f60931 100644 --- a/tools/verify/dense_head/verify_heads.py +++ b/tools/verify/dense_head/verify_heads.py @@ -202,6 +202,16 @@ def save(tag, lst): # 계열끼리 공유하는 상태가 없어 병렬로 돌릴 수 있다(전부 독립 프로세스). # 실측 계열당 최대 RSS 1.16GB → 4개면 ~4.6GB. 6코어 중 4개만 쓴다(2개는 호스트 몫). WORKERS = CFG.workers +# ⚠️ **러너를 vision.cpp 빌드 산출물에 링크한다**(`-lvisioncpp -lggml …`). 빌드를 안 했거나 +# 아직 도는 중이면 계열마다 `ld returned 1 exit status` 만 나와서 **저장소가 깨진 것처럼 +# 보인다**(신규 클론에서 실제로 그렇게 읽었다). 링커가 원인을 못 말하므로 여기서 말한다. +_missing_libs = [n for n in ("libvisioncpp.so", "libggml.so") + if not os.path.exists(os.path.join(V, "build", "lib", n))] +if _missing_libs: + sys.exit(f"빌드 산출물이 없다: {', '.join(_missing_libs)} (찾은 곳: {V}/build/lib)\n" + f"먼저 빌드해라 —\n" + f" cmake -S {V} -B {V}/build\n" + f" cmake --build {V}/build -j4") # ⚠️ **메모리 가드.** 위키 `wsl-계속-터짐` — 병렬 torch 스윕이 WSL 을 통째로 죽인 적이 있다. # 가용 메모리가 이 밑으로 내려가면 새 계열을 안 띄우고 기다린다. 느려질지언정 안 죽는다. MIN_FREE_MB = CFG.min_free_mb diff --git a/tools/verify/roi/verify_postproc_roi.py b/tools/verify/roi/verify_postproc_roi.py index f18a559..47d8b6b 100644 --- a/tools/verify/roi/verify_postproc_roi.py +++ b/tools/verify/roi/verify_postproc_roi.py @@ -559,6 +559,20 @@ def main(): ap.add_argument("-v", "--verbose", action="store_true") a = ap.parse_args() + # ⚠️ **러너를 vision.cpp 빌드 산출물에 링크한다**(`-lvisioncpp -lggml …`). 빌드를 + # 안 했거나 아직 도는 중이면 계열마다 `BUILD_FAIL … ld returned 1 exit status` 만 + # 나와서 **저장소가 깨진 것처럼 보인다**(신규 클론에서 실제로 그렇게 읽었다). + # 링커가 원인을 못 말하므로 여기서 먼저 말한다. + missing = [n for n in ("libvisioncpp.so", "libggml.so") + if not os.path.exists(os.path.join(BUILD, "lib", n))] + if missing: + print(f"빌드 산출물이 없다: {', '.join(missing)} (찾은 곳: {BUILD}/lib)\n" + f"먼저 빌드해라 —\n" + f" cmake -S {V} -B {BUILD}\n" + f" cmake --build {BUILD} -j4\n" + f"다른 빌드 디렉토리를 쓰면 VISP_BUILD 로 준다.") + return 2 + fams = a.families or (two_stage_families() if a.all else []) if not fams: print(__doc__) From f5c36824f713f7d727d4c28ce2f6ca7880b1df11 Mon Sep 17 00:00:00 2001 From: eunchae Date: Thu, 20 Aug 2026 11:14:08 +0900 Subject: [PATCH 88/89] =?UTF-8?q?docs(mmdet):=20swin=20=EC=9D=98=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EA=B8=B8=EC=9D=B4=EB=8A=94=20ggml=20?= =?UTF-8?q?=EC=9D=B4=20=EC=95=84=EB=8B=88=EB=9D=BC=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9D=84=20=EC=A4=84=EC=97=AC=20=ED=95=B4=EA=B2=B0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mmdet-detectors.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 4bf5d04..5662784 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -594,9 +594,17 @@ The ten that do not agree split four ways, and the split matters more than the c the box features **at every stage**, not only the first. `scnet` (29 px → 0.18 px) adds a global-context vector to all RoI positions the same way. `detectors` (30 px → 0.03 px) was not a fusion at all — its switchable atrous convolution ran at one dilation. `swin` - (0.22 px) failed earlier still, at load: `GGML_MAX_NAME` was 64 and its tensor names are + (0.22 px) failed earlier still, at load: `GGML_MAX_NAME` is 64 and its tensor names are longer, and separately the shifted-window attention mask is built by slice assignment that - tracing drops, which silently zeroes the mask instead of crashing. + tracing drops, which silently zeroes the mask instead of crashing. The length is resolved + by **shortening the names, not by patching ggml** — a patched submodule commit lives on no + remote we can push to, so it would break every fresh clone. What made this one hard to see + is that the shortener already existed and still let the name through: it folds the module + prefix against a budget that assumes a short suffix (`running_mean`, twelve characters), + and `relative_position_bias_table` is twenty-eight. The prefix here is short enough to pass + untouched, and nothing checked the finished length, so the bake wrote a GGUF that could not + be loaded and said so only much later, as one line from the runner. Counting the longest + weight name is not the same as checking what the shortener does with it. Nothing is left unsorted. `tridentnet` used to return no boxes at all, and it took two different C4-only faults to explain that. Its RPN puts five scales on **one** level From 003010065630e2f273687de30042b19fbbea6c03 Mon Sep 17 00:00:00 2001 From: eunchae Date: Thu, 20 Aug 2026 12:10:42 +0900 Subject: [PATCH 89/89] =?UTF-8?q?docs:=20=EA=B3=B5=EA=B0=9C=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=C2=B7=ED=97=A4=EB=8D=94=20=ED=99=95=EC=9E=A5=EC=9E=90?= =?UTF-8?q?=C2=B7=EA=B3=84=EC=97=B4=20=EC=88=98=20=EB=82=B4=EC=97=AD=C2=B7?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20=EC=A0=95?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서브에이전트로 문서 9건을 코드와 대조해 나온 것들. - overview.md 가 visp/nn.h · postproc.h · tracker.h 를 공개 헤더처럼 표에 실었다. 셋 다 src/visp/ 에 있고 include/ 에 없다 — 설치본에 링크한 프로그램은 include 못 한다. 설치되는 4개(vision·image·ml·util)와 in-tree 3개를 표를 갈라 구분했다. - model-implementation-guide.md 의 ml.hpp · image.hpp · vision.hpp → 전부 .h 다. - mmdet-detectors.md: 본문이 41+38 인데 표는 40+35 다. 표는 **엄격 기준을 통과한 것만** 싣고 나머지(free_anchor·yolact 경계, fp16 3계열, double_heads)는 뒤 절 산문에 있다. 총합 86이 맞아떨어져서 아무도 안 봤다 — 표가 검증 집합이 아니라는 것을 명시했다. - README 의 esrgan 예제가 빌드가 받지 않는 파일명을 썼다. models/CMakeLists.txt 및 나머지 문서와 같은 RealESRGAN-x4plus_anime-6B-F16.gguf 로 통일. - vision-cpp-mmdet-guide-en.md: 3장이 mmdet 에 적용 안 된다는 예외를 맨 앞으로, 도구가 둘(vision-cli/run_mmdet)이라는 사실을 해상도 문단에 명시, mmdet_wrap 사본을 .pt 옆에 쓴다는 잘못된 서술 정정, --name 대소문자 규칙 명문화. --- README.md | 2 +- docs/mmdet-detectors.md | 13 ++++++++++++- docs/model-implementation-guide.md | 6 +++--- docs/overview.md | 17 ++++++++++++++--- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 19cd8d5..a794acd 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ vision-cli migan -m MIGAN-512-places2-F16.gguf -i image.png mask.png -o output.p [Model download](https://huggingface.co/Acly/Real-ESRGAN-GGUF) | [Paper (arXiv)](https://arxiv.org/abs/2107.10833) | [Repository (GitHub)](https://github.com/xinntao/Real-ESRGAN) | License: BSD-3-Clause ```sh -vision-cli esrgan -m ESRGAN-4x-foolhardy_Remacri-F16.gguf -i input.png -o output.png +vision-cli esrgan -m RealESRGAN-x4plus_anime-6B-F16.gguf -i input.png -o output.png ``` #### YOLOv9t diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 5662784..f7ff0a5 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -291,7 +291,18 @@ within 0.1 px, and the pre-decode tensors are at relative L1 1.7e-03 on the boxe **Eighty-six families are verified against MMDetection** — forty-one single-stage and thirty-eight two-stage agree on boxes; seven more are checked at the compiled-graph level because they emit no boxes to compare (five mask-only families and three text-conditioned ones, -one of which is also single-stage). Assembling a head and decoding its output are +one of which is also single-stage). + +**The tables below will not add up to those figures, and that is deliberate.** A table lists +only what clears the strict bar — box under 2 px, score under 0.05, no label or count +mismatch — so the single-stage table holds forty rows and the two-stage table thirty-five. +The remainder are verified but sit outside the bar for a stated reason, and each is accounted +for in the sections that follow: `free_anchor` and `yolact` land on a score-cut boundary, +`dynamic_rcnn`, `pafpn` and `res2net` are several pixels out from fp16 weights alone (0.00 px +when recompiled in fp32), and `double_heads` agrees at 0.16 px. Counting rows and expecting the +summary is the mistake; the rows are the strict set, not the verified set. + +Assembling a head and decoding its output are separate steps, and a family can pass the first and fail the second. The runner picks a decoder from what the box prediction *is* — a delta against an anchor, a distance from a grid point, a normalised `cxcywh` query — not from the shape of the tower that produced it. YOLOX and RPN build the same tower as RetinaNet and decode diff --git a/docs/model-implementation-guide.md b/docs/model-implementation-guide.md index 698f83d..7752ff3 100644 --- a/docs/model-implementation-guide.md +++ b/docs/model-implementation-guide.md @@ -57,7 +57,7 @@ functionality is missing, you can quickly hack it in. Make sure to use that. _vision.cpp_ adds some infrastructure on top of ggml to reduce boilerplate for common tasks. It's designed to amend functionality, not wrap or replace it. The -[include/visp/ml.hpp](/include/visp/ml.hpp) public header contains all the +[include/visp/ml.h](/include/visp/ml.h) public header contains all the interesting bits. If you take a look at the existing model implementations in `src/visp/arch`, you @@ -298,7 +298,7 @@ Some examples where this helped: It's common for vision models to process images and masks with a wild mix of PIL/numpy/OpenCV/torchvision/whatever. The -[include/visp/image.hpp](/include/visp/image.hpp) header has a collection of +[include/visp/image.h](/include/visp/image.h) header has a collection of common transformations. If that doesn't cover it, it also has some tools to implement custom per-pixel operations. @@ -346,7 +346,7 @@ includes some practical post-processing too. ## 6. API -Models are exported in [include/visp/vision.hpp](/include/visp/vision.hpp). This +Models are exported in [include/visp/vision.h](/include/visp/vision.h). This includes a high-level API which represents the most common use cases. It should be simple, and does not need to support configuration options. Typically that means a function to load the model, and one to run inference. These are diff --git a/docs/overview.md b/docs/overview.md index 5b50afd..7ac63f8 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -49,10 +49,21 @@ how much control you need. | :--- | :--- | :--- | | Model APIs | `visp/vision.h` | Ready-made models — load, run, get a result. | | Image I/O | `visp/image.h` | Load, save, resize, tile, convert. | -| Neural network layers | `visp/nn.h` | `conv_2d`, `group_norm`, attention, and other building blocks. | | Graph and backends | `visp/ml.h` | GGUF loading, weight transfer, graph construction, execution. | -| Detection post-processing | `visp/postproc.h` | Anchors, decoding, NMS, RoIAlign, masks. | -| Tracking | `visp/tracker.h` | ByteTrack association across frames. | +| Vectors and small utilities | `visp/util.h` | `i32x2`, spans, and the shared scalar types. | + +Those four are what installing puts under `include/visp/`, and they are the whole public +surface. Three more headers are part of the build but **not installed**, so a program compiled +against an installed SDK cannot include them. They are listed because the rest of this guide +refers to them: + +| Layer | Header (in-tree only) | What it gives you | +| :--- | :--- | :--- | +| Neural network layers | `src/visp/nn.h` | `conv_2d`, `group_norm`, attention, and other building blocks. | +| Detection post-processing | `src/visp/postproc.h` | Anchors, decoding, NMS, RoIAlign, masks. | +| Tracking | `src/visp/tracker.h` | ByteTrack association across frames. | + +Code that needs those is built inside the tree — which is what registering an architecture does. Two front-ends are built on top: