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/README.md b/README.md index a45382c..a794acd 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 | @@ -20,6 +24,8 @@ 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 | +| [**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 @@ -123,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/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/mmdet-detectors.md b/docs/mmdet-detectors.md new file mode 100644 index 0000000..f7ff0a5 --- /dev/null +++ b/docs/mmdet-detectors.md @@ -0,0 +1,972 @@ +# 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. + +``` + 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) +- [What decodes to boxes](#what-decodes-to-boxes) +- [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. + +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 +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` 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 + +`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, name that directory: + +```sh +bash tools/build/build_mmdet_cpp.sh --build /path/to/that/directory \ + 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; a `.bin` extension gives raw `float32` instead, which is what +comparing against a reference implementation needs. + +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. + + +## 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: + +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 +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: `vision-cli` draws them and prints their +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` 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.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 + +**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). + +**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 +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 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 | +| :--- | :--- | ---: | ---: | +| `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 | +| `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 | +| `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 | +| `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 | +| `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 | +| `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 | +| `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 | +| `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` 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. + +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. + +`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` 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. 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. + +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` +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): + +| Family | Decoder | Worst box | Worst score | +| :--- | :--- | ---: | ---: | +| `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 | +| `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 | +| `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 | +| `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 | +| `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 | +| `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 | +| `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 +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 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 +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()"`. + +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 +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 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 +aborts. That is a property of the resolution, not of the family. + +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 + `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. +- **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. +- **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-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 + (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. + + 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` 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. 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 +(`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 +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: + +- **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` 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 + 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 + 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. + +## 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. | +| `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. | +| `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, score_factors = nullptr)` +: Anchor-based decoding: anchor generation, delta decoding, per-level top-k, score + 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. `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)`. + +`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 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)` +: 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 + 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 +# 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 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. `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. + +## 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. | +| `num_base` | Anchors per location, `len(octave_scales) * len(ratios)`. | + +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. | +| `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. 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 new file mode 100644 index 0000000..7ac63f8 --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,122 @@ +# 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. | +| Graph and backends | `visp/ml.h` | GGUF loading, weight transfer, graph construction, execution. | +| 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: + +- `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. 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" diff --git a/src/cli/cli.cpp b/src/cli/cli.cpp index 6715d74..bc0726b 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,134 @@ 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; + // 정사각 리사이즈 + **등록된** 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, task.mean.data(), task.stdv.data(), /*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); + + // 좌표까지 찍는다. 그리기만 하면 결과를 수치로 확인할 방법이 없어서, 참조 구현과 + // 맞춰 보려면 디코더를 밖에서 다시 짜야 했다. 여기 이미 다 있는 값이다. + 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); +} + 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..29e7fd8 --- /dev/null +++ b/src/visp/arch_registry.h @@ -0,0 +1,78 @@ +// 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 +#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; + + // 전처리 정규화 `(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; + 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/nn.cpp b/src/visp/nn.cpp index d614d01..2cf16b2 100644 --- a/src/visp/nn.cpp +++ b/src/visp/nn.cpp @@ -1,3 +1,4 @@ +#include #include "nn.h" #include "util/string.h" @@ -183,13 +184,56 @@ 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, 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). + // `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)); } - 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 픽셀씩 버린다" 와 같으므로 + // 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)); } @@ -216,6 +260,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..27ab87f 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 이 무시돼 @@ -52,7 +62,11 @@ 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 가 없어 여기서 잘라낸다. +// `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/src/visp/postproc.cpp b/src/visp/postproc.cpp index ef95901..fe02fd2 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; @@ -41,7 +47,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 +59,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; @@ -98,25 +114,67 @@ 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()); + // ⚠️ **`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) ──────────────────── 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; + // 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 @@ -124,10 +182,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); + } } } } @@ -137,15 +207,51 @@ 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]; 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); - cand.push_back({outb[0], outb[1], outb[2], outb[3], std::get<0>(t), std::get<1>(t)}); + 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)}); } } // multiclass NMS (label별) — mmcv batched_nms 동등 @@ -204,16 +310,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); } } @@ -225,14 +340,38 @@ 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배 커진다. + // + // 예외는 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); - cand.push_back({outb[0], outb[1], outb[2], outb[3], std::get<0>(t), std::get<1>(t)}); + 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)}); } } std::vector out; @@ -325,6 +464,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; } @@ -401,7 +550,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) { @@ -413,16 +562,35 @@ std::vector rpn_proposals( 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(), @@ -434,8 +602,14 @@ std::vector rpn_proposals( 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}); } } @@ -449,6 +623,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); } @@ -472,17 +654,22 @@ 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; 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(); @@ -542,4 +729,676 @@ std::vector preprocess(uint8_t const* img, int img_h, int img_w, int img_ return out; } -} // namespace visp + +// ── 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; +} + +// ── 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; +} + + +// ── 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; +} + + +// (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; +} + + +// ── 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 0d264e2..12598eb 100755 --- a/src/visp/postproc.h +++ b/src/visp/postproc.h @@ -15,14 +15,20 @@ 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. +// `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); @@ -43,16 +49,97 @@ 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; + // 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; + // SABL 의 버킷 파라미터. 0 이면 안 쓴다. + 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), // 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); + +// ── 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. @@ -63,15 +150,32 @@ 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; 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; + // 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 · 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, @@ -94,6 +198,72 @@ 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); + +// ── 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 순서와 반대라 뒤집어 쓰면 + // 레벨마다 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; @@ -146,14 +316,51 @@ 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]. -// 반환 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, 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). @@ -164,13 +371,25 @@ 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 는 러너가) +// +// `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/README.md b/tools/README.md index 46c081c..dfae01e 100755 --- a/tools/README.md +++ b/tools/README.md @@ -1,184 +1,235 @@ -# 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 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 +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. +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 +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_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" diff --git a/tools/build/build_mmdet_cpp.sh b/tools/build/build_mmdet_cpp.sh index e5958e9..dd0012e 100755 --- a/tools/build/build_mmdet_cpp.sh +++ b/tools/build/build_mmdet_cpp.sh @@ -5,25 +5,59 @@ # 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 에서 자동) -# env: VISP_BUILD = libvisioncpp 빌드 디렉토리 (기본: /build) +# +# options: +# --build libvisioncpp 빌드 디렉토리 (기본: /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 검증 러너 -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 ] [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 -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 있으면 자동으로 켠다. @@ -31,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" @@ -39,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..5e49c4a 100755 --- a/tools/detect/head.cpp +++ b/tools/detect/head.cpp @@ -1,69 +1,998 @@ #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); + + // 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); + 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 +1024,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 +1037,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 +1070,312 @@ 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::yolox: // 조립은 anchor 와 같다(디코드만 다르다) + 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: + 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..6c6b417 100755 --- a/tools/detect/head.h +++ b/tools/detect/head.h @@ -5,15 +5,55 @@ #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 디코드 + // 타워 조립은 anchor 와 같다(레벨별 타워 + obj 갈래). **디코드가 다르다** — 코더가 + // 없고 격자 단위 (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 (전용 함수) + reppoints, // 점 집합 → bbox (전용 함수) + // 변마다 앵커를 칸으로 쪼개 **어느 칸인지 분류 + 칸 안 오프셋 회귀**. box 갈래가 둘이라 + // (bbox_cls·bbox_reg) 두 갈래 규약으로는 못 담는다 — 두 번째를 `extra` 에 싣는다. + sabl, + 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 +61,89 @@ 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 + // 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). + 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; + // 코너 디코드 파라미터(test_cfg). 조립기는 안 쓰고 러너가 디코더에 넘긴다. + int corner_topk = 100; + int local_max_kernel = 3; + float corner_distance_thr = 0.5f; + + // ── 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 +175,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/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/frontend/mmdet/append_head_weights.py b/tools/frontend/mmdet/append_head_weights.py new file mode 100644 index 0000000..a370010 --- /dev/null +++ b/tools/frontend/mmdet/append_head_weights.py @@ -0,0 +1,125 @@ +#!/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" + # ⚠️ `GGUFWriter(path, arch=...)` 가 이미 `general.architecture` 를 쓴다. 여기서 + # `add_architecture()` 를 또 부르면 최신 gguf-py 가 stderr 로 + # `Duplicated key name 'general.architecture'` 를 뱉는다. 값이 같아 산출물은 멀쩡한데 + # 그 줄이 상위 하네스의 "실패 이유" 로 잡혀 원인을 오도한다. + writer = gguf.GGUFWriter(tmp, arch=arch) + 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" → appended {n} weights that the graph does not use: {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..3d05743 100755 --- a/tools/frontend/mmdet/frcnn_to_pt.py +++ b/tools/frontend/mmdet/frcnn_to_pt.py @@ -19,7 +19,38 @@ import torch sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from frcnn_wrap import FRCNN_SubA, FRCNN_SubB, MaskRCNN_SubC, frcnn_cfg # noqa: E402,F401 (피클: frcnn_wrap) +import mmdet_compat # noqa: E402 +from frcnn_wrap import (FRCNN_SubA, FRCNN_SubB, MaskRCNN_SubC, MSRCNN_SubD, # noqa: E402,F401 + GridRCNN_SubE, frcnn_cfg) # (피클: 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): @@ -30,17 +61,45 @@ 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(_desync_norm(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}") + # ⚠️ **저장 전에 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 출력 - torch.save(FRCNN_SubB(det).eval(), f"{a.out}/FRCNN_SubB.pt") # roi_feat → cls/bbox + from frcnn_wrap import num_bbox_stages, SPARSE_SubB + ns = num_bbox_stages(det) + # 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 을 끼워 돈다. + 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 로 크기 취득. - with torch.no_grad(): - feats = det.neck(det.backbone(torch.zeros(1, 3, a.size, a.size))) + 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) 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..87a0fed 100755 --- a/tools/frontend/mmdet/frcnn_wrap.py +++ b/tools/frontend/mmdet/frcnn_wrap.py @@ -7,34 +7,230 @@ 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 - self.rpn_head = det.rpn_head + # ⚠️ **neck 이 항상 있다고 보면 안 된다.** C4 계열(TridentFasterRCNN)은 FPN 없이 + # 백본 C4 를 그대로 쓰고 `shared_head`(ResLayer)가 C5 역할을 한다. + self.neck = getattr(det, "neck", None) + # ⚠️ **RPN 이 없는 계열이 있다.** `FastRCNN` 은 proposal 을 **밖에서** 받는 구조라 + # `rpn_head` 를 아예 안 갖는다(config 에 `test_cfg.rpn` 도 없다). 없는 게 + # 결함이 아니라 그 계열의 정의다 — SubA 는 백본+넥까지만 내고, proposal 은 + # 러너가 파일에서 읽는다. + 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). + # ⚠️ **별도 그래프로 빼지 않는다.** 입력이 FPN 전 레벨이라 다중 입력 그래프가 되는데, + # 컴파일 경로가 단일 입력만 받는다. 같은 그래프의 **출력 하나**로 붙이면 + # 백본을 두 번 돌 필요도 없다. + 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.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 + 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) + 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 + + +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)).""" - 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 + # ⚠️ **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): + # ⚠️ **`self.<새속성>` 을 그냥 읽지 마라.** 이 클래스는 `torch.save` 로 통째 절여지는데, + # 저장은 **오래 사는 워커 프로세스**(스윕 시작 때 import 한 옛 코드)가 하고 + # 불러오기는 **새 서브프로세스**(새 코드)가 한다. 코드를 고치는 순간 그 사이가 + # 갈라져 `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): + 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) +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) + 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): @@ -45,39 +241,269 @@ 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) + + +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"] + + +# 외부 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 - pg = rh.prior_generator - bc = rh.bbox_coder + 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` 로 흘려보내지 말고** 왜 안 되는지 말한다 — 그래야 "미지원" 과 + # "우리 버그" 가 구분된다. + # ⚠️ 표준 앵커 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"): + # ⚠️ **외부 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'` 로 죽어 + # "미지원" 인지 "우리 버그" 인지 구분이 안 된다. + # `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 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 은 그걸 표현 못 한다 — 조용히 레벨 선택으로 떨어뜨리면 값만 틀린다. + # 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 - 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) + # 캐스케이드는 bbox_head 도 ModuleList 다. 클래스 수·coder 종류는 단계 공통이라 + # **마지막 단계**를 쓴다(최종 박스를 내는 단계라 디코드 규약이 거기 맞춰져 있다). + if isinstance(bh, nn.ModuleList): + bh = bh[-1] + 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)` 으로 박힌다) — 다른 개수를 넣으면 러너가 죽는다. + # 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 + 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), } - return {**mask, + # 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) + + # ⚠️ **점수 활성이 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) + 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: + 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: + 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), + } + 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] + 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, + "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], - "rpn_scale": float(scales[0]), - "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), + # ⚠️ **스케일은 목록 전체를 싣는다.** 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()] 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, + # 비표준 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, + # ⚠️ 아래 셋은 **기본값이 아닌 계열이 있다**(crowddet 이 셋 다 다르다). + # 전부 shape 를 안 바꾸므로 빼먹으면 크래시 없이 proposal 만 달라진다. + # `centers` — 주어지면 그 값이 base anchor 중심이고 center_offset 은 무시된다. + "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) + 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) 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) if rh is not None else 0, # 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), + # ⚠️ `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) @@ -85,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/frontend/mmdet/mmdet_compat.py b/tools/frontend/mmdet/mmdet_compat.py new file mode 100644 index 0000000..f376dfc --- /dev/null +++ b/tools/frontend/mmdet/mmdet_compat.py @@ -0,0 +1,444 @@ +"""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() + _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 를 **오프셋 상수 이동**으로 등가 변환한다. + + `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 어텐션 마스크를 **버퍼로 미리 굽는다.** + + `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(): + """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..0198fe0 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,178 @@ # 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", "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") + for k in ("cls_convs_prefix", "reg_convs_prefix", "cls_head", "reg_head", + "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", + "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(" 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") + + # 임계값은 **게이트 앞**이다 — 디코더가 무엇이 되든 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") + 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 원시 출력까지만 낸다. + # rpn 도 **앵커 파라미터를 그대로 쓴다**(디코드만 레벨별 NMS 로 다르다) — 같이 채운다. + 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", + ] + 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") + # 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") + # 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 = {" + + ", ".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` 를 쓰고 여기엔 의미상 값을 싣는다. 뒤바꾸면 배경을 + # 클래스로 세거나 마지막 클래스를 잃는다. + 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") + 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") + # ⚠️ 배경을 뺀 **의미상 클래스 수**다. 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") + 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") + 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 +202,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..4e1b796 100755 --- a/tools/frontend/mmdet/mmdet_wrap.py +++ b/tools/frontend/mmdet/mmdet_wrap.py @@ -7,17 +7,174 @@ 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 = 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 + 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 + w0, b0 = pristine[key] + with torch.no_grad(): + std = (bn.running_var + bn.eps).sqrt() + scale = bn.weight / std + w = w0 * scale.reshape(-1, 1, 1, 1) + b = bn.bias - bn.running_mean * scale + if b0 is not None: + b = b + b0 * 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) @@ -33,6 +190,37 @@ 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): + """격자 중심 오프셋. 생성기마다 **속성 이름이 다르다.** + + 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). @@ -45,28 +233,286 @@ 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", + "SABLRetinaHead": "sabl", + "GFLHead": "gfl", "FCOSHead": "fcos", + "RPNHead": "rpn", + "ATSSHead": "anchor", "PAAHead": "anchor", "RetinaHead": "anchor", + "AnchorHead": "anchor", "AnchorFreeHead": "fcos", + "CenterNetHead": "centernet", "YOLOFHead": "yolof", + "YOLOXHead": "yolox", "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"} # 모르는 계열 — 백본만 내보낸다 + # ⚠️ 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 로 떨어진다. + # 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__ + # 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 + # (레벨별 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))) - 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 + 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 []) + 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 + # 그런 계열은 **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 …). 이름 표를 두는 대신 + # **출력 채널 수로 알아본다** — 그러면 새 계열이 와도 표를 안 고쳐도 된다. + # 타워 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 + # 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() 가 소비. @@ -81,40 +527,220 @@ def postproc_cfg(det): 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)), + # 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 { - "head_type": "anchor", + "head_type": kind, # ── 전처리(pre) — 이미지→텐서 (vision.cpp preprocess) ── "img_mean": img_mean, "img_std": img_std, "to_rgb": to_rgb, - "use_sigmoid": bool(getattr(bh, "use_sigmoid_cls", True)), + **thresholds, + "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, "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), + # ⚠️ 속성 이름이 생성기마다 다르다. 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, + "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배씩 어긋난다. + # ⚠️ **`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, + "reg_stacked_convs": reg_stacked, "feat_channels": feat_ch, - "cls_convs_prefix": "bbox_head.cls_convs", - "reg_convs_prefix": "bbox_head.reg_convs", - "cls_head": "bbox_head." + (cls_head or "retina_cls"), - "reg_head": "bbox_head." + (reg_head or "retina_reg"), + "cls_convs_prefix": "bbox_head." + cls_tower, + "reg_convs_prefix": "bbox_head." + reg_tower, + # ⚠️ 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, + "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, + # 코너 디코드 파라미터. 계열 기본값이 아니라 **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 "", + "bbox_exp": bbox_exp, + "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 에서 유도하면 안 된다 — 마지막 레벨만 두 배다. + "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/install_arch.py b/tools/install_arch.py new file mode 100644 index 0000000..4b1035d --- /dev/null +++ b/tools/install_arch.py @@ -0,0 +1,213 @@ +#!/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"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"error: header not found: {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("error: the generated .cpp declares no 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 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 폴더") + 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=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 이라 필요 없다)") + 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)) + + 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"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')};", + 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()] + 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 = {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;", + 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: + 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("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") + + +if __name__ == "__main__": + main() diff --git a/tools/verify/backbone/run_frcnn.cpp b/tools/verify/backbone/run_frcnn.cpp new file mode 100644 index 0000000..e2b1506 --- /dev/null +++ b/tools/verify/backbone/run_frcnn.cpp @@ -0,0 +1,1025 @@ +// 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 +#ifdef VISP_ARCH_HEADER_C +#include VISP_ARCH_HEADER_C +#endif +#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" + +#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) +#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 +#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); + 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); + } + // ⚠️ **중첩 배열을 평탄화해서 읽는다.** `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); + 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; + 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] [subC.gguf] [subD.gguf] [subE.gguf]\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; + // 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(); + + // ── 패스 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); + // ⚠️ **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", + 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]; + } + // 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); + 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); + // 한 레벨에 스케일이 여럿인 계열(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); + 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; + 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)` 처럼 **행 수를 + // 상수로 박는다** — 적게 넣으면 `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; + } + + // ── 호스트: 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; + // ⚠️ **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`. 안 더하면 크래시 없이 박스만 밀린다 + // (실측: 이걸 뺀 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); + } + }; + // ── 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 에 실어 준다. + 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); + // ⚠️ **레벨은 키우기 전 박스(`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; + }; + + // ── 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단계만 더하면 뒤 단계가 융합 없이 돈다. + 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); + fuse_glbctx(base_st, M); + } + std::vector roi_st = with_reg_half(base_st, (st == 0) ? props : rois); + // SubB 에 넣는 행 수. Double-Head 는 2배(cls+reg), groie 는 레벨 배다 — + // 둘 다 "그래프 입력이 하나뿐이라 배치로 이어붙인다" 는 같은 이유다. + // 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()); + 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]; + // 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; + } + // 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); + 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 도 + // `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; + } + // ⚠️ 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]; + } + // ⚠️ 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; + 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]); + // ⚠️ 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); + } + } + + // ── 최종 디코드: 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; + // ── 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_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; + 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; + // ⚠️ **채널 수를 출력 크기에서 -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((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); + // ① 단계별 로짓 평균 + 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(dst[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_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). + // 이걸 빼면 **박스는 맞는데 점수만 틀린다** — 실측 박스 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 + +#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); + } + // ⚠️ **자르지 않는다.** 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); + } +#endif + + // ── 덤프 (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]); + } + // 최종 박스도 낸다 — 여기까지 와서 개수만 알려주면 결과를 수치로 확인할 방법이 없다. + // `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; +} diff --git a/tools/verify/backbone/run_mmdet.cpp b/tools/verify/backbone/run_mmdet.cpp index d12fd5c..628a735 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,207 @@ 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); + } + + // ── 최종 디코드 — 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; + } + + 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; 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,22 +349,361 @@ int main(int argc, char** argv) { transfer_to_backend(input, std::span(in_data.data(), in_data.size())); compute(graph, backend); - // 6) raw cls/box → detect_anchor (decode + NMS) - std::vector> cls_v(L), box_v(L); - std::vector> feat_hw(L); - for (int l = 0; l < L; ++l) { + // 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); + } + }; + // ⚠️ **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]); + // 갈래가 셋을 넘는 계열(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"); + } + + // ── 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). + 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; + + // YOLOv3 는 레벨당 **한 갈래**다(na×(5+nc) 한 텐서에 box·obj·cls 가 다 들어 있다). + // box 갈래가 없는 게 정상이라 아래 검사에서 빼야 한다 — 안 빼면 조립은 됐는데 + // "레벨에 못 미친다" 로 멈춘다. + const bool single_branch = hc.kind == head_kind::yolo; + + // 조립기가 레벨 수를 못 채우면 아래 인덱싱이 널을 읽는다. 여기서 말한다. + 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 || 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) { 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). + // ⚠️ YOLACT 의 coeff 갈래는 점수가 아니다 — `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]); } - 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); + // 디코드는 **조립과 다른 축**이다. 조립은 타워 모양(head_kind)으로 갈리지만, 디코드는 + // 박스가 무엇이냐로 갈린다 — 앵커 대비 delta 냐, 격자점 대비 거리냐. + // · anchor(Delta 코더) → detect_anchor + // · fcos·gfl·vfnet → detect_fcos (조립기가 이미 픽셀 거리로 만들어 놨다) + // · 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.kind == head_kind::reppoints || + hc.bbox_mul_stride; + + if (corner_done) { + // 위에서 채웠다 — 아무것도 안 한다. + } else 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). + // 시그모이드를 또 걸면 박스는 그대로인데 점수만 낮아진다 — 눈에 안 띈다. + 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 (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::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 로 먼저 거른다 — 전용 디코더로 보낸다. + 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개를 낸다. + // ⚠️ 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; + 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; + fp.base_edge = dp.base_edge; // FoveaBox 만 채워져 온다 + // FCOS 만 MlvlPointGenerator(0.5) 다. 나머지는 AnchorGenerator(center_offset=0). + // ⚠️ **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 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); + } + + // 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..2999dd4 --- /dev/null +++ b/tools/verify/dense_head/mmdet_families.py @@ -0,0 +1,252 @@ +"""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"} + +# 계열별 시험 이미지. **여기 없으면 하네스의 기본 이미지를 쓴다.** +# +# ⚠️ 왜 필요한가 — 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` 로 끝나는 뼈대는 뺀다. + + 후보를 여럿 돌려주는 이유: 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 + +# ── 손으로 고른 대표 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/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..675cb9f --- /dev/null +++ b/tools/verify/dense_head/verify.toml @@ -0,0 +1,43 @@ +# verify.toml — mmdet head 검증 설정. +# +# 예전에는 이 값들이 전부 환경변수였다. 그러면 **재현이 안 된다** — 어떤 값으로 잰 숫자인지 +# 로그에 안 남고, 다음 사람이 같은 명령을 쳐도 다른 결과가 나온다. +# 여기 적힌 값이 기본이고, 실행 시 `--set key=value` 로만 덮어쓴다(그것도 로그에 찍힌다). +# +# 상대경로는 **이 파일 위치 기준**이다. + +[paths] +# mmdet 저장소. 아래에 `configs/` 와 `checkpoints/` 가 있어야 한다. +mmdet = "~/mmbuild/mmdetection" +# g2c(컴파일러) 루트. 기본값은 vision.cpp 의 부모 = 이 브랜치의 g2c. +g2c = "../../../.." +# 트래커·반지도 래퍼 config 를 안쪽 검출기로 푸는 전처리기. +# 없으면 그 계열만 INIT_FAIL 로 남는다 — 없다고 전체가 멈추지는 않는다. +# ⚠️ 예전에는 **형제 저장소**(`../../../../../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) 전용 작업 폴더. +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..9f60931 --- /dev/null +++ b/tools/verify/dense_head/verify_heads.py @@ -0,0 +1,830 @@ +#!/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 이 실제로 그랬다). +# 손목록은 `mmdet_families.OVERRIDE` 로 옮겼다 — two-stage 하네스도 같은 것을 봐야 한다. +# 설정은 **파일**에서 온다(`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: (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: + 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) == 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: + 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 +# ⚠️ **러너를 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 + + +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 _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패스**로 검증한다. + + 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 는 + # 원본을 그대로 돌려주므로 분기 없이 전부 통과시킨다. + # ⚠️ **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): + 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() + 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 에서 실제로 겪었다: 헤더에 새 플래그가 없는데 조용히 진행됐다). + 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?)" + # ⚠️ **어떤 짝으로 구웠는지 남긴다.** 계열마다 변종이 여럿이라(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")), "?") + # ⚠️ **텍스트 조건부 계열은 `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 를 인식하지 못했다 = 조립기가 없다. 여기서 끝낸다 — + # 계속 가면 러너에서 크래시로 나타나 "버그" 처럼 보인다. + # 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", ''' +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") + # ⚠️ **성공 판정을 출력 문구로 하지 마라.** 여기서 `"추가"` 를 찾고 있었는데 스크립트는 + # 영어로 `"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 를 함께 컴파일) + 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 이면 충분하고, 컴파일이 훨씬 빠르다. + # ⚠️ **먼저 지운다.** 존재 검사만 하면 빌드가 실패해도 **지난 실행의 바이너리**로 + # 계속 가고, 낡은 코드가 낸 숫자가 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}"', + "-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}%") diff --git a/tools/verify/dense_head/verify_postproc.py b/tools/verify/dense_head/verify_postproc.py new file mode 100644 index 0000000..857ebed --- /dev/null +++ b/tools/verify/dense_head/verify_postproc.py @@ -0,0 +1,275 @@ +"""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 + + # 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() + # ⚠️ 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 을 싣는다(둘이 같아야 같은 픽셀이 된다). + 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) + x = im[:, :, ::-1] if to_rgb else im # 러너 규약에 맞춘다 + # ⚠️ **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), + "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: + 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], + 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 + + # **라벨이 같은 쌍만** 후보로 둔다. 박스 거리만 보면, 한 물체에 두 클래스가 겹쳐 나온 + # 경우(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) + 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 + + +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")] + # ⚠️ 상대경로로 주면 `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() + 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건이 나오고, 그러면 "한쪽이 비어 비교 불가" 로 끝나 그 계열을 + # 아예 못 잰다. 이 도구는 이미지를 **인자로** 받으므로 덮어쓰지 않고 경고만 한다 — + # 부른 사람이 일부러 골랐을 수 있다(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)}건") + + 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}건") + # 박스가 수백 개인 계열(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 과 어긋났을 때" + " 나는 증상이다. 짝지은 것만 보면 조용히 통과한다.") + # ⚠️ **그런데 이 임계값은 우리가 건 것이다.** 양쪽 다 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 + + +if __name__ == "__main__": + sys.exit(main()) 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() diff --git a/tools/verify/roi/verify_postproc_roi.py b/tools/verify/roi/verify_postproc_roi.py new file mode 100644 index 0000000..47d8b6b --- /dev/null +++ b/tools/verify/roi/verify_postproc_roi.py @@ -0,0 +1,617 @@ +#!/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")) +DEFAULT_IMAGE = os.path.join(V, "tests", "input", "cat-and-hat.jpg") +PY = sys.executable + +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"): + 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: + # ⚠️ **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 생성기를 재게 된다. + 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) + # ⚠️ `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 + 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)) +''' + + +# 계열 자신의 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] +# ⚠️ `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")]) +np.ascontiguousarray(b, dtype="float32").tofile(out) +print("PROPS_OK", len(b)) +''' + + +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): + """`_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): + # ⚠️ **계열마다 시험 이미지가 다를 수 있다.** 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) + os.makedirs(d, exist_ok=True) + open(os.path.join(d, "_stub.py"), "w").write(STUB) + + # ⚠️ **짝은 공용 자리에서 받는다**(`mmdet_families.resolve_pair`). 예전엔 이 하네스만 + # metafile 을 보고 one-stage 는 손목록을 봐서 **같은 계열이 다르게 풀렸다.** + cfg, ckpt_name = MF.resolve_pair(CFGS, fam) + if not cfg: + return fam, "CONFIG_NONE", "-", None + if not ckpt_name: + 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 + + # ⚠️ **트래커·반지도 래퍼는 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 로 가른다. 여기서 죽으면 "왜" 를 그대로 옮긴다. + # ⚠️ **단계마다 산출물을 먼저 지운다.** 존재 검사만 하면 이번 실행이 실패해도 지난 + # 실행의 것이 남아 있어 **그대로 통과한다** — 고친 코드가 반영 안 된 숫자를 + # "통과" 로 보고하게 된다. 공유 코드를 자주 고치는 날에는 이게 제일 위험하다 + # (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")): + 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"))) + # ⚠️ **proposal 을 밖에서 받는 계열**(FastRCNN)은 우리가 넣어 줘야 한다. mmdet 은 + # proposal 파일을 안 배포하고, 이 계열은 RPN 설정 자체가 없다. + # **RPN 출력을 쓰면 그 순간 faster_rcnn 을 재는 것**이 되므로(같은 백본·넥·RoI head), + # 독립적인 증거가 되도록 **고정 격자**를 쓴다. 결정적이라 재현되고, + # RoIAlign+RoI head 를 proposal 생성기와 분리해서 본다. + # ⚠️ 러너와 기준값이 **같은 파일**을 읽는다 — 각자 만들면 생성기를 재게 된다. + prop_env = {} + if J.get("external_proposals"): + pf = os.path.join(fr, "proposals.bin") + 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"]) + # 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 만 갈아 낀다. + # ⚠️ **groie 는 배치가 레벨 배다.** GenericRoIExtractor 는 전 레벨에 RoIAlign 을 걸어 + # 레벨별 결과를 배치로 이어붙여 넘긴다(SubB 안에서 pre→합산→post). N 으로 구우면 + # 슬라이스가 빈 텐서가 되어 "tensor a (1000) vs b (0)" 로 죽는다. + GL = int(J.get("groie_levels") or 0) + # ⚠️ 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) + # 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}")] + # 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}"] +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 + 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"] + 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 + [ + "-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_MaskRCNN_SubC/MaskRCNN_SubC.cpp", "out_MSRCNN_SubD/MSRCNN_SubD.cpp"] + 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")): + 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") + 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"] + 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", **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) + 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}", **prop_env}) + 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)}건") + # 계열마다 이미지가 다를 수 있으므로 **무엇으로 쟀는지**를 숫자 옆에 남긴다. + # 안 적으면 나중에 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}" + 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. + + ⚠️ **래퍼는 한 겹 안을 본다.** 트래커·반지도는 `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): + try: + 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 + + +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) + # ⚠️ 기본값일 때만 계열별 이미지가 끼어든다. 사용자가 `--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 + # 컴파일이다. 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() + + # ⚠️ **러너를 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__) + 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) + # 배너가 거짓말하지 않게 — 계열별 지정이 끼어들 수 있으면 그렇다고 적는다. + 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") + + 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())