diff --git a/docs/mmdet-detectors.md b/docs/mmdet-detectors.md index 5710eb0..2e69df9 100644 --- a/docs/mmdet-detectors.md +++ b/docs/mmdet-detectors.md @@ -80,17 +80,19 @@ python tools/frontend/mmdet/mmdet_to_pt.py \ 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). +**`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). Those two are what you read. Two more land beside them, and they are not optional: saving a module pickles its classes by module name, so whatever opens the `.pt` has to be able to @@ -160,15 +162,16 @@ 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/verify/mmdet/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. +**`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: @@ -194,22 +197,26 @@ output/MMDetBackbone/run_mmdet \ run_mmdet [size=512] ``` -`` -: Weights produced in step 2. +**``** + + 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. + 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. + 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 @@ -256,7 +263,7 @@ 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. +`tools/verify/common/draw_boxes.py` draws such a file afterwards, with class names and scores as text. ## Models whose head survives tracing @@ -409,35 +416,40 @@ struct detection { ### 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. +**`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` 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 — DFL is Distribution Focal Loss, which predicts each - box edge as a distribution over bins and recovers the distance by integrating it — plus 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. +**`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` 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 — DFL is Distribution Focal Loss, which predicts each + box edge as a distribution over bins and recovers the distance by integrating it — plus 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 @@ -446,32 +458,38 @@ 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 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 rpn_proposals(rpn_cls, rpn_bbox, feat_hw, rpn_params const& p)`** -`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. + 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 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`. +**`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 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)`** -`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)`. + Per-keypoint argmax over a heatmap; returns `k × 3` as `(x, y, score)`. ### Building blocks diff --git a/docs/using-the-cli.md b/docs/using-the-cli.md index a5b023f..73afd7a 100644 --- a/docs/using-the-cli.md +++ b/docs/using-the-cli.md @@ -23,36 +23,44 @@ The command selects the model, `-m` says which weights to load, `-i` and `-o` ar ## Options -`-m, --model ` -: The `.gguf` weights. Omit it and each command looks for its own default name — - `MobileSAM-F16.gguf`, `BiRefNet-lite-F16.gguf`, and so on — under `models/`, - `$VISION_MODEL_DIR`, `$XDG_DATA_HOME/visioncpp`, `~/.local/share/visioncpp` and the - install directory, in that order. +**`-m, --model `** -`-i, --input [ ...]` -: Input image. `migan` takes two — the image and the mask. + The `.gguf` weights. Omit it and each command looks for its own default name — + `MobileSAM-F16.gguf`, `BiRefNet-lite-F16.gguf`, and so on — under `models/`, + `$VISION_MODEL_DIR`, `$XDG_DATA_HOME/visioncpp`, `~/.local/share/visioncpp` and the + install directory, in that order. -`-o, --output ` -: Output file. Defaults to `output.png`. Images are always written as **PNG**, whatever - the name says — `-o out.jpg` produces a PNG file called `out.jpg`, which some viewers - refuse to open. Give it a `.png` name. +**`-i, --input [ ...]`** -`-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. + Input image. `migan` takes two — the image and the mask. -`-b, --backend ` -: Which device to run on. Defaults to automatic — GPU if the build has Vulkan and a device is - available, CPU otherwise. +**`-o, --output `** -`--composite ` -: Also write the input image combined with the resulting mask, instead of the mask alone. + Output file. Defaults to `output.png`. Images are always written as **PNG**, whatever + the name says — `-o out.jpg` produces a PNG file called `out.jpg`, which some viewers + refuse to open. Give it a `.png` name. -`--tile ` -: Split large inputs into tiles of this size. Used by `esrgan` to keep memory bounded. +**`-p, --prompt [ ...]`** -`-h, --help` -: Print the command list and exit. + 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 diff --git a/src/cli/cli.cpp b/src/cli/cli.cpp index bc0726b..08de020 100644 --- a/src/cli/cli.cpp +++ b/src/cli/cli.cpp @@ -469,6 +469,38 @@ void run_sam(cli_args const& args) { // // g2c 생성 arch (레지스트리 경유). **모델이 늘어도 이 함수는 안 바뀐다.** +// 좌표까지 찍는다. 그리기만 하면 결과를 수치로 확인할 방법이 없어서, 참조 구현과 +// 맞춰 보려면 디코더를 밖에서 다시 짜야 했다. 여기 이미 다 있는 값이다. +// SZ 는 그래프가 구워진 입력 크기 — 원본 이미지 크기로 되돌리는 배율을 여기서 만든다. +static void report_and_draw(image_data& image, std::vector const& dets, + arch_task const& task, int SZ, letterbox_info const& lb) { + // letterbox 면 좌표계가 다르다 — 배율 하나에 패딩이 붙는다. 단순 배율로 되돌리면 + // 비정사각 이미지에서 박스가 통째로 밀린다. + const float sx = task.letterbox ? 0.0f : float(image.extent[0]) / float(SZ); + const float sy = task.letterbox ? 0.0f : float(image.extent[1]) / float(SZ); + auto ux = [&](float v) { return task.letterbox ? (v - lb.pad_x) / lb.scale : v * sx; }; + auto uy = [&](float v) { return task.letterbox ? (v - lb.pad_y) / lb.scale : v * sy; }; + 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, + ux(d.x1), uy(d.y1), ux(d.x2), uy(d.y2), d.score, d.label, name); + } + if (task.letterbox) { + // draw_detections 는 배율만 받는다 — 패딩을 뺀 좌표로 미리 옮겨 넣는다. + std::vector shifted = dets; + for (detection& d : shifted) { + d.x1 = ux(d.x1); d.y1 = uy(d.y1); d.x2 = ux(d.x2); d.y2 = uy(d.y2); + } + draw_detections(image_span(image), shifted, task.class_names, 1.0f, 1.0f); + } else { + draw_detections(image_span(image), dets, task.class_names, sx, sy); + } +} + void run_generated(cli_args const& args) { arch_entry const* e = arch_find(args.arch); ASSERT(e != nullptr, "arch not registered"); // 파싱에서 이미 확인했다 @@ -486,17 +518,25 @@ void run_generated(cli_args const& args) { 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` 가 박는다. + // 비정사각 모델은 `input_w`/`input_h` 를 싣는다. 0 이면 정사각(`input_size`). + const int IW = task.input_w ? task.input_w : task.input_size; + const int IH = task.input_h ? task.input_h : task.input_size; + const int SZ = IW; // 정사각 경로의 옛 이름 — 아래 박스 되돌리기가 쓴다 + // 리사이즈 + **등록된** 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); + letterbox_info lb; + std::vector input_cwhn = + task.letterbox + ? preprocess_letterbox(image.data.get(), image.extent[1], image.extent[0], nch, IW, IH, + task.mean.data(), task.stdv.data(), /*to_rgb=*/false, &lb) + : preprocess(image.data.get(), image.extent[1], image.extent[0], nch, IW, IH, + 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"); + tensor input = compute_graph_input(m, GGML_TYPE_F32, {3, IW, IH, 1}, "x"); ggml_build_forward_expand(graph, input); ggml_build_forward_expand(graph, e->forward(m, input, file)); compute_graph_allocate(graph, backend); @@ -543,9 +583,62 @@ void run_generated(cli_args const& args) { } } } + // v8·v9·v11·v12 는 **디코드까지 그래프 안에서 끝내고** 박스와 확률을 한 텐서로 붙여 낸다 + // (`cat(dbox, cls.sigmoid())` → 4 + nc 채널). 여기서는 자르고 NMS 만 하면 된다 — + // `detect_yolo_dense` 에 넘기면 stride·sigmoid 를 **두 번** 먹는다. + int fused = -1; if (bi < 0 || si < 0) { + for (int i = (int)outs.size() - 1; i >= 0; --i) { + if (hw[i].first == task.num_classes + 4) { + fused = i; + break; + } + } + } + if (fused < 0 && (bi < 0 || si < 0)) { throw except("Could not find box/score outputs (num_classes={})", task.num_classes); } + if (fused >= 0) { + // 채널 우선 배치: flat[c*N + a]. 0~3 = cx,cy,w,h(입력 픽셀 단위), 4.. = 클래스 확률. + const int n = hw[fused].second; + float const* d = outs[fused].data(); + std::vector raw; + for (int a = 0; a < n; ++a) { + int best = -1; + float best_p = task.score_thr; + for (int c = 0; c < task.num_classes; ++c) { + float p = d[(size_t)(4 + c) * n + a]; + if (p > best_p) { + best_p = p; + best = c; + } + } + if (best < 0) { + continue; + } + float cx = d[a], cy = d[(size_t)n + a]; + float w = d[(size_t)2 * n + a], h = d[(size_t)3 * n + a]; + raw.push_back({cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2, best_p, best}); + } + printf("- decode: fused=out_%d anchors=%d (%zu over thr)\n", fused, n, raw.size()); + + std::vector dets; + if (task.nms_free) { + dets = std::move(raw); + } else { + for (int k : nms(raw, task.nms_thr)) { + dets.push_back(raw[k]); + } + } + if ((int)dets.size() > task.max_det) { + dets.resize(task.max_det); + } + report_and_draw(image, dets, task, SZ, lb); + image_save(image, args.output); + printf("-> %zu boxes drawn, saved to %s\n", dets.size(), args.output); + return; + } + const int n_anchor = hw[si].second; printf("- decode: box=out_%d score=out_%d anchors=%d\n", bi, si, n_anchor); @@ -574,22 +667,7 @@ void run_generated(cli_args const& args) { 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); + report_and_draw(image, dets, task, SZ, lb); image_save(image, args.output); printf("-> %zu boxes drawn, saved to %s\n", dets.size(), args.output); } diff --git a/src/visp/arch_registry.h b/src/visp/arch_registry.h index 29e7fd8..90fe98b 100644 --- a/src/visp/arch_registry.h +++ b/src/visp/arch_registry.h @@ -45,7 +45,19 @@ struct arch_task { float score_thr = 0.25f; float nms_thr = 0.7f; // nms_free 면 안 쓴다 int max_det = 300; - int input_size = 640; + int input_size = 640; // 정사각 모델의 한 변 + // ⚠️ **비정사각 모델은 여기에 실어야 한다.** 0 이면 `input_size` 를 정사각으로 쓴다. + // 안 실으면 러너가 정사각으로 리사이즈해 **조용히 틀린 출력**을 낸다 — + // mmseg cityscapes(512×1024)가 512×512 로 줄어 출력이 절반만 나왔다(2026-09-01). + // mmpose 는 전부 256×192 라 한 계열도 못 돌았다. + int input_w = 0; + int input_h = 0; + + // 비율을 유지하고 남는 자리를 회색으로 채운다(YOLO 규약). **YOLO 계열에서만 켠다** — + // 분할·자세는 비율을 뭉개는 직접 리사이즈가 맞다. + // ⚠️ 정사각 이미지로만 재면 이 값이 틀려도 안 드러난다(2026-09-02 실측: + // 512×512 는 차이 없고, 452×627 에서 handbag 점수가 0.87 vs 0.94 로 갈렸다). + bool letterbox = false; // 전처리 정규화 `(v - mean) / std`. 기본값은 0~1 (YOLO 규약). // ⚠️ **모델마다 다르다.** torchvision 분류기는 ImageNet 통계를 쓰는데, 0~1 로 돌리면 diff --git a/src/visp/nn.cpp b/src/visp/nn.cpp index 2cf16b2..800c017 100644 --- a/src/visp/nn.cpp +++ b/src/visp/nn.cpp @@ -85,12 +85,33 @@ tensor add_bias_2d(model_ref m, tensor x) { return x; } +// PReLU — **채널마다 다른 기울기**를 갖는다(`nn.PReLU(num_parameters=C)`). +// y = max(0,x) + w * min(0,x) = relu(x) + w * (x - relu(x)) +// ⚠️ **`leaky_relu(0.25)` 로 근사하지 마라.** 0.25 는 torch 의 **기본 초기값**이라 +// 랜덤 초기화 모델에서는 근사가 정확히 맞아떨어져 **PASS 가 나온다** — 학습된 +// 가중치에서만 틀린다(cgnet 실측: 랜덤 1.91e-04 PASS → 학습 5.09e+00 FAIL). +// `num_parameters=1` 이면 w 가 스칼라라 같은 식이 그대로 성립한다. +tensor prelu(model_ref m, tensor x) { + tensor w = m.weights("weight"); + if (!(m.flags & model_build_flag::cwhn)) { + // WHCN 은 채널이 ne[2] 다 — [1,1,C,1] 로 펴야 broadcast 된다(add_bias_2d 와 같은 규칙). + w = ggml_reshape_4d(m, w, 1, 1, w->ne[0], 1); + } + tensor pos = ggml_relu(m, x); + tensor neg = ggml_sub(m, x, pos); // min(x, 0) + return ggml_add(m, pos, ggml_mul(m, neg, w)); +} + // conv_2d 의 본체 — weight 를 인자로 받고 bias 는 붙이지 않는다. // conv_2d / conv_2d_wt 가 공유한다. dilation 기본값 1 은 기존 동작과 동일하다. -static tensor conv_2d_impl(model_ref m, tensor x, tensor weight, int stride, int pad, - int dilation) { +static tensor conv_2d_impl(model_ref m, tensor x, tensor weight, int sw, int sh, int pw, + int ph, int dw, int dh) { + // ⚠️ 축별 인자다. ggml 은 원래 축별로 받는데(`s0`=W, `s1`=H) 여기서 하나로 묶어 + // 넘기고 있었다 — 비대칭 conv(ERFNet 의 3x1/1x3)이 **조용히 대칭으로** 돌았다. if (m.flags & model_build_flag::cwhn) { - if (weight->ne[1] == 1 && weight->ne[2] == 1 && stride == 1 && dilation == 1) { + // 1x1 fast path 는 padding 을 아예 안 태운다 → pad 가 0 일 때만 쓸 수 있다. + if (weight->ne[1] == 1 && weight->ne[2] == 1 && sw == 1 && sh == 1 && dw == 1 && + dh == 1 && pw == 0 && ph == 0) { auto [c, w, h, b] = nelements(x); weight = ggml_reshape_2d(m, weight, weight->ne[0], weight->ne[3]); x = ggml_reshape_2d(m, x, x->ne[0], w * h * b); @@ -100,29 +121,38 @@ static tensor conv_2d_impl(model_ref m, tensor x, tensor weight, int stride, int } else if (m.flags & model_build_flag::conv_2d_direct_cwhn) { weight = permute_cwhn_to_whcn(m, weight); x = permute_cwhn_to_whcn(m, x); - x = ggml_conv_2d_direct(m, weight, x, stride, stride, pad, pad, dilation, dilation); + x = ggml_conv_2d_direct(m, weight, x, sw, sh, pw, ph, dw, dh); x = permute_whcn_to_cwhn(m, x); } else { weight = ggml_cont(m, permute_cwhn_to_whcn(m, weight)); x = ggml_cont(m, permute_cwhn_to_whcn(m, x)); - x = ggml_conv_2d(m, weight, x, stride, stride, pad, pad, dilation, dilation); + x = ggml_conv_2d(m, weight, x, sw, sh, pw, ph, dw, dh); x = ggml_cont(m, permute_whcn_to_cwhn(m, x)); } } else { // WHCN layout - x = ggml_conv_2d_direct(m, weight, x, stride, stride, pad, pad, dilation, dilation); + x = ggml_conv_2d_direct(m, weight, x, sw, sh, pw, ph, dw, dh); } return x; } tensor conv_2d(model_ref m, tensor x, int stride, int pad, int dilation) { - x = conv_2d_impl(m, x, m.weights("weight"), stride, pad, dilation); + x = conv_2d_impl(m, x, m.weights("weight"), stride, stride, pad, pad, dilation, dilation); + return add_bias_2d(m, x); +} + +// 축별 conv — 커널·padding·dilation 이 H 와 W 에서 다른 경우(ERFNet 의 3x1/1x3 분리 conv). +// 인자 순서는 ggml 과 같은 **W 먼저**다(`ggml_conv_2d(s0=W, s1=H, …)`). +tensor conv_2d_ex(model_ref m, tensor x, int stride_w, int stride_h, int pad_w, int pad_h, + int dilation_w, int dilation_h) { + x = conv_2d_impl(m, x, m.weights("weight"), stride_w, stride_h, pad_w, pad_h, dilation_w, + dilation_h); return add_bias_2d(m, x); } tensor conv_2d_wt(model_ref m, tensor x, tensor weight, tensor bias, int stride, int pad, int dilation) { - x = conv_2d_impl(m, x, weight, stride, pad, dilation); + x = conv_2d_impl(m, x, weight, stride, stride, pad, pad, dilation, dilation); if (bias) { // WHCN 은 채널이 ne[2] 라 broadcast 를 위해 [1,1,C,1] 로 편다 (add_bias_2d 와 같은 규칙). if (!(m.flags & model_build_flag::cwhn)) { @@ -133,6 +163,29 @@ tensor conv_2d_wt(model_ref m, tensor x, tensor weight, tensor bias, int stride, return x; } +tensor conv_2d_wt_dw(model_ref m, tensor x, tensor weight, tensor bias, int stride, int pad, + int dilation) { + // ⚠️ **입력을 whcn 연속으로 만들어 넘긴다.** `ggml_compute_forward_conv_2d_dw` 는 + // src 가 contiguous 면 whcn 커널을, contiguous_channels 면 cwhn 커널을 타는데 + // cwhn 쪽은 **kernel 에도** `nb[0] >= nb[2]` 를 요구한다. 동적 커널은 그래프에서 + // torch 순서(`[kw,kh,1,C]` 연속)로 오므로 그 단언에 걸린다 — cwhn 모델이어도 + // 입력을 whcn 으로 돌려 whcn 커널을 타게 하는 편이 레이아웃 가정을 안 만든다. + bool cwhn = bool(m.flags & model_build_flag::cwhn); + x = cwhn ? ggml_cont(m, permute_cwhn_to_whcn(m, x)) : ggml_cont(m, x); + weight = ggml_cont(m, weight); + x = ggml_conv_2d_dw_direct(m, weight, x, stride, stride, pad, pad, dilation, dilation); + if (cwhn) { + x = ggml_cont(m, permute_whcn_to_cwhn(m, x)); + } + if (bias) { + if (!cwhn) { + bias = ggml_reshape_4d(m, bias, 1, 1, bias->ne[0], 1); + } + x = ggml_add_inplace(m, x, bias); + } + return x; +} + tensor conv_2d_grouped(model_ref m, tensor x, int stride, int pad, int dilation, int groups) { if (groups <= 1) { return conv_2d(m, x, stride, pad, dilation); @@ -169,22 +222,32 @@ tensor conv_2d_grouped(model_ref m, tensor x, int stride, int pad, int dilation, return add_bias_2d(m, y); } -tensor conv_2d_depthwise(model_ref m, tensor x, int stride, int pad) { +// `dilation` 은 torch 와 같은 뜻이다. ⚠️ **안 받으면 조용히 커진다** — 호출자가 padding 만 +// 넘기고 dilation 을 못 넘기면 출력이 `in + 2p - (k-1)` 이 되어 입력보다 크게 나오고, +// 그 크기는 **다음 op(concat)에서** 어긋나 죽는다(mmseg DeepLabV3+ 의 depthwise ASPP: +// dilation 12/24/36 인데 64x64 가 86/110/134 로 커졌다). +tensor conv_2d_depthwise_ex(model_ref m, tensor x, int sw, int sh, int pw, int ph, int dw, + int dh) { tensor weight = m.weights("weight"); if (m.flags & model_build_flag::cwhn) { weight = ggml_permute(m, weight, 3, 2, 0, 1); x = permute_cwhn_to_whcn(m, x); - x = ggml_conv_2d_dw_direct(m, weight, x, stride, stride, pad, pad, 1, 1); + x = ggml_conv_2d_dw_direct(m, weight, x, sw, sh, pw, ph, dw, dh); x = permute_whcn_to_cwhn(m, x); } else { - x = ggml_conv_2d_dw_direct(m, weight, x, stride, stride, pad, pad, 1, 1); + x = ggml_conv_2d_dw_direct(m, weight, x, sw, sh, pw, ph, dw, dh); } x = add_bias_2d(m, x); return x; } -tensor conv_transpose_2d(model_ref m, tensor x, int stride, int pad, int groups) { +tensor conv_2d_depthwise(model_ref m, tensor x, int stride, int pad, int dilation) { + return conv_2d_depthwise_ex(m, x, stride, stride, pad, pad, dilation, dilation); +} + +tensor conv_transpose_2d(model_ref m, tensor x, int stride, int pad, int groups, + int output_padding) { tensor weight = m.weights("weight"); // ⚠️ **커널은 F16 이어야 한다.** `ggml_compute_forward_conv_transpose_2d` 는 // `GGML_ASSERT(src0->type == GGML_TYPE_F16)` 로 시작한다(ggml-cpu/ops.cpp). @@ -227,12 +290,28 @@ tensor conv_transpose_2d(model_ref m, tensor x, int stride, int pad, int groups) // **다음 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])); + // ⚠️ **`output_padding` 은 「덜 자른다」로 처리한다.** torch 는 출력 오른쪽·아래를 + // 그만큼 더 갖는데, p0 버퍼는 자르기 전이라 그 자리를 대개 이미 갖고 있다. + // torch 출력 인덱스 j 는 버퍼 인덱스 j+pad 다. 버퍼가 모자라는 만큼 + // (output_padding > pad) 은 실제로 기여가 0 인 자리라 0 으로 채운다. + // 안 처리하면 출력이 output_padding 만큼 작고, 뒤의 residual add 가 + // `ggml_can_repeat` 로 죽는다 — 크래시 지점이 원인 지점이 아니다 + // (erfnet UpsamplerBlock: stride 2 · pad 1 · output_padding 1 → 2n-1 vs 2n). + if (pad > 0 || output_padding > 0) { + const int64_t bw = x->ne[0], bh = x->ne[1]; + const int64_t ow = bw - 2 * pad + output_padding; + const int64_t oh = bh - 2 * pad + output_padding; + GGML_ASSERT(ow > 0 && oh > 0); + const int64_t vw = ow < bw - pad ? ow : bw - pad; // 버퍼에서 꺼낼 수 있는 만큼 + const int64_t vh = oh < bh - pad ? oh : bh - pad; + if (pad > 0 || vw != bw || vh != bh) { + x = ggml_cont(m, ggml_view_4d(m, x, vw, vh, x->ne[2], x->ne[3], + x->nb[1], x->nb[2], x->nb[3], + pad * x->nb[0] + pad * x->nb[1])); + } + if (vw < ow || vh < oh) { + x = ggml_pad(m, x, (int) (ow - vw), (int) (oh - vh), 0, 0); + } } if (m.flags & model_build_flag::cwhn) { x = ggml_cont(m, permute_whcn_to_cwhn(m, x)); diff --git a/src/visp/nn.h b/src/visp/nn.h index 27ab87f..953e89a 100644 --- a/src/visp/nn.h +++ b/src/visp/nn.h @@ -30,6 +30,23 @@ tensor space_to_depth_quad(model_ref m, tensor x, int sw, int sh); tensor permute_cwhn_to_whcn(model_ref m, tensor x); tensor permute_whcn_to_cwhn(model_ref m, tensor x); +// 정수 텐서를 f32 로 올린다. +// +// ⚠️ **ggml 의 이항 연산(add/sub/mul/div)은 정수 타입을 모른다.** +// `ggml-cpu/binary-ops.cpp` 가 `binary_op: unsupported types: dst: i32, src0: i32, src1: i32` +// 로 **abort** 한다. 인덱스 계산이 그 자리다 — `ggml_get_rows` 가 I32 를 강제하므로 +// 한 번 i32 가 된 텐서가 그대로 산술까지 흘러온다(mmseg point_rend 의 점 좌표 차). +// +// ⚠️ 그 abort 는 **크래시로 안 보인다.** OpenMP 병렬 구간 안에서 나면 나머지 스레드가 +// 교착에 빠져 프로세스가 CPU 0% 로 매달린다 — 「느린 모델」로 오인된다(12분 관측). +// 행의 지문은 「경과 시간은 느는데 CPU 시간이 0」이다. +// +// 좌표·인덱스는 정수라 f32 로 계산해도 값이 안 깨진다(f32 는 2^24 까지 정확). +// **이미 f32 면 그대로 돌려주므로 비용이 없다** — 그래서 무조건 감싸도 된다. +inline tensor as_f32(model_ref m, tensor x) { + return x->type == GGML_TYPE_F32 ? x : ggml_cast(m, ggml_cont(m, x), GGML_TYPE_F32); +} + // "Contiguous 2D" refers to the layout configured in `m` model flags, ie. the preferred // memory layout for 2D operations like convolution. inline bool is_whcn(model_ref m) { return !(m.flags & model_build_flag::cwhn); } @@ -49,6 +66,11 @@ std::array nelements_whcn(model_ref const&, tensor t); // Input and weight are expected to be in "contiguous 2D" layout as configured in `m`. tensor conv_2d(model_ref m, tensor x, int stride = 1, int pad = 0, int dilation = 1); +// 축별 conv — H 와 W 의 padding/stride/dilation 이 다른 경우(ERFNet 의 3x1 · 1x3 분리 conv). +// ⚠️ 인자는 ggml 과 같은 **W 먼저**다. torch 의 (H, W) 순서와 반대이므로 렌더러에서 뒤집는다. +tensor conv_2d_ex(model_ref m, tensor x, int stride_w, int stride_h, int pad_w, int pad_h, + int dilation_w = 1, int dilation_h = 1); + // grouped conv (nn.Conv2d(groups=g)). groups<=1 이면 conv_2d 와 동일. // ggml 에 grouped conv 커널이 없어 그룹별 view → conv → 채널축 concat 으로 분해한다. tensor conv_2d_grouped(model_ref m, tensor x, int stride = 1, int pad = 0, @@ -59,16 +81,37 @@ tensor conv_2d_grouped(model_ref m, tensor x, int stride = 1, int pad = 0, tensor conv_2d_wt(model_ref m, tensor x, tensor weight, tensor bias, int stride = 1, int pad = 0, int dilation = 1); -tensor conv_2d_depthwise(model_ref m, tensor x, int stride = 1, int pad = 0); +// 동적 weight 의 **depthwise** conv (groups == 채널수). DMNet 의 DCM 이 이 자리다 — +// 필터를 입력에서 만들어 채널마다 다른 커널로 돈다. +// weight 는 torch `[C,1,kh,kw]` → ggml `[kw,kh,1,C]` 로 그대로 온다(GGUF 경유가 아니라 +// 그래프 텐서라 `conv_2d_depthwise` 의 레이아웃 되돌림이 필요 없다). +tensor conv_2d_wt_dw(model_ref m, tensor x, tensor weight, tensor bias, + int stride = 1, int pad = 0, int dilation = 1); + +tensor conv_2d_depthwise(model_ref m, tensor x, int stride = 1, int pad = 0, int dilation = 1); + +// 축별 depthwise conv — 커널·padding 이 H 와 W 에서 다른 strip conv 용 +// (SegNeXt MSCAN 의 1x7/7x1 · 1x11/11x1 · 1x21/21x1). 인자 순서는 ggml 과 같은 **W 먼저**다. +// ⚠️ 단일 int 헬퍼로 내면 한쪽 padding 이 통째로 버려지거나 없던 쪽에 붙는다. +tensor conv_2d_depthwise_ex(model_ref m, tensor x, int stride_w, int stride_h, int pad_w, + int pad_h, int dilation_w, int dilation_h); tensor conv_2d_deform( model_ref m, tensor x, tensor weight, tensor offset, tensor mask, int stride, int pad); // `pad` 는 torch 의 ConvTranspose2d padding 과 같은 뜻이다(출력 가장자리를 그만큼 버린다). // ggml 에는 padding 을 받는 conv_transpose 가 없어 여기서 잘라낸다. // `groups` 는 torch 와 같은 뜻이다. ggml 에 grouped conv_transpose 가 없어 그룹마다 // 커널·입력을 잘라 돌리고 채널로 이어붙인다. -tensor conv_transpose_2d(model_ref m, tensor x, int stride, int pad = 0, int groups = 1); +// `output_padding` 도 torch 와 같은 뜻이다(출력 오른쪽·아래에 그만큼 더 붙인다). +// ⚠️ 렌더러와 **같이** 고쳐야 한다 — 한쪽만 고치면 크기가 1 어긋난 채 조용히 돈다. +tensor conv_transpose_2d( + model_ref m, tensor x, int stride, int pad = 0, int groups = 1, int output_padding = 0); tensor batch_norm_2d(model_ref, tensor x); +// PReLU. 기울기 `weight` 는 채널당 하나(또는 전체 하나)다. +// ⚠️ `ggml_leaky_relu(0.25)` 로 근사하지 마라 — 0.25 는 torch 기본 초기값이라 +// **랜덤 가중치에서만 맞는다.** 학습된 모델에서 조용히 틀린다. +tensor prelu(model_ref m, tensor x); + // 2D image to patch embedding using convolution and optional norm. CWHN input and output. tensor patch_embed(model_ref, tensor x, int patch_size); diff --git a/src/visp/postproc.cpp b/src/visp/postproc.cpp index fe02fd2..605d110 100755 --- a/src/visp/postproc.cpp +++ b/src/visp/postproc.cpp @@ -699,19 +699,73 @@ std::vector roi_align( } // ── 전처리: 이미지(HWC u8) → 모델 입력(CWHN f32) ───────────────────────────── +std::vector preprocess_letterbox(uint8_t const* img, int img_h, int img_w, int img_c, + int dst_w, int dst_h, float const mean[3], + float const std[3], bool to_rgb, letterbox_info* info, + float pad_value) { + // ultralytics LetterBox(auto=false, scaleFill=false): 긴 변에 맞춘 **단일 배율**로 줄이고 + // 남는 자리를 양쪽에 반씩 패딩한다. 배율이 가로·세로 따로면 종횡비가 뭉개져 + // 컷 근처 물체의 점수가 흔들린다. + const float scale = std::min((float)dst_w / img_w, (float)dst_h / img_h); + const int rw = std::max(1, (int)std::round(img_w * scale)); + const int rh = std::max(1, (int)std::round(img_h * scale)); + const float pad_x = (dst_w - rw) / 2.0f; + const float pad_y = (dst_h - rh) / 2.0f; + if (info) { + info->scale = scale; + info->pad_x = pad_x; + info->pad_y = pad_y; + } + + std::vector out((size_t)dst_w * dst_h * 3); + // 패딩 자리를 먼저 채운다 — 정규화도 같이 먹인다(패딩은 원시 114 가 아니라 정규화된 값이다). + for (int i = 0; i < dst_w * dst_h; ++i) { + for (int c = 0; c < 3; ++c) { + out[(size_t)i * 3 + c] = (pad_value - mean[c]) / std[c]; + } + } + + const int x_off = (int)std::round(pad_x), y_off = (int)std::round(pad_y); + const float sh = (float)img_h / rh, sw = (float)img_w / rw; + for (int oh = 0; oh < rh; ++oh) { + float fy = (oh + 0.5f) * sh - 0.5f; + int y0 = (int)std::floor(fy); float wy = fy - y0; + int y0c = std::min(std::max(y0, 0), img_h - 1), y1c = std::min(y0 + 1, img_h - 1); + for (int ow = 0; ow < rw; ++ow) { + float fx = (ow + 0.5f) * sw - 0.5f; + int x0 = (int)std::floor(fx); float wx = fx - x0; + int x0c = std::min(std::max(x0, 0), img_w - 1), x1c = std::min(x0 + 1, img_w - 1); + for (int c = 0; c < 3; ++c) { + int sc = to_rgb ? (2 - c) : c; + auto at = [&](int yy, int xx) { + return (float)img[((size_t)yy * img_w + xx) * img_c + sc]; + }; + float v = at(y0c, x0c) * (1 - wy) * (1 - wx) + at(y0c, x1c) * (1 - wy) * wx + + at(y1c, x0c) * wy * (1 - wx) + at(y1c, x1c) * wy * wx; + // ultralytics 는 리사이즈 결과를 uint8 로 되돌린다. 안 맞추면 픽셀의 12% 가 + // 1~3 레벨 달라지고 컷 근처 점수가 흔들린다(2026-09-02 실측). + v = std::round(std::min(std::max(v, 0.0f), 255.0f)); + out[((size_t)(oh + y_off) * dst_w + (ow + x_off)) * 3 + c] = + (v - mean[c]) / std[c]; + } + } + } + return out; +} + 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], + int dst_w, int dst_h, float const mean[3], float const std[3], bool to_rgb, int* out_w, int* out_h) { - if (out_w) *out_w = out_size; - if (out_h) *out_h = out_size; - // bilinear resize → out_size×out_size, normalize, CWHN(index=(h*W+w)*C+c) - std::vector out((size_t)out_size * out_size * 3); - float sh = (float)img_h / out_size, sw = (float)img_w / out_size; - for (int oh = 0; oh < out_size; ++oh) { + if (out_w) *out_w = dst_w; + if (out_h) *out_h = dst_h; + // bilinear resize → dst_w×dst_h, normalize, CWHN(index=(h*W+w)*C+c) + std::vector out((size_t)dst_w * dst_h * 3); + float sh = (float)img_h / dst_h, sw = (float)img_w / dst_w; + for (int oh = 0; oh < dst_h; ++oh) { float fy = (oh + 0.5f) * sh - 0.5f; int y0 = (int)std::floor(fy); float wy = fy - y0; int y0c = std::min(std::max(y0, 0), img_h - 1), y1c = std::min(y0 + 1, img_h - 1); - for (int ow = 0; ow < out_size; ++ow) { + for (int ow = 0; ow < dst_w; ++ow) { float fx = (ow + 0.5f) * sw - 0.5f; int x0 = (int)std::floor(fx); float wx = fx - x0; int x0c = std::min(std::max(x0, 0), img_w - 1), x1c = std::min(x0 + 1, img_w - 1); @@ -722,7 +776,7 @@ std::vector preprocess(uint8_t const* img, int img_h, int img_w, int img_ }; float v = at(y0c, x0c) * (1 - wy) * (1 - wx) + at(y0c, x1c) * (1 - wy) * wx + at(y1c, x0c) * wy * (1 - wx) + at(y1c, x1c) * wy * wx; - out[((size_t)oh * out_size + ow) * 3 + c] = (v - mean[c]) / std[c]; + out[((size_t)oh * dst_w + ow) * 3 + c] = (v - mean[c]) / std[c]; } } } diff --git a/src/visp/postproc.h b/src/visp/postproc.h index 12598eb..2a791b2 100755 --- a/src/visp/postproc.h +++ b/src/visp/postproc.h @@ -392,9 +392,38 @@ std::vector roi_align( float const* level_rois = nullptr); // ── 전처리: 이미지(HWC u8) → 모델 입력 텐서(CWHN f32) ─────────────────────── -// resize(size×size) + normalize((v-mean)/std). BGR/RGB·mean/std 는 인자. +// resize(dst_w×dst_h) + normalize((v-mean)/std). BGR/RGB·mean/std 는 인자. +// +// ⚠️ **가로·세로를 따로 받는다.** 예전엔 `out_size` 하나로 정사각 리사이즈만 했는데, +// 그러면 비정사각 모델이 **조용히 틀린다** — mmseg cityscapes(512×1024)를 넣으면 +// 512×512 로 줄여 출력이 절반만 나오고 에러는 안 난다(2026-09-01 실측). +// mmpose 는 전부 256×192 라 하나도 못 돌았다. +// 정사각 모델은 두 인자에 같은 값을 주면 예전과 동일하다. 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], + int dst_w, int dst_h, float const mean[3], float const std[3], bool to_rgb, int* out_w = nullptr, int* out_h = nullptr); +// letterbox: **비율을 유지**하고 남는 자리를 회색(114)으로 채운다. YOLO 계열이 이 규약이다. +// ⚠️ **분할·자세 계열에 켜지 마라.** 그쪽은 비율을 뭉개는 직접 리사이즈가 맞다 +// (mmseg cityscapes 512×1024 는 512×1024 로 그대로 넣는다). +// ⚠️ 켜면 좌표계가 달라진다 — 되돌릴 때 `(좌표 - pad) / scale` 이다. 단순 배율로 되돌리면 +// 비정사각 이미지에서 박스가 밀린다(2026-09-02 실측: handbag 점수 0.87 vs 0.94). +struct letterbox_info { + float scale = 1.0f; // 원본 → 리사이즈 배율(가로·세로 동일) + float pad_x = 0.0f; // 왼쪽 패딩(픽셀) + float pad_y = 0.0f; // 위쪽 패딩(픽셀) +}; +std::vector preprocess_letterbox(uint8_t const* img, int img_h, int img_w, int img_c, + int dst_w, int dst_h, float const mean[3], + float const std[3], bool to_rgb, letterbox_info* info, + float pad_value = 114.0f); + +// 정사각 편의 오버로드 — 기존 호출부 호환. +inline 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) { + return preprocess(img, img_h, img_w, img_c, out_size, out_size, mean, std, + to_rgb, out_w, out_h); +} + } // namespace visp diff --git a/tools/README.md b/tools/README.md index dfae01e..a5efb5b 100755 --- a/tools/README.md +++ b/tools/README.md @@ -43,7 +43,7 @@ Nothing here holds a table of layer names. The final classification and regressi 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 +`verify/mmdet/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. @@ -144,7 +144,7 @@ encoded as colour, which keeps a font out of the runner. | `VISP_DRAW_THRESHOLD` | Minimum score to draw. Default `0.3` | | `VISP_PRINT_DETS` | How many rows to print. `0` turns the table off | -`tools/verify/draw_boxes.py` draws a `.bin` that was written earlier, which is the way to look +`tools/verify/common/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. diff --git a/tools/build/build_mmdet_cpp.sh b/tools/build/build_mmdet_cpp.sh index dd0012e..efc9dde 100755 --- a/tools/build/build_mmdet_cpp.sh +++ b/tools/build/build_mmdet_cpp.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# build_mmdet_cpp.sh — g2c 가 생성한 mmdet 백본(output/.cpp)을 러너(verify/backbone/run_mmdet)· +# build_mmdet_cpp.sh — g2c 가 생성한 mmdet 백본(output/.cpp)을 러너(verify/mmdet/backbone/run_mmdet)· # head 부품(detect/head.cpp)과 함께 컴파일해 libvisioncpp 에 링크한다. # head.cpp 는 라이브러리가 아니라 여기서 러너와 함께 컴파일된다 → g2c output/.cpp 를 직접 # 컴파일(arch/ 복사·cli REG 없음). diff --git a/tools/example_mmdet.sh b/tools/example_mmdet.sh index 894cf45..0db3870 100755 --- a/tools/example_mmdet.sh +++ b/tools/example_mmdet.sh @@ -20,7 +20,7 @@ SIZE="${SIZE:-512}" VCPP="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" MMDET="${MMDET:-$HOME/mmbuild/mmdetection}" WORK="${WORK:-/tmp/visp-example-$FAM}" -DH="$VCPP/tools/verify/dense_head" +DH="$VCPP/tools/verify/mmdet/dense_head" # ⚠️ 이 예제만 **mmdet 이 설치된 인터프리터**가 필요하다. 앞의 세 예제와 갈리는 지점이다 — # 거긴 `uv run --project` 로 g2c 환경을 쓰면 됐지만, 여기선 하네스가 mmdet 을 import 한다. diff --git a/tools/frontend/mmdet/append_head_weights.py b/tools/frontend/mmdet/append_head_weights.py index a370010..297c133 100644 --- a/tools/frontend/mmdet/append_head_weights.py +++ b/tools/frontend/mmdet/append_head_weights.py @@ -86,8 +86,15 @@ def append(pt_path: str, gguf_path: str, prefixes=None) -> int: continue arr = tensor.detach().cpu().numpy() # 정수 버퍼를 fp16 으로 구우면 값이 뭉개진다 — 부동소수만 내린다. + # ⚠️ **본체가 어떤 정밀도로 구워졌는지 따라가야 한다.** g2c 의 `generate_gguf` 는 + # `GTX_GGUF_F32=1` 이면 fp32 로 굽는데 여기만 fp16 을 하드코딩하고 있었다. + # 한 gguf 안에 두 정밀도가 섞이면 러너가 **세그폴트로 죽는다**(실측: fp32 로 + # 구운 yolact·rpn 이 `run_mmdet` 진입 직후 SIGSEGV). + # 「정밀도냐 버그냐」를 가르는 fp32 대조가 one-stage 계열에서 **통째로 막혀 + # 있었다** — two-stage 는 head 를 덧붙이지 않아 안 걸렸다. if arr.dtype.kind == "f": - arr = arr.astype(np.float16) + arr = arr.astype(np.float32 + if os.environ.get("GTX_GGUF_F32") == "1" else np.float16) # ⚠️ **여기도 이름을 줄여야 한다.** g2c 가 굽는 쪽(`generate_gguf`)만 줄이면 # 나중에 덧붙는 head 가중치가 64자를 넘어 로드가 거부된다 # (`mask_head.mask_feature_head.convs_all_levels.…` 실측 64자). diff --git a/tools/frontend/mmdet/frcnn_wrap.py b/tools/frontend/mmdet/frcnn_wrap.py index 87a0fed..fc6cd87 100755 --- a/tools/frontend/mmdet/frcnn_wrap.py +++ b/tools/frontend/mmdet/frcnn_wrap.py @@ -232,12 +232,41 @@ def num_bbox_stages(det): class MaskRCNN_SubC(nn.Module): - """mask RoIAlign feat (M,256,14,14) → mask_logits (M, num_classes, 28, 28). (Mask R-CNN).""" + """mask RoIAlign feat (M,256,14,14) → mask_logits (M, num_classes, 28, 28). (Mask R-CNN). + + ⚠️ **마스크 추출기도 `GenericRoIExtractor` 일 수 있다**(`groie`). 그때는 bbox 쪽과 + 똑같이 레벨을 고르지 않고 전 레벨에 RoIAlign 을 걸어 pre→합산→post 를 태운다. + 러너가 레벨별 결과를 **배치로 이어붙여** 넣고 여기서 가른다 — `FRCNN_SubB` 와 같은 수법. + 다른 점 하나: 러너는 마스크 head 를 **검출 하나씩(배치 1)** 돌리므로 레벨당 RoI 수가 + 항상 **1** 이다(bbox 쪽은 `rpn.max_per_img`). 그래서 여기 `mgroie_n` 은 상수 1 이다. + """ def __init__(self, det): super().__init__() self.mask_head = det.roi_head.mask_head + mext = getattr(det.roi_head, "mask_roi_extractor", None) + if isinstance(mext, nn.ModuleList): + mext = mext[0] + gen = mext is not None and type(mext).__name__ == "GenericRoIExtractor" + self.mgroie_pre = (getattr(mext, "pre_module", None) + if gen and getattr(mext, "with_pre", False) else None) + self.mgroie_post = (getattr(mext, "post_module", None) + if gen and getattr(mext, "with_post", False) else None) + self.mgroie_lvls = len(getattr(mext, "featmap_strides", []) or []) if gen else 0 def forward(self, mask_feat): + # groie: (L, C, 14, 14) 로 들어온다 → pre 한 번 → 레벨별로 갈라 합산 → post + # ⚠️ `getattr(..., 기본값)` 으로 읽는다 — 이 클래스는 절여져서 **다른 프로세스**가 + # 푼다. 새 속성을 그냥 읽으면 옛 코드가 절인 객체에서 AttributeError 가 난다. + L = getattr(self, "mgroie_lvls", 0) + if L: + if getattr(self, "mgroie_pre", None) is not None: + mask_feat = self.mgroie_pre(mask_feat) + acc = mask_feat[:1] # 러너가 검출 하나씩 넣는다 → 레벨당 1 + for i in range(1, L): + acc = acc + mask_feat[i:i + 1] + mask_feat = acc + if getattr(self, "mgroie_post", None) is not None: + mask_feat = self.mgroie_post(mask_feat) return self.mask_head(mask_feat) @@ -328,16 +357,22 @@ def frcnn_cfg(det, size=800): # 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 렌더러 작업이다") + # `post_cfg` 가 `GeneralizedAttention` 인 경우(2026-08-24 진행 중): + # ✔ 5D 위치 임베딩 repeat — 렌더러가 4D 로 접는다(`shape/render.reduce_to_4d`) + # ✔ `aten::div_` — 렌더러 등록(없을 땐 나눗셈이 통째로 사라졌다) + # ✔ 비균일 상수(`dim_mat` 64원소) — 리터럴 상수를 항상 GGUF 로 굽는다 + # ✔ `div` 양방향 broadcast — `sub` 와 같은 처방(좌변 실체화) + # ✔ >4D `zeros` 가 1원소로 뭉개지던 것 — 같은 축소 규칙으로 접는다 + # ✗ **6D `energy` 를 4D 로 못 맞춘다.** `energy(n,heads,h,w,h_kv,w_kv)` 는 6D 이고 + # 위치항은 5D 인데, 바깥 축 병합은 **각자 다른 개수의 축을 접는다** — + # energy 는 (42000,7,4,4), 위치항은 (6000,7,7,4) 가 돼 broadcast 가 안 맞는다. + # 일반 규칙(인접 축 병합)으로는 여기까지다. **축을 피연산자끼리 맞추려면 + # 이 op 전용 로우어링**(예: (h,w)→hw, (h_kv,w_kv)→kv 로 접어 (n*heads, hw, kv)) + # 이 필요하다 — 그래프 전역 레이아웃 패스는 pvt 를 회귀시킨 전례가 있다. + # ⚠️ **op 이름으로 거절하는 게 아니다.** 위 둘은 실제로 남은 한계이고, 둘 다 고쳐지면 + # 이 분기를 지운다. 이름 목록은 실제 한계보다 항상 넓거나 좁다 — 이전 조건은 + # `GeneralizedAttention` 전체를 막았지만 `empirical_attention`(`0010`)은 진작 + # 통과하고 있었다. 막힌 건 `attention_type[1]/[3]`(기하 갈래)뿐이다. bh = det.roi_head.bbox_head # 캐스케이드는 bbox_head 도 ModuleList 다. 클래스 수·coder 종류는 단계 공통이라 # **마지막 단계**를 쓴다(최종 박스를 내는 단계라 디코드 규약이 거기 맞춰져 있다). @@ -381,6 +416,17 @@ def frcnn_cfg(det, size=800): "mask_finest_scale": int(getattr(mext, "finest_scale", 56)), "mask_thr_binary": float(rcnn_c.mask_thr_binary), } + # 마스크 로짓을 재려면 `MaskRCNN_SubC` 를 구울 수 있어야 한다. head 가 **하나일 + # 때만** 굽는다 — HTC·SCNet 은 `mask_head` 가 `ModuleList`(단계마다 하나)라 + # 통째로 태우면 `forward` 가 없어 export 에서 죽는다. + # ⚠️ 이 값이 **박스 판정을 바꾸면 안 된다.** 마스크는 더 재는 축이지 문턱이 아니다 + # (게이트는 `groie` 하나뿐 — `verify_postproc_roi.MASK_GATE`). + mask["mask_head_single"] = int( + not isinstance(det.roi_head.mask_head, nn.ModuleList)) + # 마스크 추출기도 레벨을 고르지 않을 수 있다 — 러너가 레벨마다 RoIAlign 을 걸어 + # 배치로 이어붙여야 한다(0 이면 평소대로 레벨 하나를 고른다). + mask["mask_groie_levels"] = (len(mext.featmap_strides) + if type(mext).__name__ == "GenericRoIExtractor" else 0) # Mask Scoring R-CNN: 마스크 IoU 로 점수를 다시 매긴다. 없으면 안 싣는다. if getattr(det.roi_head, "mask_iou_head", None) is not None: mih = det.roi_head.mask_iou_head @@ -476,7 +522,11 @@ def frcnn_cfg(det, size=800): # 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), + # ⚠️ **`det.rpn_head` 를 직접 읽지 마라.** `FastRCNN` 은 proposal 을 밖에서 받는 + # 것이 정의라 그 속성이 **아예 없다**(`AttributeError: 'FastRCNN' object has no + # attribute 'rpn_head'`). 위에서 `rh` 로 안전하게 받아 뒀는데 여기 한 줄만 + # 직접 읽고 있어서, 외부 proposal 경로를 다 갖춰 놓고도 이 줄에서 죽었다. + "num_proposals": int(getattr(rh, "num_proposals", 0) or 0), # groie: 러너가 레벨마다 RoIAlign 을 걸어 배치로 이어붙여야 한다(0 이면 평소대로). "groie_levels": len(getattr(ext, "featmap_strides", []) or []) if type(ext).__name__ == "GenericRoIExtractor" else 0, diff --git a/tools/frontend/mmdet/mmdet_compat.py b/tools/frontend/mmdet/mmdet_compat.py index f376dfc..9b79329 100644 --- a/tools/frontend/mmdet/mmdet_compat.py +++ b/tools/frontend/mmdet/mmdet_compat.py @@ -156,6 +156,8 @@ def sigmoid_geometric_mean(x, y): _patch_carafe() _patch_swin_mask() + _patch_pointrend_coords() + _patch_san_attn_bias() _patch_sac_dilation() _patch_masked_conv() @@ -274,6 +276,183 @@ def forward(self, x): SAConv2d.forward = forward SAConv2d._visp_sac_patched = True + +import torch.nn as _nn + + +class _ScalarAffine(_nn.Module): + """`nn.Linear(1, 1)` 을 **matmul 없이** 낸다 — 스칼라 affine 이다. + + ⚠️ **모듈 스코프에 둔다.** 함수 안에 정의하면 `torch.save(model)` 가 + `Can't pickle local object` 로 죽는다(하네스가 모델을 `.pt` 로 저장한다). + """ + + def __init__(self, lin): + super().__init__() + self.register_buffer("w", lin.weight.detach().reshape(())) + b = (lin.bias.detach().reshape(()) if lin.bias is not None + else lin.weight.detach().new_zeros(())) + self.register_buffer("b", b) + + def forward(self, x): + return x * self.w + self.b + + +def _patch_pointrend_coords(): + """PointRend 의 좌표 조립을 **슬라이스 대입 대신 `stack`** 으로 다시 쓴다. + + ⚠️ **trace 가 부분 슬라이스 대입을 삼킨다.** mmseg `point_head.py:357-366` 은 + + point_coords = torch.zeros(B, P, 2) + point_coords[:, :, 0] = w_step/2 + (idx %% width) * w_step + point_coords[:, :, 1] = h_step/2 + (idx // width) * h_step + + 로 좌표를 만드는데, 이 두 대입이 그래프에 안 실린다. 산술(`mul`·`sub`)은 실리는데 + **되쓰기만 빠진다** — 그래서 `zeros` 가 그대로 흘러가 **8196개 점이 전부 같은 + 자리를 샘플링**하고, 그 값을 서로 다른 위치에 되쓴다. + `unhandled op` 표시조차 안 남는다(`grep -c` 가 0 이다). + + 실측(mmseg point_rend, 512x1024): + 정제 없이 coarse 만 올린 것 vs torch 최종 = 2.44e-02 ← 통과선 안 + 우리 최종 vs torch 최종 = 8.24e-02 ← **정제가 값을 망친다** + 오차 분포도 산수가 맞는다 — 정제점 8196 x 업샘플 4 = 32,784 / 524,288 = 6.25%, + 실측 「>1.0 오차 6.27%」와 일치. + + ⚠️ swin 마스크(`_patch_swin_mask`)와 달리 **버퍼로 못 굽는다** — 좌표는 입력에 따라 + 변한다. 대신 **같은 수식을 `stack` 으로** 쓴다. 값이 동일하므로 torch 기준값 쪽이 + 이 패치를 지나도 결과가 같다. + """ + try: + from mmseg.models.decode_heads.point_head import PointHead + except Exception: + return # mmseg 가 없는 환경 — 조용히 넘어간다 + if getattr(PointHead, "_visp_coords_patched", False): + return + import torch + + def get_points_test(self, seg_logits, uncertainty_func, cfg): + num_points = cfg.subdivision_num_points + uncertainty_map = uncertainty_func(seg_logits) + batch_size, _, height, width = uncertainty_map.shape + # ⚠️ **파이썬 int 로 내린다.** trace 에서 `.shape` 은 동적 값으로 잡혀, + # `idx %% width` 의 나누는 수가 상수가 아니게 된다 → `remainder`·`floor_divide` + # 렌더러가 상수를 못 뽑아 `unhandled op` 으로 떨어진다. + # 입력 크기가 고정이라(계열별 crop) 이 둘은 실제로 상수다. + height, width = int(height), int(width) + h_step = 1.0 / height + w_step = 1.0 / width + uncertainty_map = uncertainty_map.view(batch_size, height * width) + num_points = min(height * width, num_points) + point_indices = uncertainty_map.topk(num_points, dim=1)[1] + # 원식과 같은 값 — 대입 대신 stack 으로 쌓는다. + # ⚠️ **정수 인덱스를 먼저 f32 로 올린 뒤 계산한다.** ggml 의 산술(`sub`·`mul`· + # `div`·`scale`·`concat`)은 정수 타입을 모르고 **abort** 한다. 정수인 채로 + # 두면 렌더러마다 하나씩 걸린다(실측: sub → scale → concat 순으로 세 번). + # 인덱스 최대가 height*width(=131072)라 f32(2^24 까지 정확)로 손실이 없다. + idxf = point_indices.float() + xs = w_step / 2.0 + (idxf % width) * w_step + ys = h_step / 2.0 + torch.floor(idxf / width) * h_step + point_coords = torch.stack([xs, ys], dim=2) + return point_indices, point_coords + + PointHead.get_points_test = get_points_test + PointHead._visp_coords_patched = True + + + +def _patch_san_attn_bias(): + """SAN 의 어텐션 바이어스를 **대입 대신 `cat`** 으로 조립한다. + + ⚠️ mmseg `san_head.py:401-409` 이 `new_zeros` 뒤 **대입 네 번**으로 마스크를 만든다: + + new_attn_bias = attn_bias.new_zeros(S+1+L, S+1+L) + new_attn_bias[:, :S] = -100 + new_attn_bias[arange(S), arange(S)] = 0 + new_attn_bias[:S, S] = -100 + new_attn_bias[..., :S, -L:] = attn_bias # ← 이것만 입력 의존 + + trace 가 이 대입들을 삼켜 마스크가 **전부 0** 이 된다. `zeros` 가 그대로 흘러가고 + `unhandled op 'index_put_inplace'` 하나만 남는다(`point_rend` 좌표와 같은 부류). + + 앞 셋은 **shape 만으로 정해지는 상수**이고 넷째만 입력에 의존한다. + 블록으로 갈라 `cat` 으로 쌓으면 값이 동일하고 trace 에 실린다. + + ┌─────────────┬────┬──────────┐ + │ -100·(1-I) │-100│ attn_bias│ S 행 + ├─────────────┼────┴──────────┤ + │ -100 │ 0 │ 1+L 행 + └─────────────┴───────────────┘ + """ + try: + from mmseg.models.decode_heads.san_head import RecWithAttnbias + except Exception: + return # mmseg/SAN 이 없는 환경 — 조용히 넘어간다 + if getattr(RecWithAttnbias, "_visp_bias_patched", False): + return + import torch + import torch.nn.functional as F + + def _build_attn_biases(self, attn_biases, target_shape): + formatted_attn_biases = [] + for attn_bias in attn_biases: + n, num_head, num_sos, h, w = attn_bias.shape + attn_bias = F.adaptive_max_pool2d( + attn_bias.reshape(n, num_head * num_sos, h, w), + output_size=target_shape) + attn_bias = attn_bias.reshape(n, num_head, num_sos, *target_shape) + true_num_head = self.num_heads + if num_head == 1: + attn_bias = attn_bias.repeat(1, true_num_head, 1, 1, 1) + attn_bias = attn_bias.reshape(n * true_num_head, num_sos, -1) + L = int(attn_bias.shape[-1]) + if self.cross_attn: + formatted_attn_biases.append(attn_bias) + else: + S = int(num_sos) + B = int(attn_bias.shape[0]) + dev, dt = attn_bias.device, attn_bias.dtype + eye = torch.eye(S, device=dev, dtype=dt) + tl = -100.0 * (1.0 - eye) # (S,S) 대각만 0 + tm = torch.full((S, 1), -100.0, device=dev, dtype=dt) + top_c = torch.cat([tl, tm], dim=-1).unsqueeze(0).expand(B, -1, -1) + top = torch.cat([top_c, attn_bias], dim=-1) # (B, S, S+1+L) + bl = torch.full((1 + L, S), -100.0, device=dev, dtype=dt) + br = torch.zeros(1 + L, 1 + L, device=dev, dtype=dt) + bot = torch.cat([bl, br], dim=-1).unsqueeze(0).expand(B, -1, -1) + formatted_attn_biases.append(torch.cat([top, bot], dim=-2)) + if len(formatted_attn_biases) == 1: + formatted_attn_biases = [ + formatted_attn_biases[0] for _ in range(self.num_layers) + ] + return formatted_attn_biases + + RecWithAttnbias._build_attn_biases = _build_attn_biases + RecWithAttnbias._visp_bias_patched = True + + # ── `bias_scaling` 은 `nn.Linear(1,1)` = **스칼라 affine** 이다 ──────────────── + # ⚠️ 호출부가 `bias_scaling(attn_bias[..., None]).squeeze(-1)`(san_head.py:91) 라 + # **크기-1 축을 만들어 matmul** 을 건다. 그 축은 `reduce_to_4d` 가 버리므로 + # `ggml_mul_mat` 이 `a->ne[0]=1` vs `b->ne[0]=32` 로 죽는다(`ggml.c:3203`). + # matmul 없이 곱셈+덧셈으로 내면 그 축 자체가 필요 없다 — 값은 동일하다. + try: + from mmseg.models.decode_heads.san_head import MLPMaskDecoder + except Exception: + return + if getattr(MLPMaskDecoder, "_visp_scale_patched", False): + return + import torch.nn as nn + + _orig_init = MLPMaskDecoder.__init__ + + def __init__(self, *a, **kw): + _orig_init(self, *a, **kw) + if isinstance(getattr(self, "bias_scaling", None), nn.Linear): + self.bias_scaling = _ScalarAffine(self.bias_scaling) + + MLPMaskDecoder.__init__ = __init__ + MLPMaskDecoder._visp_scale_patched = True + + def _patch_swin_mask(): """Swin 의 shifted-window 어텐션 마스크를 **버퍼로 미리 굽는다.** @@ -426,12 +605,18 @@ def install_loader_modules(out_path, *names): import os import shutil + import sys + 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") + # ⚠️ **이 폴더에만 있다고 보지 마라.** `mmseg_wrap`·`mmpose_wrap` 은 형제 폴더에 + # 있어서 `here` 로 찾으면 `FileNotFoundError` 가 난다(2026-09-01 실측). + # 이미 import 된 모듈이면 그 실제 위치를 쓴다. + mod = sys.modules.get(name) + src = getattr(mod, "__file__", None) or os.path.join(here, name + ".py") dst = os.path.join(dst_dir, name + ".py") if os.path.abspath(src) == os.path.abspath(dst): continue diff --git a/tools/frontend/mmdet/mmdet_to_pt.py b/tools/frontend/mmdet/mmdet_to_pt.py index 0198fe0..d9c2042 100755 --- a/tools/frontend/mmdet/mmdet_to_pt.py +++ b/tools/frontend/mmdet/mmdet_to_pt.py @@ -198,9 +198,12 @@ def main(argv=None): ap.add_argument("--checkpoint", default=None, help="가중치(.pth) — 없으면 config init") ap.add_argument("--out", required=True, help="출력 .pt") ap.add_argument("--size", type=int, default=512) + # 텍스트 조건부 계열(GLIP)만 쓴다. 캡션이 고정이면 텍스트 임베딩이 상수라 + # 버퍼로 구워 그래프 입력을 이미지 하나로 만든다 — `MMDetTextCond` 주석 참고. + ap.add_argument("--caption", default=None, help="텍스트 조건부 계열의 고정 문구") a = ap.parse_args(argv) - m, shapes, cfg = build(a.config, a.checkpoint, a.size) + m, shapes, cfg = build(a.config, a.checkpoint, a.size, caption=a.caption) print(f" backbone/neck features: {shapes}") torch.save(m, a.out) n_head = sum(1 for k in m.state_dict() if k.startswith("bbox_head")) diff --git a/tools/frontend/mmdet/mmdet_wrap.py b/tools/frontend/mmdet/mmdet_wrap.py index 4e1b796..a6f47d9 100755 --- a/tools/frontend/mmdet/mmdet_wrap.py +++ b/tools/frontend/mmdet/mmdet_wrap.py @@ -183,6 +183,69 @@ def forward(self, x): return tuple(f) # FPN features (레벨별 텐서) +# 검증용 기본 캡션. **아무 문장이나 되는 건 아니다** — 토큰 수가 임베딩 shape 을 정하고 +# 그게 GGUF 에 구워지므로, 실제로 쓸 문구와 비슷한 길이여야 의미 있는 측정이 된다. +DEFAULT_CAPTION = "person. bicycle. car. dog." + + +def text_embedding(det, caption): + """텍스트 가지를 한 번 돌려 임베딩을 얻는다. `(1, max_tokens, 768)`. + + ⚠️ transformers 5.x 에서 `batch_encode_plus` 가 **삭제**됐다. mmdet 3.3.0 은 4.x API 를 + 기대한다(`language_models/bert.py`) — 같은 뜻인 `__call__` 로 이어 준다. + """ + tok = det.language_model.tokenizer + if not hasattr(tok, "batch_encode_plus"): + tok.batch_encode_plus = tok.__call__ + with torch.no_grad(): + d = det.language_model([caption]) + return d["embedded"].contiguous() + + +class MMDetTextCond(nn.Module): + """텍스트 조건부 검출기(GLIP)를 **이미지 하나만 받는** 모듈로 감싼다. + + 캡션이 고정이면 텍스트 임베딩은 입력과 무관한 **상수**다 → 버퍼로 구우면 그래프 입력이 + 이미지 하나가 되어 단일입력 codegen·러너를 그대로 쓴다. DETR 의 `pos_embed`·`enc_ref`, + Deformable 의 `enc_proposals` 를 굽는 것과 같은 수법이다. + (그렇게 안 하면 융합헤드는 입력이 6개인데 codegen 이 **모든 input 노드를 같은 `x` 에 + 묶어** 크래시 없이 전부 이미지가 들어간다. 자세한 사유는 g2c `DECISIONS.md`.) + + ⚠️ 이 래퍼로 재는 것은 **이미지 가지 + 융합헤드**다. BERT 자체가 아니다 — + 구운 임베딩은 torch 가 계산한 값이다(BERT 는 2026-08-26 에 따로 쟀다). + + 출력은 15텐서: 레벨 5 × {정렬점수 (1,HW,256) · 박스 (1,4,H,W) · centerness (1,1,H,W)}. + """ + + def __init__(self, det, caption=DEFAULT_CAPTION): + super().__init__() + self.backbone = det.backbone + self.neck = det.neck + self.bbox_head = det.bbox_head + self.register_buffer("text_embedded", text_embedding(det, caption)) + self.caption = caption + # `_no_box` 경로는 g2c 가 그래프에서 모은 가중치만 쓴다 — 헤드가 트레이스에 들어오므로 + # `append_head_weights` 가 필요 없다. 선언은 남겨 둔다(다른 경로가 읽는다). + self.head_weight_prefixes = ("text_embedded",) + + def forward(self, x): + f = tuple(self.neck(self.backbone(x))) + cls_logits, bbox_preds, centerness = self.bbox_head( + f, {"embedded": self.text_embedded}) + return tuple(cls_logits) + tuple(bbox_preds) + tuple(centerness) + + +def is_text_conditional(det): + """이 검출기가 `MMDetTextCond` 로 감쌀 수 있는가. + + GLIP 만이다. GroundingDINO 계열은 head 가 DETR 꼴이라 `forward` 규약이 다르다 — + 같은 래퍼로 감싸면 인자 개수에서 죽는다. 여기서 **명시적으로** 갈라 둔다. + """ + if getattr(det, "language_model", None) is None: + return False + return type(getattr(det, "bbox_head", None)).__name__ == "ATSSVLFusionHead" + + def _tolist(v): try: return list(v) @@ -640,7 +703,7 @@ def _tc(key, default): } -def build(config, checkpoint=None, size=512): +def build(config, checkpoint=None, size=512, caption=None): """mmdet config(.py) → (MMDetBackbone(eval), feature shapes, postproc cfg).""" from mmdet.apis import init_detector # mmdet 만 import (g2c 무관) trace_friendly_ops() # trace 가 삼키는 커스텀 op 을 등가 수식으로 @@ -652,6 +715,17 @@ def build(config, checkpoint=None, size=512): det = init_detector(config, checkpoint, device="cpu") det.eval() _unwrap_checkpoint_forward(det) + # 텍스트 조건부(GLIP)는 **융합헤드까지** 한 그래프에 넣는다 — 캡션이 고정이면 텍스트 + # 임베딩이 상수라 입력이 이미지 하나로 줄어든다(`MMDetTextCond` 주석 참고). + # 그러면 `_no_box` 경로의 "그래프 출력 대조" 가 곧 **헤드 검증**이 된다. + if is_text_conditional(det): + m = MMDetTextCond(det, caption or DEFAULT_CAPTION) + m.eval() + cfg = postproc_cfg(det) + cfg["img_size"] = int(size) + with torch.no_grad(): + outs = m(torch.randn(1, 3, size, size)) + return m, [tuple(o.shape) for o in outs], cfg m = MMDetBackbone(det) m.eval() cfg = postproc_cfg(det) diff --git a/tools/frontend/mmpose/mmpose_wrap.py b/tools/frontend/mmpose/mmpose_wrap.py new file mode 100644 index 0000000..d3b19b4 --- /dev/null +++ b/tools/frontend/mmpose/mmpose_wrap.py @@ -0,0 +1,174 @@ +"""mmpose_wrap.py — mmpose 추정기를 '이미지 하나 → head 출력' nn.Module 로 감싸는 부품. + +**클래스 전용 import 모듈**(스크립트로 직접 실행 금지) — `mmseg_wrap`·`mmdet_wrap` 과 같은 규약. + +mmseg 와 같은 이유로 단순하다: 앵커도 NMS 도 없다. topdown 계열은 이미 잘린 사람 이미지를 +받아 **히트맵**(또는 SimCC 의 1D 벡터)을 내고, 디코드는 argmax + 소수점 보정뿐이라 그래프 +밖이다. `backbone → (neck) → head` 를 한 그래프로 컴파일하고 출력 텐서를 대조한다. +""" +import os +import sys + +import torch +import torch.nn as nn + +_FE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(_FE, "mmdet")) +import mmdet_compat # noqa: E402 +import mmdet_wrap # noqa: E402 + +trace_friendly_ops = mmdet_compat.patch_ops + + +def _stub_xtcocotools(): + """`xtcocotools` 를 `pycocotools` 로 잇는다 — **없으면 모델 로드조차 못 한다.** + + `mmpose.apis.init_model` 은 레지스트리를 채우려고 `mmpose.datasets` 를 통째로 import + 하는데, 그 안의 COCO 계열 데이터셋이 `from xtcocotools.coco import COCO` 를 한다. + xtcocotools 는 C 확장이라 이 환경에서 빌드가 실패한다(gcc exit 1). + + ⚠️ **우리는 데이터셋을 안 쓴다.** 재는 것은 `backbone → head` 의 텐서뿐이고, 이 심은 + import 를 통과시키는 용도다. 평가지표(AP 등)를 계산하려 들면 **이 심으로는 안 된다** — + xtcocotools 는 crowdpose/wholebody 용으로 확장된 것이라 API 가 더 넓다. + 그때는 진짜 xtcocotools 를 빌드해야 한다. + """ + if "xtcocotools.coco" in sys.modules or _has_module("xtcocotools"): + return + import types + import pycocotools.coco + import pycocotools.cocoeval + import pycocotools.mask + pkg = types.ModuleType("xtcocotools") + pkg.__path__ = [] + sys.modules["xtcocotools"] = pkg + for sub in ("coco", "cocoeval", "mask"): + sys.modules[f"xtcocotools.{sub}"] = getattr(pycocotools, sub) + setattr(pkg, sub, getattr(pycocotools, sub)) + + +def _mmpose_pkg_dir(): + import mmpose + return os.path.dirname(os.path.abspath(mmpose.__file__)) + + +class _chdir: + """`with` 로 cwd 를 잠깐 바꾼다 (py3.11 의 `contextlib.chdir` 과 같다).""" + + def __init__(self, path): + self.path, self.prev = path, None + + def __enter__(self): + self.prev = os.getcwd() + os.chdir(self.path) + return self + + def __exit__(self, *exc): + os.chdir(self.prev) + return False + + +def _has_module(name): + import importlib.util + try: + return importlib.util.find_spec(name) is not None + except Exception: + return False + + +# ⚠️ **import 시점에 건다.** `torch.load` 로 `MMPoseWrap` 를 되살리는 쪽(g2c 서브프로세스)은 +# 피클이 이 모듈을 먼저 import 하는데, 거기서 mmpose 가 딸려 들어온다. 함수 안에서만 +# 걸면 그 경로는 심을 못 보고 `ModuleNotFoundError: xtcocotools` 로 컴파일이 죽는다. +_stub_xtcocotools() + + +class MMPoseWrap(nn.Module): + """`backbone → (neck) → head.forward` 까지. 디코드(argmax·보정)는 그래프 밖. + + `head.forward` 는 mmpose 의 **순수 forward** 다(`base.py:_forward` 가 부르는 것과 같다). + `predict` 는 flip TTA·디코드가 붙어 있어 쓰지 않는다 — 그건 후처리다. + + 출력은 head 마다 다르다: + · `HeatmapHead` → (1, K, H/4, W/4) 텐서 하나 + · `SimCCHead` → (x, y) 1D 로짓 **두 개** (튜플로 그대로 흘린다) + """ + + def __init__(self, pose): + super().__init__() + self.backbone = pose.backbone + self.neck = pose.neck if getattr(pose, "with_neck", False) else None + self.head = pose.head + + def forward(self, x): + f = self.backbone(x) + if self.neck is not None: + f = self.neck(f) + out = self.head.forward(f) + # ⚠️ **항상 튜플이다.** 예전엔 출력이 하나면 벌거벗은 Tensor 를 돌려줬는데, + # `build()` 는 shape 을 늘 리스트로 알려줘서 **호출자가 두 겹 벗기는 사고**가 + # 났다 — `outs[0][0]` 이 19장짜리 클래스 맵에서 1장만 남겼다(2026-09-01 실측). + # 형태를 하나로 고정해 그 사고 자체를 없앤다. 러너는 `out_i` 로 순서대로 덤프한다. + return (out,) if isinstance(out, torch.Tensor) else tuple(out) + + +def build(config, checkpoint=None, size=(256, 192)): + """mmpose config(.py) → (MMPoseWrap(eval), 출력 shape 목록). + + ⚠️ **입력이 정방이 아니다.** topdown 은 사람 박스를 256x192(H x W)로 잘라 넣는다 — + 정방으로 넣으면 히트맵 크기가 config 와 안 맞아 head 가 죽거나 조용히 다른 것을 잰다. + """ + _stub_xtcocotools() + from mmpose.apis import init_model # mmpose 만 import (g2c 무관) + trace_friendly_ops() + mmdet_wrap.allow_mmengine_checkpoint_globals() + # ⚠️ **mmpose 의 dataset metainfo 경로는 cwd 기준이다.** config 가 + # `from_file='configs/_base_/datasets/aic.py'` 같은 **상대경로**를 들고 있고, + # `parse_pose_metainfo` 는 그걸 cwd 에서 찾는다. 폴백은 `mmpose/.mim/configs/` 인데 + # editable 설치에는 그 폴더가 없다 → `FileNotFoundError` 로 모델 로드가 죽는다. + # 레포 루트에서 부르고 되돌린다(산출물은 원래 cwd 에 쓰인다). + with _chdir(os.path.dirname(_mmpose_pkg_dir())): + pose = init_model(config, checkpoint, device="cpu") + pose.eval() + m = MMPoseWrap(pose) + m.eval() + h, w = size + with torch.no_grad(): + outs = m(torch.randn(1, 3, h, w)) + if isinstance(outs, torch.Tensor): + outs = (outs,) + return m, [tuple(o.shape) for o in outs] + + +def input_size(config): + """config 의 `codec.input_size` → (H, W). 없으면 topdown 관례값 (256, 192). + + codec 은 `(W, H)` 순서다 — **torch 와 반대다.** 뒤집어 쓰면 히트맵이 전치돼 나오는데 + 정사각이 아니면 shape 에서 죽고, 정사각이면 조용히 틀린다. + """ + _stub_xtcocotools() + from mmengine.config import Config + cfg = Config.fromfile(config) + codec = cfg.get("codec") + if isinstance(codec, (list, tuple)): + codec = codec[0] + sz = (codec or {}).get("input_size") if codec else None + if sz and len(sz) == 2: + return int(sz[1]), int(sz[0]) + return 256, 192 + + +def save(model, path): + """`.pt` 를 쓰고 **여는 데 필요한 모듈을 그 옆에 전부 복사한다.** + + `torch.save` 는 클래스를 `__module__` 이름으로 절이므로 여는 쪽이 그 이름을 import + 할 수 있어야 한다. g2c 는 `.pt` 가 있는 디렉터리를 `sys.path` 에 넣으므로, 모듈이 + 거기 **있기만 하면** `PYTHONPATH` 없이 열린다. + + ⚠️ **자기 파일 하나로는 부족하다.** 이 래퍼는 `mmdet_wrap`·`mmdet_compat` 을 import + 하므로 그것들도 같이 날라야 한다 — 안 그러면 컴파일이 + `ModuleNotFoundError: No module named 'mmdet_compat'` 에서 멈춘다(2026-09-01 실측). + mmdet 쪽 `install_loader_modules` 가 그 일을 이미 한다. + """ + import torch + + torch.save(model, path) + return mmdet_compat.install_loader_modules(path, "mmpose_wrap", "mmdet_wrap") diff --git a/tools/frontend/mmseg/mmseg_wrap.py b/tools/frontend/mmseg/mmseg_wrap.py new file mode 100644 index 0000000..0ad7ad4 --- /dev/null +++ b/tools/frontend/mmseg/mmseg_wrap.py @@ -0,0 +1,447 @@ +"""mmseg_wrap.py — mmseg 세그멘터를 '이미지 하나 → seg_logits' nn.Module 로 감싸는 부품. + +**클래스 전용 import 모듈**(스크립트로 직접 실행 금지). `torch.save` 는 클래스를 +`__module__` 로 피클하므로, CLI(`mmseg_to_pt.py`)와 로더가 **같은 이름으로 import** 해야 +동일 클래스로 복원된다(mmdet 쪽 `mmdet_wrap` 과 같은 규약). + +**mmdet 보다 훨씬 단순하다** — 앵커도 NMS 도 박스 디코드도 없다. head 를 C++ 로 조립할 +이유가 없어 `decode_head` 까지 통째로 g2c 로 컴파일한다(GLIP 융합헤드에서 통한 경로). +""" +import inspect +import os +import sys + +import torch +import torch.nn as nn + +# mmdet 프론트엔드의 trace 호환 패치를 그대로 쓴다 — mmcv custom op(CARAFE·DCN 등)은 +# 두 라이브러리가 같은 것을 쓴다. 없으면 그 op 들이 trace 에서 삼켜진다. +_FE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(_FE, "mmdet")) +import mmdet_compat # noqa: E402 +import mmdet_wrap # noqa: E402 + +trace_friendly_ops = mmdet_compat.patch_ops + + +class MMSegWrap(nn.Module): + """`backbone → (neck) → decode_head` 까지. 출력은 **resize 전** seg_logits. + + mmseg 는 `encode_decode` 에서 head 출력을 입력 크기로 bilinear resize 하는데, 그건 + **후처리**라 그래프 밖에 둔다 — 러너가 원하는 크기로 올린다. 그래프에 넣으면 입력 크기가 + 바뀔 때마다 다시 구워야 한다. + + `auxiliary_head` 는 학습 전용이라 뺀다(`predict` 경로가 안 부른다). + """ + + def __init__(self, seg): + super().__init__() + # ⚠️ **`backbone` 이 없는 세그멘터가 있다.** `MultimodalEncoderDecoder`(san)는 + # `image_encoder` + `text_encoder` 로 되어 있어 `seg.backbone` 을 무조건 집으면 + # `AttributeError: ... has no attribute 'backbone'` 로 죽는다. 「범위 밖」이 + # 아니라 **하네스가 못 세운 것**이었다(2026-08-31). + self.multimodal = (not hasattr(seg, "backbone") + and hasattr(seg, "image_encoder") + and hasattr(seg, "text_encoder")) + if self.multimodal: + self.backbone = None + self.neck = None + self.image_encoder = seg.image_encoder + self.text_encoder = seg.text_encoder + # `encode_decode` 가 이미지 인코더에만 줄인 입력을 넣는다(san 은 0.5배). + self.asymetric_input = getattr(seg, "asymetric_input", False) + self.encoder_resolution = getattr(seg, "encoder_resolution", 1.0) + else: + self.backbone = seg.backbone + self.neck = seg.neck if getattr(seg, "with_neck", False) else None + self.decode_head = seg.decode_head + self.cascade = isinstance(seg.decode_head, nn.ModuleList) + # ⚠️ **cascade 라고 다 `forward(x, prev)` 로 끝나지 않는다.** `PointHead`(point_rend)의 + # `forward` 는 `(fine_grained_point_feats, coarse_point_feats)` 라 특징 튜플을 + # 그대로 넘기면 `torch.cat` 이 `expected Tensor ... but got tuple` 로 죽는다. + # mmseg 자신도 `cascade_encoder_decoder.py:135` 에 "TODO support PointRend + # tensor mode" 라고 적어 두었다 — 추론 경로는 마지막 단계만 `predict` 다 + # (`encode_decode`: 0..n-2 는 forward, n-1 은 predict). + self.cascade_predict = ( + self.cascade + and "prev_output" in inspect.signature( + seg.decode_head[-1].predict).parameters + and "fine_grained_point_feats" in inspect.signature( + seg.decode_head[-1].forward).parameters + ) + self.test_cfg = getattr(seg, "test_cfg", None) + # ⚠️ **MaskFormer 계열의 head 는 `forward(x)` 가 아니다** — + # `forward(x, batch_data_samples)` 라 그냥 부르면 + # `forward() missing 1 required positional argument` 로 죽는다. + # 두 번째 인자는 이름과 달리 **입력 크기를 넘기는 통로**일 뿐이고 + # (mmseg `maskformer_head.py:151` 주석이 그렇게 말한다), 실제 추론 경로는 + # `predict(x, batch_img_metas, test_cfg)` 다. 그쪽으로 부른다. + self.needs_metas = ( + not self.cascade + and not self.multimodal + and "batch_data_samples" in inspect.signature( + seg.decode_head.forward).parameters + ) + + def forward(self, x): + # **계열 무관 프로브.** 값이 틀릴 때 백본/넥/헤드 중 어디서 벌어지는지 먼저 가른다 — + # 계열마다 프로브를 새로 짜지 않으려고 여기 둔다. 튜플을 그대로 돌려주면 + # 하네스가 `out_i` 로 각각 덤프하고 `ref.shapes.txt` 도 여러 줄이 된다. + _probe = os.environ.get("VISP_SEG_PROBE") + if self.multimodal: + return self._forward_multimodal(x, _probe) + f = self.backbone(x) + if _probe == "bb": + return f if isinstance(f, torch.Tensor) else tuple(f) + if self.neck is not None: + f = self.neck(f) + if _probe == "neck": + return f if isinstance(f, torch.Tensor) else tuple(f) + if self.cascade: + # ⚠️ **`CascadeEncoderDecoder` 의 head 는 `nn.ModuleList` 다.** 그냥 부르면 + # `Module [ModuleList] is missing the required forward` 로 죽는다 — + # 「범위 밖」처럼 보이지만 **하네스가 못 부른 것**이다(ocrnet·point_rend). + # 단계 규약은 mmseg `cascade_encoder_decoder.py:78` 과 같다: 0단계는 + # `forward(x)`, 이후는 `forward(x, prev)`. 마지막 단계의 `predict` 는 + # `forward(x, prev)` 뒤 **resize** 만 하는데 그건 그래프 밖이라 뺀다. + n = len(self.decode_head) + out = self.decode_head[0](f) + # `cascade_predict` 면 마지막 단계는 forward 가 아니라 predict 로 부른다. + # 그 외(ocrnet 등)는 예전 그대로 끝까지 forward 다. + for i in range(1, (n - 1) if self.cascade_predict else n): + out = self.decode_head[i](f, out) + if self.cascade_predict: + h, w = int(x.shape[-2]), int(x.shape[-1]) + metas = [{"img_shape": (h, w), "batch_input_shape": (h, w)}] + out = self.decode_head[n - 1].predict(f, out, metas, self.test_cfg) + elif self.needs_metas: + # `predict` 는 마스크를 **입력 크기로** 올린 뒤 클래스 점수와 곱해 + # seg_logits 를 만든다(`einsum('bqc,bqhw->bchw')`). 다른 계열의 + # 「resize 는 그래프 밖」 규약과 달리 이건 head 안의 계산이라 그래프에 둔다 — + # 여기서 빼면 남는 게 마스크 로짓이라 비교 대상이 달라진다. + h, w = int(x.shape[-2]), int(x.shape[-1]) + metas = [{"img_shape": (h, w), "batch_input_shape": (h, w)}] + probe = os.environ.get("VISP_SEG_PROBE") + if probe == "cs": + # cumsum 그 자체만. 여기서 틀리면 렌더러, 맞으면 PE 의 뒷단 + # (stride-2 슬라이스·stack·view)이 범인이다. + feat = f[-1] + mask = feat.new_zeros( + (int(feat.shape[0]), int(feat.shape[2]), int(feat.shape[3]))) + not_mask = 1 - mask + # ⚠️ 배치축(dim=0)에 붙이면 안 된다 — 하네스가 `t[0]` 로 배치를 + # 벗기므로 절반만 비교된다. 채널축으로 붙인다. + out = (torch.stack([not_mask.cumsum(1), not_mask.cumsum(2)], dim=1) + + feat[:, 0:1, :, :] * 0) + elif probe in ("pe", "pe_n", "pe_d"): + # 위치인코딩만. `cumsum` 이 여기 있고 MHA(baddbmm)는 없다 — + # 둘 중 누구인지 가르는 자리. + # `pe_n`·`pe_d` 는 그 안을 다시 셋으로 가른다. `pe` 는 전체다. + # pe_n → cumsum + normalize(⚠️ **음수 인덱스 슬라이스** `[:, -1:, :]`) + # pe_d → 거기에 dim_t 브로드캐스트 나눗셈까지 + # pe → 거기에 stride-2 슬라이스 + stack(dim=4) + view 까지 + feat = f[-1] + mask = feat.new_zeros( + (int(feat.shape[0]), int(feat.shape[2]), int(feat.shape[3]))) + zero = feat[:, :1] * 0 + if probe == "pe": + out = self.decode_head.decoder_pe(mask) + zero + else: + out = _pe_stage(self.decode_head.decoder_pe, mask, probe) + zero + elif probe in ("pix", "pix_ms"): + # ⚠️ **head 안을 픽셀 디코더와 트랜스포머 디코더로 가른다.** + # `pixel_decoder`(MSDeformAttn) 는 head 의 앞단이다. 여기서 틀리면 + # deform-attn 이 범인이고, 여기가 맞으면 뒤의 디코더 층이다. + mask_features, multi_scale_memorys = self.decode_head.pixel_decoder(f) + out = (mask_features if probe == "pix" + else _flatten_tensors(multi_scale_memorys)) + elif probe and ((probe.startswith("am") and probe[2:].isdigit()) or ( + probe.startswith("ami") and probe[3:].isdigit())): + # `_forward_head` 가 만드는 **attn_mask** 자체를 그래프 출력으로 뺀다. + # 계단 함수(`sigmoid()<0.5`)라 여기가 한 비트만 틀려도 뒤가 크게 벌어진다 — + # 「자를 의심하기 전에 자가 맞는지 재라」에 해당하는 자리. + head = self.decode_head + want = int(probe[3:] if probe.startswith("ami") else probe[2:]) + grabbed = {} + orig = head._forward_head + + import torch.nn.functional as _F + logits = probe.startswith("ami") + + def _spy(decoder_out, mask_feature, attn_mask_target_size): + cls_pred, mask_pred, attn_mask = orig( + decoder_out, mask_feature, attn_mask_target_size) + # `ami` 는 **문턱 전 로짓**을 잡는다. 여기가 통과선 안이면 + # 마스크 비트 차이는 계단 함수의 민감도지 별개의 버그가 아니다. + grabbed.setdefault( + len(grabbed), + _F.interpolate(mask_pred, attn_mask_target_size, + mode="bilinear", align_corners=False) + if logits else attn_mask) + return cls_pred, mask_pred, attn_mask + + head._forward_head = _spy + try: + from mmseg.structures import SegDataSample + head(f, [SegDataSample(metainfo=metas[0])]) + finally: + head._forward_head = orig + am = grabbed[want] + # bool → float. 하네스는 f32 만 비교한다. + out = am.to(torch.float32).unsqueeze(0) + elif probe and probe.startswith("mask") and probe[4:].isdigit(): + # 디코더 **층별** 마스크 예측. `all_mask_preds[0]` 은 층을 하나도 안 거친 + # 예측이다 — 거기서 맞으면 범인은 디코더 층(마스크드 어텐션)이다. + from mmseg.structures import SegDataSample + _, all_mask_preds = self.decode_head( + f, [SegDataSample(metainfo=metas[0])]) + out = all_mask_preds[int(probe[4:])] + elif probe == "mask": + # 값이 어디서 벌어지는지 가르는 자리 — 디코더까지만 내고 + # `predict` 의 interpolate·softmax·einsum 은 뺀다. + from mmseg.structures import SegDataSample + _, all_mask_preds = self.decode_head( + f, [SegDataSample(metainfo=metas[0])]) + out = all_mask_preds[-1] + else: + out = self.decode_head.predict(f, metas, None) + else: + out = self.decode_head(f) + # ⚠️ **항상 튜플이다.** 예전엔 출력이 하나면 벌거벗은 Tensor 를 돌려줬는데, + # `build()` 는 shape 을 늘 리스트로 알려줘서 **호출자가 두 겹 벗기는 사고**가 + # 났다 — `outs[0][0]` 이 19장짜리 클래스 맵에서 1장만 남겼다(2026-09-01 실측). + # 형태를 하나로 고정해 그 사고 자체를 없앤다. 러너는 `out_i` 로 순서대로 덤프한다. + return (out,) if isinstance(out, torch.Tensor) else tuple(out) + + def _forward_multimodal(self, x, probe=None): + """`MultimodalEncoderDecoder`(san) 전용 경로. + + mmseg `multimodal_encoder_decoder.py:121` 의 `encode_decode` 와 **같은 순서**다: + (필요하면 입력을 줄여) `image_encoder` → `text_encoder()` → + `decode_head.predict([원본입력, 시각특징, 클래스임베딩], metas, test_cfg)`. + 다르게 쓰면 재는 대상이 달라지므로 순서를 바꾸지 않는다. + + `text_encoder()` 는 입력을 안 받는다 — 클래스 이름 임베딩이라 이미지와 무관하다. + `predict` 안에서 마스크를 입력 크기로 올려 클래스 점수와 곱하므로 + (`einsum('bqc,bqhw->bchw')`) 그 resize 는 head 안의 계산이고 그래프에 남긴다 + — maskformer 계열(`needs_metas`)과 같은 사정이다. + """ + import torch.nn.functional as F + clip_x = x + if self.asymetric_input: + clip_x = F.interpolate(x, scale_factor=self.encoder_resolution, + mode="bilinear") + feats = self.image_encoder(clip_x) + if probe == "bb": + # ⚠️ **san 의 image_encoder 는 리스트 안에 리스트를 낸다.** 그냥 `tuple(feats)` + # 로 내면 하네스가 `.shape` 을 찾다 죽는다(REF_FAIL 로 보여 「프로브가 + # 안 되는 계열」처럼 읽힌다 — 실제로는 평탄화만 하면 된다). + return _flatten_tensors(feats) + cls_embeds = self.text_encoder() + h, w = int(x.shape[-2]), int(x.shape[-1]) + metas = [{"img_shape": (h, w), "batch_input_shape": (h, w)}] + if probe in ("san_pe", "san_fuse"): + # `encode_feature` 의 **앞머리만** 재현한다(mmseg san_head.py:114-137 과 같은 식). + # san_pe : patch_embed + pos_embed(필요하면 bicubic resize) + query 결합까지 + # san_fuse : 거기에 첫 CLIP 융합(fuse_clip)까지 + # 블록을 돌기 전에 이미 틀렸는지부터 가른다. + from mmseg.models.utils import resize as _resize + san = self.decode_head.side_adapter_network + xx, hwshape = san.patch_embed(x) + ori_h, ori_w = san.patch_embed.init_out_size + pos_embed = san.pos_embed + if san.pos_embed.shape[1] != xx.shape[1]: + pos_embed = _resize( + san.pos_embed.reshape(1, ori_h, ori_w, -1).permute(0, 3, 1, 2), + size=hwshape, mode="bicubic", align_corners=False, + ).flatten(2).permute(0, 2, 1) + pos_embed = torch.cat( + [san.query_pos_embed.expand(pos_embed.shape[0], -1, -1), pos_embed], dim=1) + xx = torch.cat([san.query_embed.expand(xx.shape[0], -1, -1), xx], dim=1) + xx = xx + pos_embed + if probe == "san_pe": + return xx + L = hwshape[0] * hwshape[1] + if san.fusion_index[0] == 0: + xx = san.fuse_clip(0, xx, feats[0][0], hwshape, L) + return xx + if probe == "san_enc": + # SAN 안을 다시 가른다 — `encode_feature`(경량 ViT + CLIP 융합)까지만 잰다. + # 여기서 맞으면 범인은 `decode_feature`(MLPMaskDecoder)다. + san = self.decode_head.side_adapter_network + return _flatten_tensors(san.encode_feature(x, feats, [])) + if probe in ("san_mask", "san_cls", "san_head"): + # `predict` 는 `forward` → `predict_by_feat`(업샘플·softmax·einsum) 이다. + # 그 둘을 갈라야 어느 쪽이 틀렸는지 짚인다 — `predict` 전체만 재면 + # 마스크 오류와 클래스 오류가 한 숫자로 섞인다. + mask_props, mask_logits = self.decode_head.forward([x, feats, cls_embeds], []) + if probe == "san_mask": + return _flatten_tensors(mask_props[-1]) + if probe == "san_cls": + return _flatten_tensors(mask_logits[-1]) + return _flatten_tensors((mask_props[-1], mask_logits[-1])) + out = self.decode_head.predict([x, feats, cls_embeds], metas, self.test_cfg) + return out if isinstance(out, torch.Tensor) else tuple(out) + + + +def _flatten_tensors(obj): + """중첩 list/tuple 을 텐서 튜플로 편다(프로브 전용). 텐서 하나면 그대로 돌려준다.""" + if isinstance(obj, torch.Tensor): + return obj + out = [] + + def _walk(o): + if isinstance(o, torch.Tensor): + out.append(o) + elif isinstance(o, (list, tuple)): + for e in o: + _walk(e) + elif isinstance(o, dict): + # san 의 `encode_feature` 는 `{'query':…, 'x':…}` 리스트를 낸다. + # 안 걸으면 빈 리스트가 돼 `out[0]` 이 IndexError 로 죽는다. + for k in sorted(o): + _walk(o[k]) + + _walk(obj) + return out[0] if len(out) == 1 else tuple(out) + + +def _pe_stage(pe, mask, stage): + """`SinePositionalEncoding.forward` 를 단계별로 끊어 낸다(프로브 전용). + + 본체(mmdet `positional_encoding.py`)와 **같은 식**을 그대로 옮긴 것이다 — + 다르게 쓰면 재는 대상이 달라진다. 출력은 항상 `[B, C, H, W]` 로 맞춘다: + 하네스가 `t[0]` 로 배치를 벗기므로 채널축에 실어야 전부 비교된다. + """ + B, H, W = mask.size() + mask = mask.to(torch.int) + not_mask = 1 - mask + y_embed = not_mask.cumsum(1, dtype=torch.float32) + x_embed = not_mask.cumsum(2, dtype=torch.float32) + if pe.normalize: + # ⚠️ 음수 인덱스 슬라이스다. 마지막 행/열을 집는 이 두 줄이 g2c 에서 + # 어떻게 나가는지가 미확인이었다. + y_embed = (y_embed + pe.offset) / (y_embed[:, -1:, :] + pe.eps) * pe.scale + x_embed = (x_embed + pe.offset) / (x_embed[:, :, -1:] + pe.eps) * pe.scale + if stage == "pe_n": + return torch.stack([y_embed, x_embed], dim=1) + dim_t = torch.arange(pe.num_feats, dtype=torch.float32) + dim_t = pe.temperature ** (2 * (dim_t // 2) / pe.num_feats) + pos_x = x_embed[:, :, :, None] / dim_t + pos_y = y_embed[:, :, :, None] / dim_t + if stage == "pe_d": + return torch.cat((pos_y, pos_x), dim=3).permute(0, 3, 1, 2) + raise ValueError(stage) + +def crop_size(config): + """config 의 `data_preprocessor.size` → (H, W). 없으면 (512, 512). + + ⚠️ **계열마다 다르다** — cityscapes 는 512x1024, ade20k 는 512x512, beit 는 640x640, + cgnet 은 680x680, bisenetv2 는 1024x1024. 하나로 고정하면 **잰 것이 그 모델이 아니다.** + ViT 계열은 조용히 넘어가지도 않는다: `pos_embed` 토큰 수가 입력에 묶여 있어 + `The size of tensor a (1025) must match ...` 로 죽는다(512/16 → 1024+1 vs 640/16 → 1600+1). + `data_preprocessor.size` 는 학습·평가 crop 이라 그 모델이 실제로 보는 크기다. + """ + from mmengine.config import Config + cfg = Config.fromfile(config) + sz = (cfg.get("model") or {}).get("data_preprocessor", {}).get("size") + if sz and len(sz) == 2: + return int(sz[0]), int(sz[1]) + return 512, 512 + + + +def _pin_nmf_random_bases(): + """SegNeXt(`LightHamHead`)의 NMF2D 를 **결정적**으로 만든다. + + `NMF2D._build_bases` 는 추론마다 `torch.rand` 로 기저를 새로 뽑는다(`rand_init=True` 가 + config 의 실제 값이다). 그래서 **torch 두 번이 서로 어긋난다** — 실측 rel L1 1.09e-02 + (2026-08-28) · 1.275e-02 (2026-08-31). 기준값 자체가 흔들리면 컴파일러를 못 잰다. + + 게다가 `aten::rand` 는 ggml 로 낼 수 없다 — 렌더러가 없어 passthrough 로 떨어지고 + 「낼 수 없는 op 1개」로 컴파일이 멈춘다. + + 고정 시드로 한 번 뽑아 **모듈 버퍼로 등록**한다. 그러면 + · 기준값(ref.py)과 컴파일(trace)이 **같은 값**을 쓴다 — 같은 프로세스에서 만든 + `seg.pt` 한 벌을 양쪽이 읽기 때문이다 + · 그래프에서 난수가 사라지고 GGUF 가중치가 된다 + `build()` 의 워밍업 forward 가 `torch.save` 보다 앞서므로 저장 시점에 버퍼가 잡혀 있다. + + ⚠️ **이건 원 모델과 다른 구성이다.** 원 모델은 매 추론 기저를 다시 뽑는다. 여기서 + 재는 것은 「고정 기저에서 컴파일러가 맞게 번역하는가」이지 「난수 초기화까지 같은가」가 + 아니다. 수치를 적을 때 이 조건을 같이 적어라. + """ + try: + from mmseg.models.decode_heads.ham_head import NMF2D + import torch.nn.functional as _F + except Exception: + return + if getattr(NMF2D, "_visp_pinned", False): + return + + def _build_bases(self, B, S, D, R, device=None): + # ⚠️ **버퍼 이름을 shape 으로 만들지 마라.** trace 중에는 `D = C // S` 가 정수가 + # 아니라 그래프 텐서로 와서 이름이 매번 달라지고, 버퍼가 새로 등록돼 + # `torch.jit.trace` 가 「state_dict changed after running the tracer」로 죽는다. + # 한 모델 안에서 shape 은 고정이므로 이름 하나면 된다. + buf = getattr(self, "visp_bases", None) + if buf is None: + n = int(B) * int(S) + g = torch.Generator().manual_seed(0) + bases = _F.normalize(torch.rand((n, int(D), int(R)), generator=g), dim=1) + # persistent=True — state_dict 에 남아야 GGUF 로 나간다. + self.register_buffer("visp_bases", bases, persistent=True) + buf = getattr(self, "visp_bases") + return buf + + NMF2D._build_bases = _build_bases + NMF2D._visp_pinned = True + + +# ⚠️ **모듈 import 시점에 건다.** `build()` 안에서만 걸면 기준값 프로세스에만 먹고 +# **컴파일 프로세스에는 안 먹는다** — 거기서는 `seg.pt` 를 언피클할 뿐 `build()` 를 +# 부르지 않기 때문이다(언피클이 이 모듈을 import 하므로 여기 두면 양쪽 다 걸린다). +# 실측: build() 안에만 뒀을 때 생성물에 `aten::rand` 가 그대로 남았다. +_pin_nmf_random_bases() + + +def build(config, checkpoint=None, size=512): + """mmseg config(.py) → (MMSegWrap(eval), 출력 shape 목록). + + `size` 는 int(정방) 또는 (H, W) 다. + """ + from mmseg.apis import init_model # mmseg 만 import (g2c 무관) + trace_friendly_ops() + _pin_nmf_random_bases() + # PyTorch 2.6 부터 `torch.load` 의 `weights_only` 기본값이 True 라 mmengine 이 넣은 + # 학습 메타에 걸린다. mmdet 쪽과 같은 사정이므로 그 구현을 재사용한다. + mmdet_wrap.allow_mmengine_checkpoint_globals() + seg = init_model(config, checkpoint, device="cpu") + seg.eval() + m = MMSegWrap(seg) + m.eval() + h, w = (size, size) if isinstance(size, int) else size + with torch.no_grad(): + outs = m(torch.randn(1, 3, h, w)) + if isinstance(outs, torch.Tensor): + outs = (outs,) + return m, [tuple(o.shape) for o in outs] + + +def save(model, path): + """`.pt` 를 쓰고 **여는 데 필요한 모듈을 그 옆에 전부 복사한다.** + + `torch.save` 는 클래스를 `__module__` 이름으로 절이므로 여는 쪽이 그 이름을 import + 할 수 있어야 한다. g2c 는 `.pt` 가 있는 디렉터리를 `sys.path` 에 넣으므로, 모듈이 + 거기 **있기만 하면** `PYTHONPATH` 없이 열린다. + + ⚠️ **자기 파일 하나로는 부족하다.** 이 래퍼는 `mmdet_wrap`·`mmdet_compat` 을 import + 하므로 그것들도 같이 날라야 한다 — 안 그러면 컴파일이 + `ModuleNotFoundError: No module named 'mmdet_compat'` 에서 멈춘다(2026-09-01 실측). + mmdet 쪽 `install_loader_modules` 가 그 일을 이미 한다. + """ + import torch + + torch.save(model, path) + return mmdet_compat.install_loader_modules(path, "mmseg_wrap", "mmdet_wrap") diff --git a/tools/install_arch.py b/tools/install_arch.py index e45a528..f17a2c4 100644 --- a/tools/install_arch.py +++ b/tools/install_arch.py @@ -96,18 +96,31 @@ def gguf_arch(out_dir, cls): def detect_shapes(out_dir, cls): - """생성 .cpp 의 출력 등록에서 (클래스 수, 앵커 수) 를 추정한다. + r"""생성 `.py` 에서 탐지 헤드의 클래스 수를 읽는다. 못 읽으면 80. - `compute_graph_output(..., "out_i")` 앞의 텐서 shape 를 직접 읽을 수는 없으므로, - **weights_manifest 의 마지막 conv 출력 채널**로 클래스 수를 잡는다. 실패하면 80. + ⚠️ **예전엔 `.weights.txt` 에서 `cv3.*\[(\d+),` 를 찾았는데 그 파일에는 + shape 대괄호가 없다** — 순수 텐서 이름만 있다. 정규식이 **한 번도 안 맞아서** + 언제나 80 으로 조용히 떨어졌다(2026-09-01 실측). COCO 모델은 우연히 맞았고 + 커스텀 클래스 수 모델은 조용히 틀렸다. + + 생성 `.py` 에는 `out_channels=` 가 주석의 모듈 경로와 함께 남는다. 탐지 헤드 + (`Detect[...]`) 안의 마지막 `cv3` conv 출력 채널이 클래스 수다. """ - 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)) + py = os.path.join(out_dir, cls + ".py") + if not os.path.exists(py): + print(f" ⚠ {cls}.py 가 없다 — 클래스 수를 80 으로 둔다. 다르면 --classes 로 줘라") + return 80 + nc = None + for line in open(py, encoding="utf-8"): + if "CONV2D" not in line or "Detect[" not in line or "cv3" not in line: + continue + m = re.search(r"out_channels=(\d+)", line) + if m: + nc = int(m.group(1)) + if nc is None: + print(f" ⚠ {cls}.py 에서 탐지 헤드를 못 찾았다 — 80 으로 둔다. " + f"다르면 --classes 로 줘라") + return 80 return nc @@ -122,14 +135,14 @@ def traced_size(out_dir, cls, fallback): try: src = open(path, encoding="utf-8").read() except OSError: - return fallback + return fallback, 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 + return fallback, fallback h, w = int(m.group(1)), int(m.group(2)) if h != w: - print(f" ⚠ 입력이 정사각이 아니다({h}x{w}) — {h} 로 등록한다. 필요하면 --size 로 덮어써라") - return h + print(f" · 입력이 정사각이 아니다 — {h}x{w} 를 그대로 등록한다") + return h, w def main(): @@ -161,7 +174,10 @@ def main(): shutil.copy(cpp, os.path.join(ARCH_DIR, cls + ".cpp")) shutil.copy(hdr, os.path.join(ARCH_DIR, header)) - size = a.size or traced_size(a.out_dir, cls, 640) + ih, iw = traced_size(a.out_dir, cls, 640) + if a.size: + ih = iw = a.size + size = ih # 옛 필드 호환 — 정사각이면 이 값 하나로 충분하다 def _triple(spec, what): v = [x.strip() for x in spec.split(",") if x.strip()] @@ -183,6 +199,11 @@ def _triple(spec, what): f" t.nms_free = {'false' if a.nms else 'true'};", f" t.score_thr = {a.score_thr}f;", f" t.input_size = {size};", + f" t.input_w = {iw};", + f" t.input_h = {ih};", + # YOLO 는 letterbox 를 전제한다. 직접 리사이즈로 넣으면 비정사각 이미지에서 + # 종횡비가 뭉개져 점수가 흔들린다(2026-09-02 실측). + " t.letterbox = true;", ] + norm if names: body.append(" t.class_names = {") @@ -192,7 +213,9 @@ def _triple(spec, what): else: # 검출기가 아니어도 크기는 실어야 한다 — 러너가 그 크기로 입력을 만든다. body = [" t.kind = visp::arch_kind::raw;", - f" t.input_size = {size};"] + norm + f" t.input_size = {size};", + f" t.input_w = {iw};", + f" t.input_h = {ih};"] + norm reg_path = os.path.join(ARCH_DIR, arch + "_register.cpp") with open(reg_path, "w", encoding="utf-8") as f: diff --git a/tools/verify/draw_boxes.py b/tools/verify/common/draw_boxes.py similarity index 97% rename from tools/verify/draw_boxes.py rename to tools/verify/common/draw_boxes.py index 0bf53a6..f3079ed 100644 --- a/tools/verify/draw_boxes.py +++ b/tools/verify/common/draw_boxes.py @@ -5,7 +5,7 @@ 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 + python tools/verify/common/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 diff --git a/tools/verify/backbone/run_dump.cpp b/tools/verify/common/run_dump.cpp similarity index 79% rename from tools/verify/backbone/run_dump.cpp rename to tools/verify/common/run_dump.cpp index f4a9a07..4454e5a 100755 --- a/tools/verify/backbone/run_dump.cpp +++ b/tools/verify/common/run_dump.cpp @@ -28,13 +28,18 @@ static std::vector load_bin(const char* path, size_t n) { int main(int argc, char** argv) { if (argc < 4) { - fprintf(stderr, "usage: %s [size=512]\n", argv[0]); + fprintf(stderr, + "usage: %s [W=512] [H=W]\n", argv[0]); return 1; } const char* gguf = argv[1]; const char* inb = argv[2]; std::string pref = argv[3]; - const int SZ = argc > 4 ? atoi(argv[4]) : 512; + // ⚠️ **정방이 아닌 입력이 있다.** mmpose topdown 은 사람 박스를 256x192(HxW)로 잘라 + // 넣는다. 크기를 하나만 받으면 정방으로 굳어 히트맵 크기가 config 와 어긋난다. + // `H` 를 안 주면 예전처럼 정방이다 — 기존 호출자는 그대로다. + const int W = argc > 4 ? atoi(argv[4]) : 512; + const int H = argc > 5 ? atoi(argv[5]) : W; backend_device backend = backend_init(); model_file file = model_load(gguf); @@ -45,13 +50,13 @@ int main(int argc, char** argv) { model_ref m(weights, graph); auto p = DETECT_PARAMS(file); - tensor input = compute_graph_input(m, GGML_TYPE_F32, {3, SZ, SZ, 1}, "x"); + tensor input = compute_graph_input(m, GGML_TYPE_F32, {3, W, H, 1}, "x"); // cwhn ggml_build_forward_expand(graph, input); tensor last = FWD(m, input, p); // 내부에서 compute_graph_output 이 out_i 를 그래프에 등록 ggml_build_forward_expand(graph, last); compute_graph_allocate(graph, backend); - auto in = load_bin(inb, (size_t)3 * SZ * SZ); + auto in = load_bin(inb, (size_t)3 * W * H); transfer_to_backend(input, std::span(in.data(), in.size())); compute(graph, backend); diff --git a/tools/verify/backbone/run_frcnn.cpp b/tools/verify/mmdet/backbone/run_frcnn.cpp similarity index 89% rename from tools/verify/backbone/run_frcnn.cpp rename to tools/verify/mmdet/backbone/run_frcnn.cpp index e2b1506..d6bf555 100644 --- a/tools/verify/backbone/run_frcnn.cpp +++ b/tools/verify/mmdet/backbone/run_frcnn.cpp @@ -723,6 +723,46 @@ int main(int argc, char** argv) { // 박스는 마지막 단계가 이미 정제해 `rois` 에 들어 있다(단계마다 갱신된다). // NMS 경로로 흘리면 임계값이 0 이라 전부 걸러져 **0건**이 나온다(실측). if (SPARSE > 0) { + // ⚠️ **마지막 단계의 박스 정제가 빠져 있었다.** 위 정제 루프는 `st + 1 < NS` 라 + // 마지막 단계를 건너뛴다 — 캐스케이드는 그 단계의 delta 를 `detect_roi` 가 + // 디코드하므로 맞지만, SparseR-CNN 은 NMS 경로를 안 타서 **아무도 디코드하지 + // 않는다.** 그래서 박스만 한 단계 뒤처졌다. 점수는 마지막 단계 cls 라 정확해서 + // "라벨·개수·점수는 맞는데 박스만 몇 px 밀린" 모습으로 나온다 — 실측 4.02px, + // C++ 박스가 마지막 단계 **입력** 박스와 0.000px 로 같았다. + { + const bool agn2 = J.num("class_agnostic", 0.0f) != 0.0f; + if (!agn2) { + fprintf(stderr, "[sparse] class_agnostic 이 아니다 — 마지막 정제를 건너뛴다\n"); + } else { + const int lb = NS - 1; + const bool hs = st_stds.size() >= (size_t)(lb + 1) * 4; + const bool hm = st_means.size() >= (size_t)(lb + 1) * 4; + const float MAXR = 4.13516655f; // |log(16/1000)| — 루프와 같다 + std::vector nb((size_t)M * 4); + for (int i = 0; i < M_real; ++i) { + float d[4]; + for (int k = 0; k < 4; ++k) { + d[k] = box_st[lb][(size_t)i * 4 + k]; + if (hs) d[k] *= st_stds[(size_t)lb * 4 + k]; + if (hm) d[k] += st_means[(size_t)lb * 4 + k]; + } + 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]); + 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); + } + } 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; }; @@ -743,12 +783,19 @@ int main(int argc, char** argv) { } } -#if defined(ARCH_C) && defined(ARCH_D) - // ── 패스 2: 마스크 IoU 로 점수를 다시 매긴다 (Mask Scoring R-CNN) ─────── +#ifdef ARCH_C + // ── 패스 2: 마스크 head 를 돌린다 ─────────────────────────────────────── + // 두 가지를 겸한다. + // ⓐ **마스크 로짓을 낸다**(`.mlogit.bin`) — 마스크 축 대조용. 모든 마스크 계열. + // ⓑ Mask Scoring R-CNN 이면 그 로짓으로 **점수를 다시 매긴다**(SubD 가 있을 때만). // mmdet 은 `score * mask_iou[label]` 로 점수를 낮춘다(maskiou_head.predict_by_feat). - // 이걸 빼면 **박스는 맞는데 점수만 틀린다** — 실측 박스 0.07px / 점수 0.163. + // ⓑ 를 빼면 **박스는 맞는데 점수만 틀린다** — 실측 박스 0.07px / 점수 0.163. // 게다가 점수가 임계값을 넘나들어 개수까지 달라진다(mmdet 4건 vs C++ 6건). // + // ⚠️ **ⓐ 가 실패해도 박스 판정이 바뀌면 안 된다.** 마스크는 더 재는 축이지 문턱이 + // 아니다 — 여기서 죽으면 이미 맞은 박스까지 못 쓰게 된다. 그래서 SubC 가 없으면 + // (`gc` 가 비었으면) 통째로 건너뛴다. + // // ⚠️⚠️ **두 그래프를 검출 하나씩(배치 1) 돌린다. 묶어 넣으면 안 된다.** // `ggml_compute_forward_conv_transpose_2d` 는 **배치 축을 안 돈다** — src1 을 // 풀 때 i12(채널)·i11(행)만 돌고 i13(배치)이 없고, 출력도 `dst->data + i2*nb2` @@ -756,7 +803,8 @@ int main(int argc, char** argv) { // 계산되고 나머지 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 bool want_miou = J.num("has_mask_iou", 0.0f) != 0.0f && gd && *gd; + if (J.num("has_mask", 0.0f) != 0.0f && gc && *gc && !dets.empty()) { const int MD = (int)dets.size(); // ⚠️ **박스를 원본 해상도로 되돌리지 않는다.** mmdet 은 마스크 분기가 있으면 // bbox 쪽 rescale 을 끈다(`base_roi_head.py`: @@ -779,15 +827,36 @@ int main(int argc, char** argv) { 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); + // ⚠️ **마스크 추출기도 레벨을 고를 수도, 안 고를 수도 있다.** `GenericRoIExtractor` + // (`groie`)면 전 레벨에 RoIAlign 을 걸어 pre→합산→post 를 SubC 안에서 태운다 — + // bbox 쪽과 같은 수법이다(위 `groie_levels` 참고). 이걸 빼고 레벨 하나만 넣으면 + // **크래시 없이 로짓 크기가 통째로 작아진다**(실측 |x| 0.45 vs 2.00, rel L1 1.00). + const int mgroie_lv = (int)J.num("mask_groie_levels", 0.0f); + const int MLV = mgroie_lv > 0 ? mgroie_lv : 1; // SubC 한 번에 넣는 배치 크기 + std::vector mfeat; + if (mgroie_lv > 0) { + // 레벨별 (MD,C,MO,MO) 를 이어붙인다 — 러너가 행 하나씩 꺼내 쓴다. + for (int l = 0; l < mgroie_lv; ++l) { + roi_align_params lp = mp; + lp.force_level = l; + std::vector one = roi_align(mfeats, mhw, mbox.data(), MD, lp); + mfeat.insert(mfeat.end(), one.begin(), one.end()); + } + } else { + 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()); +#ifdef ARCH_D + // SubD 는 Mask Scoring R-CNN 에만 컴파일된다 — 하네스가 SubD 를 넘길 때만 + // `ARCH_D` 를 세우므로 여기 오면 파일이 있다. 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()); +#endif std::vector logit_all, din_all, miou(MD, 1.0f); int MH = 0, MW = 0, NCLS_M = 0; @@ -798,17 +867,23 @@ int main(int argc, char** argv) { { 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"); + tensor min_ = compute_graph_input(mc, GGML_TYPE_F32, {C, MO, MO, MLV}, "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]; + // groie 면 레벨 L 개를 **배치로** 쌓아 넣는다 — 레벨 l 의 행 n 은 + // `mfeat` 안에서 `(l * MD + n)` 번째다(레벨별로 통째로 이어붙였다). + std::vector cw((size_t)MLV * C * MO * MO); + for (int l = 0; l < MLV; ++l) { + const size_t row = (size_t)l * MD + n; + float* dstl = cw.data() + (size_t)l * C * MO * MO; + for (int c = 0; c < C; ++c) + for (int y = 0; y < MO; ++y) + for (int x = 0; x < MO; ++x) + dstl[((size_t)y * MO + x) * C + c] = + mfeat[((row * C + c) * MO + y) * MO + x]; + } transfer_to_backend(min_, std::span(cw.data(), cw.size())); compute(g2, backend); std::vector> hw; @@ -821,6 +896,11 @@ int main(int argc, char** argv) { MH = hw[0].first; MW = hw[0].second; NCLS_M = (int)(mo0.size() / ((size_t)MH * MW)); } + logit_all.insert(logit_all.end(), mo0.begin(), mo0.end()); +#ifdef ARCH_D + if (!want_miou) continue; + // ⚠️ 아래 「28 → 14」 전제는 **SubD 를 먹이는 쪽만** 필요하다. 로짓만 낼 때는 + // 아무 크기나 상관없으므로 여기서 막으면 마스크 축을 통째로 못 잰다. 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", @@ -866,20 +946,27 @@ int main(int argc, char** argv) { 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()); +#endif // ARCH_D } - for (int i = 0; i < MD; ++i) { - dets[i].score *= miou[i]; - } + if (want_miou) + 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 예측 + dump_bin(pref + ".mlogit.bin", logit_all); // SubC 출력 (행별 cwhn: ((y*MW+x)*NCLS+k)) + // ⚠️ 축을 파일 이름으로 못 적으니 **따로 적는다.** 행수·클래스수·H·W 를 모르면 + // 파이썬 쪽이 크기로 짐작하게 되고, 짐작은 계열이 바뀌면 조용히 틀린다. + { + const std::vector dims = {(float)MD, (float)NCLS_M, (float)MH, (float)MW}; + dump_bin(pref + ".mlogit.dims.bin", dims); + } + if (want_miou) { + dump_bin(pref + ".miouin.bin", din_all); // SubD 입력 (행별 cwhn, 257ch) + dump_bin(pref + ".maskiou.bin", miou); // 라벨 채널의 IoU 예측 + } } #endif diff --git a/tools/verify/backbone/run_mmdet.cpp b/tools/verify/mmdet/backbone/run_mmdet.cpp similarity index 100% rename from tools/verify/backbone/run_mmdet.cpp rename to tools/verify/mmdet/backbone/run_mmdet.cpp diff --git a/tools/verify/mmdet/dense_head/box_all.py b/tools/verify/mmdet/dense_head/box_all.py new file mode 100644 index 0000000..49005f2 --- /dev/null +++ b/tools/verify/mmdet/dense_head/box_all.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""box_all.py — one-stage **박스 축**을 계열별로 전수로 돌린다. + + python box_all.py # 빌드된 계열 전부 + python box_all.py yolact rpn # 골라서 + python box_all.py --gen-root ~/work/box-full + python box_all.py --results /tmp/box-onestage.results.json + +`verify_postproc.py` 한 계열을 부르는 얇은 드라이버다. **판정 규약도 짝 결정도 여기서 +새로 만들지 않는다** — 전부 하네스 것을 그대로 쓴다. + +왜 있나 +------ +이 드라이버가 **없어서 one-stage 전수를 한 번 통째로 재현 못 했다.** 2026-08-25 에 +같은 이름의 스크립트로 44계열을 돌린 기록이 위키에 있는데, 그게 세션 로컬(`/tmp`)에만 +있었고 같이 사라졌다. 산출물(`<계열>/out/run_mmdet`)은 멀쩡히 남아 있었는데도 +**부를 방법이 없었다.** + +→ 위키 규칙 그대로다: **파일에 안 적힌 것은 다음 세션에 존재하지 않는다.** + 그래서 `/tmp` 가 아니라 저장소 안에 둔다. + +⚠️ **짝은 `used.json` 에서 받는다 — 손으로 고르지 마라.** + `verify_heads.py` 가 구울 때 실제로 쓴 config·체크포인트를 그 파일에 남긴다. + 래퍼 계열(KD·트래커)은 언랩한 config 를 `cfg.py` 로 덤프해 굽기 때문에, mmdet 원본 + config 를 주면 **「짝이 다르다」 경고가 항상 뜨고** 기준값도 다른 모델에서 나온다. + `ld`·`strongsort` 가 그래서 「값 못 냄」으로 잘못 적혔다(둘 다 멀쩡했다). + → wiki `pitfall/짝을-손으로-고르면-남의-가중치를-잰다.md` + +⚠️ **빌드는 여기서 안 한다.** `<계열>/out/run_mmdet` 이 없으면 `BUILD_NONE` 으로 적고 + 넘어간다 — 굽는 것은 `verify_heads.py` 몫이고, 그쪽이 export→g2c→가중치→빌드까지 + 한다. 두 도구가 각자 구우면 **어느 쪽 산출물을 쟀는지** 알 수 없게 된다. + +⚠️ **못 잰 것을 실패로 적지 마라.** `BUILD_NONE`·`PAIR_NONE`·`EMPTY` 는 실패가 아니라 + 측정 실패다. 표에서 따로 세고, PASS 수와 나란히 놓지 않는다. +""" +import argparse +import concurrent.futures as _fut +import json +import os +import re +import subprocess +import sys + +sys.stdout.reconfigure(line_buffering=True) # 파이프로 보내도 진행이 보이게 + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +import mmdet_families as MF # noqa: E402 +import vconfig # noqa: E402 + +CFG, ARGS = vconfig.load() +V = os.path.normpath(os.path.join(HERE, "..", "..", "..")) # vision.cpp/tools +VISP = os.path.normpath(os.path.join(V, "..")) # vision.cpp +POSTPROC = os.path.join(HERE, "verify_postproc.py") +DEFAULT_IMAGE = os.path.join(VISP, "tests", "input", "cat-and-hat.jpg") + +# 판정 요약 줄. `verify_postproc.py` 가 찍는 형식과 **한 군데서만** 묶여 있다. +RE_WORST = re.compile( + r"최대: 박스 ([\d.]+)px · 점수 ([\d.]+) · 라벨 불일치 (\d+)건 · 개수차 (\d+)건") +RE_DIST = re.compile(r"분포: 중앙값 ([\d.]+)px · 95%tile ([\d.]+)px · 1px 이내 (\d+)/(\d+)건") +RE_BAND = re.compile(r"임계값 [\d.]+~[\d.]+ 구간: mmdet (\d+)건 · C\+\+ (\d+)건") +RE_COUNT = re.compile(r"mmdet (\d+)건 · run_mmdet (\d+)건") + + +def _rebase(path, fam, gen_root): + """`used.json` 에 박힌 절대경로를 지금 `gen_root` 로 되돌린다. + + ⚠️ **경로는 트리를 옮기면 죽는다.** `used.json` 은 구울 때의 절대경로를 적는데, + 2026-08-28 홈 재편으로 `~/work/box-full` 이 `~/company/artifacts/box-full` 로 + 옮겨가면서 래퍼 계열의 `cfg.py` 를 통째로 못 찾게 됐다. 그런데 `pair_for` 는 + **조용히 원본 mmdet config 로 떨어진다** — 트래커는 `model.backbone` 이 없어서 + `'ConfigDict' object has no attribute 'backbone'`, `ld` 는 선생 config 를 상대경로로 + 가리켜 `FileNotFoundError` 가 난다. 넷 다 「실패」로 보이지만 **짝을 잘못 준 것**이다. + + 산출물 디렉터리 안의 파일(`cfg.py`·`ckpt.pth`)이면 파일명만 살려 지금 자리에서 찾는다. + """ + if not path: + return path + if os.path.exists(path): + return path + here = os.path.join(gen_root, fam, os.path.basename(path)) + return here if os.path.exists(here) else path + + +def pair_for(fam, gen_root): + """(config, checkpoint) — **구울 때 쓴 짝**이 정본이다. `used.json` 하나만 본다. + + ⛔ **원본 mmdet config 로 떨어지지 않는다.** 예전엔 `used.json` 을 못 읽으면 조용히 + `resolve_pair` 로 내려갔는데, 그러면 **남의 짝으로 재고도 실패처럼 보인다.** + 실측 2026-09-01: 홈 재편으로 `used.json` 의 절대경로가 죽자 래퍼 4계열이 + 원본 config 로 떨어져 `'ConfigDict' object has no attribute 'backbone'` · + `FileNotFoundError` 를 냈다. 넷 다 **모델은 멀쩡했다** — 짝이 틀렸을 뿐이다. + + 못 잰 것은 못 잤다고 적는다. 실패 칸에 넣지 않는다. + """ + u = os.path.join(gen_root, fam, "used.json") + if not os.path.exists(u): + print(f" ⚠️ {fam}: used.json 이 없다 — verify_heads.py 로 먼저 구워라") + return None, None + try: + d = json.load(open(u, encoding="utf-8")) + except Exception as e: + print(f" ⚠️ {fam}: used.json 을 못 읽는다 — {type(e).__name__}: {e}") + return None, None + c = _rebase(d.get("config"), fam, gen_root) + k = _rebase(d.get("checkpoint"), fam, gen_root) + if c and k and os.path.exists(c) and os.path.exists(k): + return c, k + for name, v in (("config", c), ("checkpoint", k)): + if not v or not os.path.exists(v): + print(f" ⚠️ {fam}: used.json 의 {name} 이 없다 — {v}") + return None, None + + +def one(fam, gen_root, size, image, verbose): + gen = os.path.join(gen_root, fam, "out") + if not os.path.exists(os.path.join(gen, "run_mmdet")): + return dict(family=fam, status="BUILD_NONE", + note=f"{gen}/run_mmdet 없음 — verify_heads.py 로 먼저 굽는다") + cfg, ckpt = pair_for(fam, gen_root) + if not cfg or not ckpt: + return dict(family=fam, status="PAIR_NONE", note="config·체크포인트를 못 찾았다") + + img = MF.test_image(fam, image) + r = subprocess.run([sys.executable, POSTPROC, gen, cfg, ckpt, img, str(size)], + capture_output=True, text=True, + env={**os.environ, "OMP_NUM_THREADS": "1"}) + out = (r.stdout or "") + (r.stderr or "") + m = RE_WORST.search(out) + if not m: + # 판정 줄이 없다 = 비교까지 못 갔다. **왜** 인지를 그대로 옮긴다. + why = "한쪽이 비어 비교 불가" if "한쪽이 비어" in out else \ + next((l.strip() for l in reversed(out.splitlines()) + if l.strip() and not l.startswith(" ")), "출력 없음") + return dict(family=fam, status="EMPTY" if "한쪽이 비어" in out else "RUN_FAIL", + note=why[:110], stdout=out if verbose else None) + + box, score, bad_label, gap = float(m[1]), float(m[2]), int(m[3]), int(m[4]) + ok = box < 2.0 and score < 0.05 and bad_label == 0 and gap == 0 + res = dict(family=fam, status="PASS" if ok else "FAIL", + box=box, score=score, bad_label=bad_label, gap=gap, + image=os.path.basename(img), + note=f"박스 {box:.2f}px · 점수 {score:.3f} · 라벨 {bad_label} · 개수차 {gap}") + if (c := RE_COUNT.search(out)): + res["n_ref"], res["n_got"] = int(c[1]), int(c[2]) + # ⚠️ **박스가 수백 개인 계열은 최대값만 보면 오해한다**(rpn: 최대 133px 인데 중앙값 + # 0.09px · 182/185건이 1px 이내). 분포가 있으면 반드시 같이 싣는다. + if (d := RE_DIST.search(out)): + res["median"], res["p95"] = float(d[1]), float(d[2]) + res["within_1px"], res["n_rows"] = int(d[3]), int(d[4]) + res["note"] += f" · 중앙값 {float(d[1]):.2f}px · 1px 이내 {d[3]}/{d[4]}" + # 개수차가 컷 근처에서 났는지 — 「임계 경계」와 「진짜 결함」을 가르는 첫 단서다. + if (b := RE_BAND.search(out)): + res["band_ref"], res["band_got"] = int(b[1]), int(b[2]) + if "짝이 다르다" in out: + # 래퍼 계열에서 **거짓으로도** 뜬다(basename 비교라 `cfg.py` 와 늘 어긋난다). + # 판정을 바꾸지 않고 표시만 한다 — 수치가 크게 틀릴 때만 의심하면 된다. + res["pair_warn"] = True + res["note"] += " · ⚠️짝 경고" + if verbose: + res["stdout"] = out + return res + + +def main(): + ap = argparse.ArgumentParser(add_help=True) + ap.add_argument("families", nargs="*", help="비우면 빌드된 계열 전부") + ap.add_argument("--gen-root", default=CFG.workdir, + help=f"계열별 산출물 루트 (<루트>/<계열>/out). 기본 {CFG.workdir}") + ap.add_argument("--size", type=int, default=CFG.size) + ap.add_argument("--image", default=DEFAULT_IMAGE, + help="계열별 권장 이미지가 있으면 그쪽이 우선한다") + ap.add_argument("--results", default=None, help="results.json 을 쓸 경로") + ap.add_argument("--workers", type=int, default=CFG.workers) + ap.add_argument("-v", "--verbose", action="store_true", help="계열별 원본 출력을 남긴다") + a = ap.parse_args(ARGS) + + root = os.path.expanduser(a.gen_root) + fams = a.families or sorted( + d for d in os.listdir(root) + if os.path.exists(os.path.join(root, d, "out", "run_mmdet"))) + if not fams: + print(f"돌릴 계열이 없다 — {root}/<계열>/out/run_mmdet 이 하나도 없다.\n" + f" verify_heads.py 로 먼저 굽는다 (--set paths.workdir={root}).") + return 2 + + print(CFG.banner() if hasattr(CFG, "banner") else "") + print(f"gen-root={root} · size={a.size} · 판정: 박스<2.0px 점수<0.05 라벨0 개수차0") + print(f"{len(fams)}계열: {' '.join(fams)}\n") + + rows = [] + with _fut.ThreadPoolExecutor(max_workers=max(1, a.workers)) as ex: + futs = {ex.submit(one, f, root, a.size, a.image, a.verbose): f for f in fams} + for i, fu in enumerate(_fut.as_completed(futs), 1): + r = fu.result() + rows.append(r) + print(f"[{i:3d}/{len(fams)}] {r['family']:<22} {r['status']:<12} {r.get('note','')}") + + rows.sort(key=lambda r: r["family"]) + print("\n" + "=" * 78) + for r in rows: + print(f" {r['family']:<22} {r['status']:<12} {r.get('note','')}") + + # ⚠️ **PASS 수만 적지 마라.** 못 잰 것과 실패한 것을 한 칸에 넣으면 다음 사람이 + # "이 계열이 못 한다" 로 읽는다. 성격별로 따로 센다. + n = {} + for r in rows: + n[r["status"]] = n.get(r["status"], 0) + 1 + measured = sum(v for k, v in n.items() if k in ("PASS", "FAIL")) + print(f"\nPASS {n.get('PASS', 0)}/{measured} (잰 것 기준)") + unmeasured = {k: v for k, v in n.items() if k not in ("PASS", "FAIL")} + if unmeasured: + print(" 못 잼: " + " · ".join(f"{k} {v}" for k, v in sorted(unmeasured.items())) + + " ← 실패가 아니다. PASS 수와 나란히 놓지 마라") + + if a.results: + with open(a.results, "w", encoding="utf-8") as f: + json.dump(rows, f, ensure_ascii=False, indent=1) + print(f" → {a.results}") + return 0 if n.get("FAIL", 0) == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/verify/dense_head/fetch_checkpoints.py b/tools/verify/mmdet/dense_head/fetch_checkpoints.py similarity index 100% rename from tools/verify/dense_head/fetch_checkpoints.py rename to tools/verify/mmdet/dense_head/fetch_checkpoints.py diff --git a/tools/verify/mmdet/dense_head/glip_decode.py b/tools/verify/mmdet/dense_head/glip_decode.py new file mode 100644 index 0000000..67cd0cb --- /dev/null +++ b/tools/verify/mmdet/dense_head/glip_decode.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""GLIP 의 15텐서 → 박스·점수·라벨. **호스트(numpy) 참조 구현.** + +컴파일된 그래프가 내는 것은 `cls_logits · bbox_pred · centerness` 15개뿐이고, 거기서 +박스를 만드는 규칙은 `mmdet/models/dense_heads/atss_vlfusion_head.py` 의 +`_predict_by_feat_single` 이다. 그걸 **여기서 먼저 정확히 재현해** C++ 로 옮길 때 +비교 기준으로 쓴다 — C++ 를 바로 쓰면 「값이 틀렸는데 디코드 탓인지 그래프 탓인지」를 +못 가른다. + +일반 검출기와 두 곳이 다르다. + +1. **점수가 클래스가 아니라 토큰이다.** `cls_logits` 의 마지막 축 256 은 **텍스트 토큰 + 슬롯**이라 argmax 가 뜻이 없다. `positive_map`(문구 → 토큰 인덱스들)으로 **평균**내 + 문구 점수를 만든다. 그 map 은 토크나이저가 만들므로 **호스트가 넘긴다.** +2. **박스 코더가 `DeltaXYWHBBoxCoderForGLIP`** 이다. 표준 Delta 디코드에서 `-1`/`+1` 이 + 네 군데 붙는다(아래 `delta2bbox_glip` 주석). + +⚠️ **점수는 곱이 아니라 `sqrt(곱)`** 이다. 곱으로 두면 점수가 통째로 낮아져 score_thr 에서 + 개수가 줄고, 「박스는 맞는데 개수차」로 보인다. +""" +import numpy as np + + +def convert_grounding_to_cls_scores(logits, positive_map, num_phrases): + """토큰 로짓 → 문구 점수. `atss_vlfusion_head.py:32` 와 같은 식. + + logits: (N, n_tokens) — **이미 sigmoid 를 거친 값** + positive_map: {문구번호 j(1부터): [토큰 인덱스…]} + 반환: (N, num_phrases) — 열 j-1 이 문구 j 의 점수 + """ + out = np.zeros((logits.shape[0], num_phrases), dtype=np.float32) + for j, toks in positive_map.items(): + if not toks: + continue + out[:, int(j) - 1] = logits[:, np.asarray(toks, dtype=np.int64)].mean(-1) + return out + + +def anchors(feat_hw, stride, octave_base_scale=8.0, center_offset=0.5): + """`AnchorGenerator(ratios=[1.0], scales_per_octave=1)` 의 격자 앵커 (N, 4). + + ratio 1·octave 1 이라 앵커는 정사각 하나뿐이다 — 변 = stride × octave_base_scale. + `center_offset=0.5` 라 격자 중심이 칸 가운데다. + """ + h, w = feat_hw + size = stride * octave_base_scale + cx = (np.arange(w, dtype=np.float32) + center_offset) * stride + cy = (np.arange(h, dtype=np.float32) + center_offset) * stride + cx, cy = np.meshgrid(cx, cy) # (h, w) — 행 우선 + cx, cy = cx.reshape(-1), cy.reshape(-1) + half = size / 2.0 + return np.stack([cx - half, cy - half, cx + half, cy + half], axis=1) + + +def delta2bbox_glip(rois, deltas, means=(0., 0., 0., 0.), stds=(0.1, 0.1, 0.2, 0.2), + max_shape=None, wh_ratio_clip=16 / 1000): + """`DeltaXYWHBBoxCoderForGLIP.decode`. 표준 Delta 와 **네 군데** 다르다. + + | | 표준 | GLIP | + |---|---|---| + | 앵커 중심 | `(x1+x2)*0.5` | `(x1+x2-1)*0.5` | + | 박스 복원 | `gxy ∓ gwh*0.5` | `gxy ∓ (gwh-1)*0.5` | + | 클립 | `max_shape` | `max_shape - 1` | + | 마지막 | — | `x2,y2 += 1` (**후처리 뒤**, 여기가 아니다) | + + mmdet 주석에도 "very strange bbox decoder logic" 이라 적혀 있다 — 공식 구현의 mAP 에 + 맞추려고 남긴 것이라 **한 칸이라도 빼면 박스가 1px 씩 밀린다.** + """ + d = deltas.reshape(-1, 4) * np.asarray(stds, np.float32) + np.asarray(means, np.float32) + dxy, dwh = d[:, :2], d[:, 2:] + pxy = (rois[:, :2] + rois[:, 2:] - 1) * 0.5 # ⚠️ -1 + pwh = rois[:, 2:] - rois[:, :2] + + max_ratio = abs(np.log(wh_ratio_clip)) + dwh = np.clip(dwh, -max_ratio, max_ratio) + gxy = pxy + pwh * dxy + gwh = pwh * np.exp(dwh) + + x1y1 = gxy - (gwh - 1) * 0.5 # ⚠️ -1 + x2y2 = gxy + (gwh - 1) * 0.5 # ⚠️ -1 + boxes = np.concatenate([x1y1, x2y2], axis=-1) + if max_shape is not None: + h, w = max_shape + boxes[:, 0::2] = np.clip(boxes[:, 0::2], 0, w - 1) # ⚠️ -1 + boxes[:, 1::2] = np.clip(boxes[:, 1::2], 0, h - 1) + return boxes + + +def _nms(boxes, scores, iou_thr): + """클래스 무관 NMS. 라벨별로 부르는 쪽에서 갈라 준다.""" + order = scores.argsort()[::-1] + x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] + area = (x2 - x1).clip(0) * (y2 - y1).clip(0) + keep = [] + while order.size: + i = order[0] + keep.append(i) + if order.size == 1: + break + rest = order[1:] + xx1 = np.maximum(x1[i], x1[rest]) + yy1 = np.maximum(y1[i], y1[rest]) + xx2 = np.minimum(x2[i], x2[rest]) + yy2 = np.minimum(y2[i], y2[rest]) + inter = (xx2 - xx1).clip(0) * (yy2 - yy1).clip(0) + iou = inter / np.maximum(area[i] + area[rest] - inter, 1e-9) + order = rest[iou <= iou_thr] + return np.asarray(keep, dtype=np.int64) + + +def decode(cls_logits, bbox_preds, centernesses, positive_map, num_phrases, + img_shape, strides=(8, 16, 32, 64, 128), + score_thr=0.05, nms_pre=1000, nms_thr=0.6, max_per_img=100): + """15텐서 → (boxes (M,4), scores (M,), labels (M,)). + + cls_logits[i] : (HW, n_tokens) — 레벨 i + bbox_preds[i] : (4, H, W) + centernesses[i]: (1, H, W) + """ + all_boxes, all_scores, all_labels = [], [], [] + for lvl, stride in enumerate(strides): + bp = bbox_preds[lvl] + _, h, w = bp.shape + # (4,H,W) → (HW,4). torch 의 `permute(1,2,0).reshape(-1,4)` 와 같은 순서다. + deltas = bp.transpose(1, 2, 0).reshape(-1, 4) + ctr = 1.0 / (1.0 + np.exp(-centernesses[lvl].reshape(-1))) # sigmoid + logit = cls_logits[lvl] + scores = convert_grounding_to_cls_scores( + 1.0 / (1.0 + np.exp(-logit)), positive_map, num_phrases) # (HW, P) + + flat = scores.reshape(-1) + idx = np.nonzero(flat > score_thr)[0] + if idx.size == 0: + continue + if idx.size > nms_pre: # 상위 nms_pre 개만 + idx = idx[np.argsort(flat[idx])[::-1][:nms_pre]] + keep_loc, labels = idx // num_phrases, idx % num_phrases + # ⚠️ 곱이 아니라 **sqrt(곱)** 이다. + sc = np.sqrt(flat[idx] * ctr[keep_loc]) + pri = anchors((h, w), float(stride))[keep_loc] + all_boxes.append(delta2bbox_glip(pri, deltas[keep_loc], max_shape=img_shape)) + all_scores.append(sc) + all_labels.append(labels) + + if not all_boxes: + return (np.zeros((0, 4), np.float32), np.zeros((0,), np.float32), + np.zeros((0,), np.int64)) + boxes = np.concatenate(all_boxes).astype(np.float32) + scores = np.concatenate(all_scores).astype(np.float32) + labels = np.concatenate(all_labels).astype(np.int64) + + # 라벨별 NMS (mmdet `batched_nms` 와 같은 뜻). + keep = [] + for lab in np.unique(labels): + sel = np.nonzero(labels == lab)[0] + keep.append(sel[_nms(boxes[sel], scores[sel], nms_thr)]) + keep = np.concatenate(keep) if keep else np.zeros((0,), np.int64) + keep = keep[np.argsort(scores[keep])[::-1][:max_per_img]] + + boxes, scores, labels = boxes[keep], scores[keep], labels[keep] + # ⚠️ **후처리 뒤에 +1.** mmdet 이 "1 을 안 더하면 공식 mAP 와 안 맞는다" 고 적어 둔 곳이다. + boxes[:, 2:] = boxes[:, 2:] + 1 + return boxes, scores, labels diff --git a/tools/verify/dense_head/head_support_map.py b/tools/verify/mmdet/dense_head/head_support_map.py similarity index 99% rename from tools/verify/dense_head/head_support_map.py rename to tools/verify/mmdet/dense_head/head_support_map.py index a2c9976..8f4cfb4 100644 --- a/tools/verify/dense_head/head_support_map.py +++ b/tools/verify/mmdet/dense_head/head_support_map.py @@ -32,7 +32,7 @@ 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__), "..", "..", "..")) +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 diff --git a/tools/verify/dense_head/mmdet_families.py b/tools/verify/mmdet/dense_head/mmdet_families.py similarity index 90% rename from tools/verify/dense_head/mmdet_families.py rename to tools/verify/mmdet/dense_head/mmdet_families.py index 2999dd4..a517514 100644 --- a/tools/verify/dense_head/mmdet_families.py +++ b/tools/verify/mmdet/dense_head/mmdet_families.py @@ -34,8 +34,31 @@ "masktrack_rcnn")} # 이미지는 **저장소 안**에 있다. 사용자가 준 `--image` 의 디렉토리에서 찾으면 # 엉뚱한 곳(`~/pics/bench-image.jpg`)을 가리키고, 상대경로면 하위 프로세스의 cwd 로 풀린다. +# ⚠️ `..` 은 **넷**이다. dense_head → mmdet → verify → tools → vision.cpp. +# 셋이면 `tools/tests/input` 이라 없는 경로다. 그러면 `test_image` 가 default 로 +# 조용히 떨어져 **위 매핑이 통째로 무효가 된다** — 트래커를 사람 없는 이미지로 재고 +# 양쪽 0건이 나와 `EMPTY` 로 찍힌다(2026-09-01 실측). _IMG_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), - "..", "..", "..", "tests", "input")) + "..", "..", "..", "..", "tests", "input")) + + +# 계열별 **입력 크기**. 지정이 없으면 호출자가 준 기본값을 쓴다. +# +# ⚠️ **이 표가 없으면 전수를 돌릴 때마다 같은 계열이 다시 실패한다.** `fpg`(Feature +# Pyramid Grids)는 P5 아래로 레벨을 더 쌓는데 800·512 가 거기서 안 나눠떨어져 +# `size of tensor a (25) must match b (26) at dim 3` 로 죽는다. 2026-08-18 에 +# 「1024 로 재측정」으로 해결됐고 0.28px 로 통과하는데, **그 해결이 코드에 안 들어가 +# 있어서** 09-01 전수에서 또 EXPORT_FAIL 이 났다. +# → wiki `pitfall/하네스가-못-잰-것을-대상이-못-하는-것으로-적지-마라` +# +# ⚠️ **여기 걸린 계열은 분모가 다르다.** 다른 크기에서 잰 값을 같은 전수 PASS 수에 +# 합치지 마라 — 측정 조건이 섞인다. 보고할 때 크기를 같이 적는다. +_SIZE = {"fpg": 1024} + + +def test_size(fam, default): + """이 계열로 잴 때 쓸 입력 한 변. 지정이 없으면 `default`.""" + return _SIZE.get(fam, default) def test_image(fam, default): diff --git a/tools/verify/dense_head/run_vfnet_head.cpp b/tools/verify/mmdet/dense_head/run_vfnet_head.cpp similarity index 100% rename from tools/verify/dense_head/run_vfnet_head.cpp rename to tools/verify/mmdet/dense_head/run_vfnet_head.cpp diff --git a/tools/verify/dense_head/vconfig.py b/tools/verify/mmdet/dense_head/vconfig.py similarity index 100% rename from tools/verify/dense_head/vconfig.py rename to tools/verify/mmdet/dense_head/vconfig.py diff --git a/tools/verify/dense_head/verify.toml b/tools/verify/mmdet/dense_head/verify.toml similarity index 85% rename from tools/verify/dense_head/verify.toml rename to tools/verify/mmdet/dense_head/verify.toml index 675cb9f..0dd4fa9 100644 --- a/tools/verify/dense_head/verify.toml +++ b/tools/verify/mmdet/dense_head/verify.toml @@ -10,14 +10,16 @@ # mmdet 저장소. 아래에 `configs/` 와 `checkpoints/` 가 있어야 한다. mmdet = "~/mmbuild/mmdetection" # g2c(컴파일러) 루트. 기본값은 vision.cpp 의 부모 = 이 브랜치의 g2c. -g2c = "../../../.." +g2c = "../../../../.." # 트래커·반지도 래퍼 config 를 안쪽 검출기로 푸는 전처리기. # 없으면 그 계열만 INIT_FAIL 로 남는다 — 없다고 전체가 멈추지는 않는다. # ⚠️ 예전에는 **형제 저장소**(`../../../../../GTX_Compiler/...`)를 가리켰는데, 이 트리에서는 # `/tmp/GTX_Compiler` 로 풀려 **조용히 꺼진 채** 돌았다(존재 검사만 하고 없으면 넘어간다). # 도구를 상위 저장소로 옮겼으니 그쪽을 가리킨다 — vision.cpp 는 서브모듈이라 -# `../../../../` 가 g2c 루트다(`g2c` 항목과 같은 기준). -unwrap = "../../../../test_script/mmdet/mmdet_unwrap_config.py" +# `../../../../../` 가 g2c 루트다(`g2c` 항목과 같은 기준). +# ⚠️ 2026-08-27 verify/ 를 생태계별로 재편하며 이 파일이 한 단계 깊어졌다 — +# `..` 하나를 안 늘렸더니 `g2c` 가 vision.cpp 를 가리켰다(존재는 하니 조용히 통과). +unwrap = "../../../../../test_script/mmdet/mmdet_unwrap_config.py" # 중간 산출물(.pt · 생성 .cpp · gguf · 덤프). 지워도 된다. workdir = "/tmp/visp-verify-heads" # head 지원 여부 조사(head_support_map.py) 전용 작업 폴더. diff --git a/tools/verify/mmdet/dense_head/verify_glip_box.py b/tools/verify/mmdet/dense_head/verify_glip_box.py new file mode 100644 index 0000000..8baeddf --- /dev/null +++ b/tools/verify/mmdet/dense_head/verify_glip_box.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""GLIP 의 **박스**를 검증한다 — 텐서가 아니라 최종 검출 결과로. + +두 단계로 나눠 잰다. **한 번에 재면 틀렸을 때 디코드 탓인지 그래프 탓인지 못 가른다.** + + ① 디코드만 : torch 15텐서 → 우리 디코드 vs torch 15텐서 → mmdet 디코드 + ② 전체 : C++ 15텐서 → 우리 디코드 vs torch 15텐서 → mmdet 디코드 + +①이 0 이면 디코드 규칙(`glip_decode.py`)이 mmdet 과 같다는 뜻이고, 그때 ②의 오차는 +전부 그래프(fp16·커널) 몫이다. + + python verify_glip_box.py [--caption "person. bicycle. car. dog."] [--size 512] + +⚠️ **positive_map 은 호스트가 만든다.** 토크나이저가 문구를 토큰 인덱스로 나눈 결과라 + C++ 토크나이저가 없어도 여기까지 온다 — 그건 CLI 를 만들 때 할 일이다. +""" +import argparse +import os +import subprocess +import sys + +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +V = os.path.abspath(os.path.join(HERE, "..", "..", "..", "..")) +P = os.path.abspath(os.path.join(V, "..")) +FE = V + "/tools/frontend/mmdet" +sys.path.insert(0, HERE) +sys.path.insert(0, FE) + +MM = os.path.expanduser("~/mmbuild/mmdetection") +CFG = MM + "/configs/glip/glip_atss_swin-t_a_fpn_dyhead_pretrain_obj365.py" +CK = MM + "/checkpoints/glip_tiny_a_mmdet-b3654169.pth" + +# 판정 기준. 박스는 픽셀, 점수는 절대차. 저장소 관례(박스 하네스)를 따른다. +BOX_TOL_PX = 2.0 +SCORE_TOL = 0.05 + + +def _match(a_boxes, a_scores, a_labels, b_boxes, b_scores, b_labels): + """두 검출 집합을 점수 내림차순으로 짝지어 최대 오차를 낸다. + + NMS 를 거친 뒤라 **순서가 곧 짝**이다(같은 입력·같은 임계값이면 같은 순서가 나온다). + 개수가 다르면 그 자체가 결함이므로 따로 보고한다. + """ + n = min(len(a_boxes), len(b_boxes)) + if n == 0: + return dict(count_a=len(a_boxes), count_b=len(b_boxes), + box_px=float("nan"), score=float("nan"), label_bad=0) + box_px = float(np.abs(a_boxes[:n] - b_boxes[:n]).max()) + score = float(np.abs(a_scores[:n] - b_scores[:n]).max()) + label_bad = int((a_labels[:n] != b_labels[:n]).sum()) + return dict(count_a=len(a_boxes), count_b=len(b_boxes), + box_px=box_px, score=score, label_bad=label_bad) + + +def main(): + ap = argparse.ArgumentParser(prog="verify_glip_box") + ap.add_argument("--caption", default="person. bicycle. car. dog.") + ap.add_argument("--size", type=int, default=512) + ap.add_argument("--workdir", default="/tmp/visp-glip-box") + ap.add_argument("--skip-cpp", action="store_true", help="①(디코드만) 만 잰다") + a = ap.parse_args() + + import torch + import mmdet_wrap + import glip_decode + + mmdet_wrap.trace_friendly_ops() + mmdet_wrap.allow_mmengine_checkpoint_globals() + from mmdet.apis import init_detector + det = init_detector(CFG, CK, device="cpu") + det.eval() + + tok = det.language_model.tokenizer + if not hasattr(tok, "batch_encode_plus"): + tok.batch_encode_plus = tok.__call__ # transformers 5.x 에서 삭제된 4.x API + + # 캡션 → 토큰 + positive_map. **mmdet 자신의 함수로 만든다** — 규칙을 다시 짜면 틀린다. + tokenized, caption_string, tokens_positive, entities = \ + det.get_tokens_and_prompts(a.caption, True) + # ⚠️ `get_positive_map` 은 **튜플**을 돌려준다 — + # `(label→token dict, positive_map 텐서)`. 앞엣것이 이미 우리가 쓸 dict 다 + # (`plus=1` 이 안에서 걸려 라벨이 1부터다). 다시 변환하면 안 된다. + pos_map, _positive_map = det.get_positive_map(tokenized, tokens_positive) + n_phrase = len(entities) + print(f"[caption] {caption_string!r}") + print(f"[phrases] {entities} → positive_map {len(pos_map)}문구") + + with torch.no_grad(): + text_dict = det.language_model([caption_string]) + emb = text_dict["embedded"].contiguous() + + SZ = a.size + g = torch.Generator().manual_seed(0) + x = torch.randn(1, 3, SZ, SZ, generator=g) + with torch.no_grad(): + feats = det.extract_feat(x) + cls_logits, bbox_preds, centerness = det.bbox_head(feats, {"embedded": emb}) + + # ── ① 디코드만 ──────────────────────────────────────────────────────────── + ours = glip_decode.decode( + [t[0].numpy() for t in cls_logits], + [t[0].numpy() for t in bbox_preds], + [t[0].numpy() for t in centerness], + {int(k): list(v) for k, v in pos_map.items()}, n_phrase, (SZ, SZ)) + + from mmengine.structures import InstanceData # noqa: F401 (mmdet 내부에서 쓴다) + ref = det.bbox_head.predict_by_feat( + cls_logits, bbox_preds, centerness, + batch_img_metas=[{"img_shape": (SZ, SZ), "scale_factor": (1.0, 1.0), + "ori_shape": (SZ, SZ)}], + batch_token_positive_maps=[pos_map], rescale=False)[0] + ref_b = ref.bboxes.numpy() + ref_s = ref.scores.numpy() + ref_l = ref.labels.numpy() + + m1 = _match(ours[0], ours[1], ours[2], ref_b, ref_s, ref_l) + print(f"\n① 디코드만 개수 {m1['count_a']} vs {m1['count_b']} · " + f"박스 {m1['box_px']:.4f}px · 점수 {m1['score']:.5f} · 라벨불일치 {m1['label_bad']}") + + if a.skip_cpp: + return + + # ── ② 전체 (C++ 텐서 + 우리 디코드) ─────────────────────────────────────── + d = a.workdir + gen = os.path.join(d, "out") + run_dump = os.path.join(gen, "run_dump") + gguf = os.path.join(gen, "Glip.gguf") + if not (os.path.exists(run_dump) and os.path.exists(gguf)): + print(f"\n② 건너뜀 — 컴파일 산출물이 없다: {gen}\n" + f" (`verify_heads.py glip` 로 먼저 굽거나 --skip-cpp 를 준다)") + return + np.ascontiguousarray(x[0].numpy().transpose(1, 2, 0)).tofile(os.path.join(d, "in.bin")) + subprocess.run([run_dump, gguf, os.path.join(d, "in.bin"), os.path.join(d, "cpp"), + str(SZ)], cwd=d, capture_output=True, text=True) + + # 러너 덤프 순서 = 그래프 출력 순서 = cls×5, bbox×5, centerness×5 + cpp_cls, cpp_box, cpp_ctr = [], [], [] + for i in range(5): + s = cls_logits[i][0].shape # (HW, n_tok) + cpp_cls.append(np.fromfile(os.path.join(d, f"cpp.out.{i}.bin"), + dtype="float32").reshape(s)) + for i in range(5): + c, h, w = bbox_preds[i][0].shape + # 러너는 cwhn(HWC)로 쓴다 — torch CHW 로 되돌린다. + cpp_box.append(np.fromfile(os.path.join(d, f"cpp.out.{5 + i}.bin"), + dtype="float32").reshape(h, w, c).transpose(2, 0, 1)) + for i in range(5): + c, h, w = centerness[i][0].shape + cpp_ctr.append(np.fromfile(os.path.join(d, f"cpp.out.{10 + i}.bin"), + dtype="float32").reshape(h, w, c).transpose(2, 0, 1)) + + full = glip_decode.decode(cpp_cls, cpp_box, cpp_ctr, + {int(k): list(v) for k, v in pos_map.items()}, + n_phrase, (SZ, SZ)) + m2 = _match(full[0], full[1], full[2], ref_b, ref_s, ref_l) + print(f"② 전체 개수 {m2['count_a']} vs {m2['count_b']} · " + f"박스 {m2['box_px']:.4f}px · 점수 {m2['score']:.5f} · 라벨불일치 {m2['label_bad']}") + + ok = (m2["count_a"] == m2["count_b"] and m2["label_bad"] == 0 + and m2["box_px"] < BOX_TOL_PX and m2["score"] < SCORE_TOL) + print(f"\n{'PASS' if ok else 'FAIL'} (박스 {BOX_TOL_PX}px · 점수 {SCORE_TOL} · 라벨 0 · 개수차 0)") + + +if __name__ == "__main__": + main() diff --git a/tools/verify/dense_head/verify_heads.py b/tools/verify/mmdet/dense_head/verify_heads.py similarity index 97% rename from tools/verify/dense_head/verify_heads.py rename to tools/verify/mmdet/dense_head/verify_heads.py index 96836da..d21e341 100644 --- a/tools/verify/dense_head/verify_heads.py +++ b/tools/verify/mmdet/dense_head/verify_heads.py @@ -72,8 +72,8 @@ def _all_families(): # 저장소 관례(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__), "..", "..", "..")) +# g2c(컴파일러)와 vision.cpp 경로. 이 파일은 vision.cpp/tools/verify/mmdet/dense_head/ 에 있다. +V = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) P = CFG.g2c PY = sys.executable FE = V + "/tools/frontend/mmdet" @@ -353,7 +353,7 @@ def run(cmd, cwd, env_extra=None, timeout=2400, phase=None): raise SystemExit( "SubB 의 bbox_pred 가 None 이다 — with_reg=False 인 head 다(좌표를 다른 head 가 낸다). " "이 하네스는 (cls, box) 쌍을 전제하므로 이 계열은 two-stage 하네스로 재라: " - "tools/verify/roi/verify_postproc_roi.py") + "tools/verify/mmdet/roi/verify_postproc_roi.py") 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) @@ -399,7 +399,7 @@ def _no_box(fam, cfg_path, cw, d): "-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"), + V + "/tools/verify/common/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") @@ -534,7 +534,7 @@ def _two_stage(fam, cfg_path, cw, d): "-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", + V + "/tools/verify/mmdet/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") @@ -672,9 +672,15 @@ def _unwrap(out_name, *extra): 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 와 댄다. - # ⚠️ 그래서 이 숫자는 "이 계열이 검증됐다" 가 아니라 "컴파일 범위가 맞다" 는 뜻이다. + # missing 1 required positional argument`). 박스 하네스로는 못 재므로 **컴파일된 + # 그래프의 출력**을 torch 와 대는 `_no_box` 로 보낸다. + # + # ⚠️ **컴파일 범위가 계열마다 다르다 — 숫자를 같은 뜻으로 읽지 마라.** + # · `glip`: 2026-08-27 부터 **융합헤드까지** 한 그래프다(`MMDetTextCond` 가 텍스트 + # 임베딩을 상수로 굽는다) → 출력 15텐서. 이 숫자는 **헤드까지 검증**한 값이다. + # 아직 없는 것은 **디코드**(박스 delta·토큰→문구 점수 집계)뿐이다. + # · `grounding_dino`·`mm_grounding_dino`: head 가 DETR 꼴이라 아직 백본+넥뿐이다. + # 그 숫자는 "이 계열이 검증됐다" 가 아니라 "컴파일 범위가 맞다" 는 뜻이다. try: from mmengine.config import Config as _C if (_C.fromfile(cfg_path).get("model") or {}).get("language_model"): @@ -754,7 +760,7 @@ def _unwrap(out_name, *extra): "-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, + V + "/tools/verify/mmdet/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") diff --git a/tools/verify/dense_head/verify_postproc.py b/tools/verify/mmdet/dense_head/verify_postproc.py similarity index 87% rename from tools/verify/dense_head/verify_postproc.py rename to tools/verify/mmdet/dense_head/verify_postproc.py index 857ebed..fe6b4bb 100644 --- a/tools/verify/dense_head/verify_postproc.py +++ b/tools/verify/mmdet/dense_head/verify_postproc.py @@ -40,7 +40,16 @@ def cpp_boxes(gen_dir, image, size, thr): - """run_mmdet 을 돌려 디코드된 박스를 받는다(.bin = 박스당 6값).""" + """run_mmdet 을 돌려 디코드된 박스를 받는다(.bin = 박스당 6값). + + ⚠️ **`image` 는 기준값이 쓴 것과 같은 픽셀이어야 한다.** 여기 오는 것이 + `.bin`(전처리된 CWHN f32)이면 그대로 넘긴다 — 러너가 전처리를 건너뛴다. + jpg 를 넘기면 **러너가 자기 방식으로 디코드·리사이즈**하는데, 기준값 쪽은 + PIL `BILINEAR`(축소 때 안티에일리어싱이 들어간다)로 줄인다. 같은 사진이라도 + 픽셀이 달라져 점수가 계통적으로 어긋난다(실측 중앙값 5e-04~9e-04). + 그 크기면 컷(0.30) 언저리의 검출 하나가 갈려 **개수차 1** 이 난다. + two-stage 하네스는 처음부터 `in.bin` 을 한 번 만들어 양쪽에 준다. + """ out = os.path.join(gen_dir, "_postproc_check.bin") exe = os.path.join(gen_dir, "run_mmdet") if not os.path.exists(exe): @@ -60,6 +69,11 @@ def _one_gguf(gen_dir): return os.path.join(gen_dir, g[0]) +# 기준값이 만든 픽셀을 러너에게도 주기 위한 슬롯. `main()` 이 채운다. +# 리스트인 이유는 함수 서명을 안 바꾸려는 것뿐이다(부르는 곳이 여럿이다). +_SHARED_INPUT = [None] + + def mmdet_boxes(cfg, ckpt, image, size, thr, to_rgb): """mmdet 자신의 predict_by_feat — 앵커·디코드·NMS 의 정본.""" from mmdet.apis import init_detector @@ -67,8 +81,11 @@ def mmdet_boxes(cfg, ckpt, image, size, thr, to_rgb): # mmdet v3 체크포인트는 학습 메타(HistoryBuffer)를 함께 담고 있어 torch 2.6 의 # weights_only=True 기본값에서 로드가 거부된다(rtmdet 이 그랬다). 프론트엔드가 # 이미 쓰는 우회를 그대로 쓴다 — 필요한 클래스만 이름으로 허용한다. + # ⚠️ `..` 은 **셋**이다. dense_head → mmdet → verify → tools 로 세 칸 올라가야 + # `tools/frontend/mmdet` 이다. 둘이면 없는 경로라 import 가 조용히 실패하고, + # 허용 목록이 안 걸려 DETR 계열 8개가 통째로 RUN_FAIL 났다(2026-09-01 실측). sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), - "..", "..", "frontend", "mmdet")) + "..", "..", "..", "frontend", "mmdet")) try: import mmdet_wrap mmdet_wrap.allow_mmengine_checkpoint_globals() @@ -97,6 +114,11 @@ def mmdet_boxes(cfg, ckpt, image, size, thr, to_rgb): # `expected scalar type Double but found Float` 로 죽는다. 계열 탓처럼 보이지만 # 전처리 dtype 문제다. x = ((np.ascontiguousarray(x) - mean) / std).astype(np.float32) + # ⚠️ **러너에게도 이 픽셀을 그대로 준다.** 각자 jpg 를 열면 리사이즈 구현이 + # 달라 픽셀이 갈리고, 그러면 백엔드가 아니라 **리사이즈를 재게 된다.** + # `x` 는 이미 HWC(=러너의 cwhn) 라 그대로 쓴다. + if _SHARED_INPUT[0]: + np.ascontiguousarray(x).tofile(_SHARED_INPUT[0]) t = torch.from_numpy(x).permute(2, 0, 1).unsqueeze(0) meta = {"img_shape": (size, size), "ori_shape": (size, size), @@ -226,8 +248,12 @@ def main(): except Exception: # 계열을 못 알아내도 계속 간다 pass - got = cpp_boxes(gen, image, size, thr) + # ⚠️ **순서를 바꾸지 마라.** 기준값 쪽이 픽셀을 만들면서 `in.bin` 을 떨구고, + # 러너는 그 파일을 받는다. 러너를 먼저 돌리면 파일이 없어 jpg 로 떨어지고 + # **각자 리사이즈**하게 된다(그게 원래 상태였다). + _SHARED_INPUT[0] = os.path.join(gen, "_postproc_in.bin") ref = mmdet_boxes(cfg, ckpt, image, size, thr, to_rgb) + got = cpp_boxes(gen, _SHARED_INPUT[0], size, thr) print(f"\nmmdet {len(ref)}건 · run_mmdet {len(got)}건") rows = match(ref, got) diff --git a/tools/verify/seg/run_maskrcnn.cpp b/tools/verify/mmdet/inst_seg/run_maskrcnn.cpp similarity index 100% rename from tools/verify/seg/run_maskrcnn.cpp rename to tools/verify/mmdet/inst_seg/run_maskrcnn.cpp diff --git a/tools/verify/roi/run_frcnn.cpp b/tools/verify/mmdet/roi/run_frcnn.cpp similarity index 100% rename from tools/verify/roi/run_frcnn.cpp rename to tools/verify/mmdet/roi/run_frcnn.cpp diff --git a/tools/verify/roi/run_roi_verify.cpp b/tools/verify/mmdet/roi/run_roi_verify.cpp similarity index 100% rename from tools/verify/roi/run_roi_verify.cpp rename to tools/verify/mmdet/roi/run_roi_verify.cpp diff --git a/tools/verify/roi/run_rpn_verify.cpp b/tools/verify/mmdet/roi/run_rpn_verify.cpp similarity index 100% rename from tools/verify/roi/run_rpn_verify.cpp rename to tools/verify/mmdet/roi/run_rpn_verify.cpp diff --git a/tools/verify/roi/verify_postproc_roi.py b/tools/verify/mmdet/roi/verify_postproc_roi.py similarity index 70% rename from tools/verify/roi/verify_postproc_roi.py rename to tools/verify/mmdet/roi/verify_postproc_roi.py index 47d8b6b..1ef302d 100644 --- a/tools/verify/roi/verify_postproc_roi.py +++ b/tools/verify/mmdet/roi/verify_postproc_roi.py @@ -33,9 +33,13 @@ import time HERE = os.path.dirname(os.path.abspath(__file__)) -V = os.path.abspath(os.path.join(HERE, "..", "..", "..")) # vision.cpp (tools/verify/roi 에서 3단계 위) +V = os.path.abspath(os.path.join(HERE, "..", "..", "..", "..")) # vision.cpp (tools/verify/mmdet/roi 에서 4단계 위) FE = os.path.join(V, "tools", "frontend", "mmdet") -DH = os.path.join(V, "tools", "verify", "dense_head") +# ⚠️ `dense_head` 는 `verify/` 바로 밑이 아니라 `verify/mmdet/` 밑이다. 빠뜨리면 +# `os.path.join` 이 없는 경로를 만들고 `sys.path.insert` 도 성공한다 — +# `import mmdet_families` 만 죽는다. → wiki `하네스-상대경로는-틀려도-안-죽는다` +DH = os.path.join(V, "tools", "verify", "mmdet", "dense_head") +assert os.path.isdir(DH), f"dense_head 를 못 찾는다: {DH}" 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")) @@ -68,6 +72,15 @@ # 판정 기준 — dense head 와 **같게 둔다**. 다르면 18계열 숫자와 나란히 못 놓는다. BOX_TOL, SCORE_TOL, THR = 2.0, 0.05, 0.30 +# 마스크 축 — `paste_mask` **전** 로짓의 상대 L1. 텐서 축이 이미 쓰는 수라 새 기준을 +# 만들지 않는다. ⚠️ **이진 마스크 IoU 는 쓰지 않는다** — 0.5 이진화는 절벽이라 fp16 +# 타이 플립이 그대로 점수에 실린다. +MASK_TOL = 5e-2 +# ⚠️ **게이트는 이 집합뿐이다.** 다른 마스크 계열은 수치만 찍는다 — 모수가 1인 축을 +# 전 계열 문턱으로 올리면, 재본 적 없는 계열을 측정 없이 재분류하게 된다. +# 수치가 모이면 **별도 이슈로** 전체 게이트를 켤지 판단한다. +MASK_GATE = {"groie"} + def run(cmd, cwd, env_extra=None, timeout=3600): env = dict(os.environ, OMP_NUM_THREADS="1") @@ -143,6 +156,37 @@ def last_error(text): print("REF_OK", len(res.scores)) ''' +# 마스크 축 기준값 — **러너가 쓴 그 박스**로 mmdet 의 마스크 갈래를 돌린다. +# +# ⚠️ **mmdet 이 스스로 고른 검출로 재면 안 된다.** 박스가 0.05px 라도 다르면 RoIAlign +# 격자가 달라져 로짓이 통째로 움직인다 — 그러면 마스크 head 가 아니라 **박스 차이**를 +# 재게 된다. 러너가 낸 좌표를 그대로 먹여야 마스크 갈래만 남는다. +# ⚠️ `_mask_forward` 를 부른다. RoI 추출기 선택·shared_head 까지 mmdet 자신의 순서다 — +# 우리가 다시 조립하면 그 조립을 재게 된다(`groie` 는 추출기가 `SumGenericRoiExtractor`). +MREF = r''' +import _stub, sys, numpy as np, torch +cfg, ckpt, size, npy, bx, out = (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 +from mmdet.structures.bbox import bbox2roi +from frcnn_to_pt import _desync_norm +det = init_detector(_desync_norm(cfg), ckpt, device="cpu"); det.eval() +x = np.load(npy) +t = torch.from_numpy(x).permute(2, 0, 1).unsqueeze(0).contiguous() +boxes = np.load(bx) +with torch.no_grad(): + feats = det.extract_feat(t) + rois = bbox2roi([torch.from_numpy(boxes.astype("float32"))]) + mp = det.roi_head._mask_forward(feats, rois)["mask_preds"] +np.save(out, mp.numpy()) +print("MREF_OK", tuple(mp.shape)) +''' + # 전처리 상수는 detector 의 `data_preprocessor` 가 정본이다. 러너와 torch 에 **같은 배열**을 # 주기 위해, 여기서 한 번만 만들어 `.npy`(torch용) 와 `.bin`(러너용, cwhn) 로 내보낸다. PREP = r''' @@ -390,11 +434,21 @@ def _one(fam, size, image, workdir, keep, verbose): # 크래시가 없어 조용히 틀린다. mask head 의 14→28 deconv 가 그 경로다. has_miou = bool(J.get("has_mask_iou")) MO = int(J.get("mask_roi_out", 14)) + # 마스크 축을 재려면 SubC(mask head)를 구워야 한다. head 가 **하나일 때만** 굽는다 — + # HTC·SCNet 은 `mask_head` 가 `ModuleList` 라 통째로 태울 수 없다(`mask_head_single`). + # ⚠️ **여기서 실패해도 박스 판정을 바꾸지 않는다.** 아래 컴파일 루프가 SubC 만 + # 따로 처리하는 이유다 — 마스크는 더 재는 축이지 문턱이 아니다. + want_mask = bool(J.get("has_mask")) and bool(J.get("mask_head_single")) + mask_jobs = [] + if want_mask: + # ⚠️ 배치가 **1 이 아닐 수 있다.** 마스크 추출기가 `GenericRoIExtractor` 면 러너가 + # 레벨 L 개를 배치로 쌓아 넣고 SubC 가 그 안에서 합산한다(`mask_groie_levels`). + MLV = int(J.get("mask_groie_levels") or 0) or 1 + mask_jobs += [("MaskRCNN_SubC", "MaskRCNN_SubC", "out_MaskRCNN_SubC", + f"{MLV},{RC},{MO},{MO}")] 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}")] + mask_jobs += [("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")) @@ -402,7 +456,8 @@ def _one(fam, size, image, workdir, keep, verbose): if has_grid: jobs += [("GridRCNN_SubE", "GridRCNN_SubE", "out_GridRCNN_SubE", f"1,{RC},{GO},{GO}")] - for src, name, outdir, shape in jobs: + def compile_sub(src, name, outdir, shape): + """서브그래프 하나를 굽는다. 성공하면 None, 실패하면 사유 문자열.""" try: os.remove(os.path.join(fr, outdir, f"{name}.gguf")) # 위와 같은 이유 except OSError: @@ -413,13 +468,38 @@ def _one(fam, size, image, workdir, keep, verbose): 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 + return f"{src}: " + last_error(r.stderr)[:100] + return None + + for src, name, outdir, shape in jobs: + why = compile_sub(src, name, outdir, shape) + if why: + return fam, "COMPILE_FAIL", why, None + + # 마스크 갈래는 **박스 판정과 분리한다.** 마스크 축만 끄고 박스는 그대로 잰다 — + # 마스크가 없다고 이미 맞은 박스를 못 쓰게 만들 이유가 없다. + # ⚠️ 단, 끈 사실을 **말한다.** 조용히 끄면 다음 사람이 "쟀는데 통과했다" 로 읽는다. + # ⚠️ **Mask Scoring R-CNN 은 예외다.** 거기서는 마스크가 더 재는 축이 아니라 + # **점수를 정하는 경로**다(`score *= mask_iou`) — SubC·SubD 중 하나만 없어도 + # 박스 점수가 틀리므로 예전처럼 COMPILE_FAIL 이어야 한다. + mask_off = None + for src, name, outdir, shape in mask_jobs: + why = compile_sub(src, name, outdir, shape) + if not why: + continue + if has_miou: + return fam, "COMPILE_FAIL", why, None + mask_off = why + want_mask = False + break # ③ 러너 빌드 — 빌드 라인은 build_frcnn_cpp.sh / verify_heads.py 와 같아야 한다. import shutil incs = [("FRCNN_SubA", "incA"), (subs[0], "incB")] + if want_mask: + incs += [("MaskRCNN_SubC", "incC")] if has_miou: - incs += [("MaskRCNN_SubC", "incC"), ("MSRCNN_SubD", "incD")] + incs += [("MSRCNN_SubD", "incD")] if has_grid: incs += [("GridRCNN_SubE", "incE")] for name, inc in incs: @@ -427,11 +507,12 @@ def _one(fam, size, image, workdir, keep, verbose): shutil.copy(os.path.join(fr, "out_" + name, name + ".h"), os.path.join(fr, inc, "visp", "arch")) extra = [] + if want_mask: + extra += ["-DARCH_C=MaskRCNN_SubC", + '-DVISP_ARCH_HEADER_C="visp/arch/MaskRCNN_SubC.h"', "-IincC"] if has_miou: - extra += ["-DARCH_C=MaskRCNN_SubC", "-DARCH_D=MSRCNN_SubD", - '-DVISP_ARCH_HEADER_C="visp/arch/MaskRCNN_SubC.h"', - '-DVISP_ARCH_HEADER_D="visp/arch/MSRCNN_SubD.h"', - "-IincC", "-IincD"] + extra += ["-DARCH_D=MSRCNN_SubD", + '-DVISP_ARCH_HEADER_D="visp/arch/MSRCNN_SubD.h"', "-IincD"] if has_grid: extra += ["-DARCH_E=GridRCNN_SubE", '-DVISP_ARCH_HEADER_E="visp/arch/GridRCNN_SubE.h"', "-IincE"] @@ -440,10 +521,10 @@ def _one(fam, size, image, workdir, keep, verbose): 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", + V + "/tools/verify/mmdet/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_MaskRCNN_SubC/MaskRCNN_SubC.cpp"] if want_mask else []) + ( + ["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) @@ -464,18 +545,20 @@ def _one(fam, size, image, workdir, keep, verbose): 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"] + # 자리 인자다 — 7=SubC · 8=SubD · 9=SubE. 없는 자리는 빈 문자열로 채운다. + if want_mask or has_miou or has_grid: + argv.append("out_MaskRCNN_SubC/MaskRCNN_SubC.gguf" if want_mask else "") + if has_miou or has_grid: + argv.append("out_MSRCNN_SubD/MSRCNN_SubD.gguf" if has_miou else "") 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] + got_all = np.fromfile(pref + ".boxes.bin", dtype="float32").reshape(-1, 6) + # ⚠️ 마스크 로짓은 **거르기 전** 검출 전부에 대해 나온다 — 러너가 그 순서로 돌렸다. + # 거른 뒤 배열로 짝지으면 행이 밀린다. + got = got_all[got_all[:, 4] >= THR] # ⑥ mmdet 기준값 open(os.path.join(d, "ref.py"), "w").write(REF % {"FE": FE}) @@ -502,6 +585,17 @@ def _one(fam, size, image, workdir, keep, verbose): # 안 적으면 나중에 0건이 "대상이 못 한다" 인지 "안 맞는 사진을 넣었다" 인지 못 가른다. if os.path.basename(image) != os.path.basename(default_image): note += f" · {os.path.basename(image)}" + # ── ⑦ 마스크 축 — `paste_mask` 전 로짓의 상대 L1 ───────────────────────── + # ⚠️ **박스 판정과 섞지 않는다.** 게이트는 `MASK_GATE` 뿐이고, 나머지 계열은 + # 수치만 남긴다. 못 잰 계열에 「통과」도 「실패」도 적지 않는다. + if mask_off: + note += f" · 마스크 못 잼({mask_off[:40]})" + elif want_mask: + m_note, m_ok = _mask_axis(fam, d, fr, pref, cfg, ckpt, size, npy, got_all, prop_env) + note += " · " + m_note + if fam in MASK_GATE and not m_ok: + ok = False + 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}" @@ -511,6 +605,99 @@ def _one(fam, size, image, workdir, keep, verbose): return fam, ("PASS" if ok else "FAIL"), note, dt +def _mask_axis(fam, d, fr, pref, cfg, ckpt, size, npy, got_all, prop_env): + """마스크 로짓을 mmdet 과 대조한다. `(비고 문자열, 통과 여부)`. + + ⚠️ **못 잰 것을 실패로 적지 않는다.** 로짓 파일이 없거나 기준값이 안 나오면 + 「못 잼」이라고 쓰고 `True` 를 돌려준다 — 게이트 계열이라도 측정 실패를 + 대상의 실패로 바꾸지 않는다. 게이트는 **수치가 나왔는데 넘었을 때** 걸린다. + """ + import numpy as np + lg, dims_p = pref + ".mlogit.bin", pref + ".mlogit.dims.bin" + if not (os.path.exists(lg) and os.path.exists(dims_p)): + return "마스크 못 잼(로짓 없음)", True + dims = np.fromfile(dims_p, dtype="float32") + if dims.size < 4: + return "마스크 못 잼(축 미상)", True + MD, NCLS, MH, MW = (int(v) for v in dims[:4]) + if MD <= 0: + return "마스크 0건", True + + got = np.fromfile(lg, dtype="float32") + want_n = MD * NCLS * MH * MW + if got.size != want_n: + return f"마스크 못 잼(원소 {got.size} != {want_n})", True + # 러너는 행마다 **cwhn** 으로 낸다: ((y*MW + x) * NCLS + k). torch 는 NCHW 다. + got = got.reshape(MD, MH, MW, NCLS).transpose(0, 3, 1, 2) + + bx = os.path.join(d, "mboxes.npy") + np.save(bx, got_all[:MD, :4].astype("float32")) + open(os.path.join(d, "mref.py"), "w").write(MREF % {"FE": FE}) + refp = os.path.join(d, "mref.npy") + q = run([PY, "mref.py", cfg, ckpt, str(size), npy, bx, refp], d, + {"PYTHONPATH": f"{d}:{FE}", **prop_env}) + if "MREF_OK" not in (q.stdout or ""): + return "마스크 못 잼(기준값: " + last_error(q.stderr)[:40] + ")", True + ref = np.load(refp) + if ref.shape != got.shape: + return f"마스크 못 잼(모양 {tuple(ref.shape)} vs {tuple(got.shape)})", True + + den = float(np.abs(ref).sum()) + if den <= 0: + return "마스크 못 잼(기준값이 0)", True + rel = float(np.abs(ref - got).sum()) / den + # 이진 IoU 는 **적되 판정에 쓰지 않는다** — 0.5 이진화는 절벽이라 fp16 타이 플립이 + # 그대로 점수에 실린다. 눈으로 볼 때만 쓴다. + a, b = ref > 0, got > 0 + inter, uni = float((a & b).sum()), float((a | b).sum()) + iou = inter / uni if uni else 1.0 + tag = "PASS" if rel < MASK_TOL else "FAIL" + if fam not in MASK_GATE: + tag = "수치만" # 게이트가 아닌 계열은 판정하지 않는다 + return f"마스크 rel L1 {rel:.2e} (IoU {iou:.3f}, {MD}건) {tag}", rel < MASK_TOL + + +# 계열 하나의 피크 RSS 는 **3.26GB** 다(실측 2026-08-25, 60초 샘플링 — 최대 기여는 +# `ref.py` 의 `init_detector` + 800px 추론). WSL 은 `.wslconfig` 로 13GB 상한이다. +# 그래서 워커 2가 안전선이고 3이 상한이다 — 그 이상은 swap 으로 밀려 **더 느려진다.** +# ⚠️ **OOM 을 내면 WSL 이 통째로 죽는다.** 이 저장소는 이미 겪었다(2026-08-10: +# OOM 0건인데 크래시 덤프 626회로 C: 가 찼다). `.wslconfig` 의 `maxCrashDumpCount=0` +# 이 그 대응이니 되돌리지 마라. +WORKER_CAP = 3 +PER_FAM_MB = 3400 # 계열당 피크 RSS(실측 3.26GB)에 여유를 붙인 값 + + +def _avail_mb(): + """가용 메모리(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 _safe_workers(want): + """요청한 워커 수를 **가용 메모리로 다시 깎는다.** 플래그를 그대로 믿지 않는다. + + ⚠️ 왜 플래그를 안 믿나 — 이 기계는 세션이 여럿 떠 있고 그때그때 여유가 다르다. + `--workers 3` 이 어제 됐다고 오늘 되는 게 아니다. **재서 정한다.** + """ + want = max(1, int(want)) + if want > WORKER_CAP: + print(f" ⚠️ --workers {want} 는 상한 {WORKER_CAP} 을 넘는다 — {WORKER_CAP} 로 낮춘다." + f" 계열당 피크 RSS 가 {PER_FAM_MB}MB 다(실측).", flush=True) + want = WORKER_CAP + avail = _avail_mb() + fit = max(1, avail // PER_FAM_MB) + if fit < want: + print(f" ⚠️ 가용 메모리 {avail}MB → 워커 {want} → **{fit}** 로 낮춘다" + f" (계열당 {PER_FAM_MB}MB).", flush=True) + return fit + return want + + def two_stage_families(): """config 로 판정한다 — 이름으로 짐작하지 않는다. roi_head 가 있으면 two-stage. @@ -556,6 +743,9 @@ def main(): # 수정이 특정 조건에서만 도는 코드면(예: `NS>1`·`RSF>0`) 영향 계열만 골라 재라. ap.add_argument("--skip-pass", metavar="results.json", help="이전 결과에서 PASS 였던 계열은 건너뛴다") + ap.add_argument("--workers", type=int, default=1, + help=f"동시 실행 계열 수(기본 1, 상한 {WORKER_CAP}). " + "가용 메모리를 읽어 더 낮출 수 있다") ap.add_argument("-v", "--verbose", action="store_true") a = ap.parse_args() @@ -587,21 +777,60 @@ def main(): # 배너가 거짓말하지 않게 — 계열별 지정이 끼어들 수 있으면 그렇다고 적는다. 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}" + # ⚠️ **머리글이 실제 측정 조건을 말해야 한다.** `a.size` 만 찍으면 계열별 크기 + # 표(`MF._SIZE`)로 올라간 계열이 로그에 거짓으로 적힌다 — `fpg` 를 1024 로 + # 재고도 `size=800` 이라고 찍혔다(2026-09-01). 합계는 입력 크기와 같이 묶는다. + over = {f: MF.test_size(f, a.size) for f in fams if MF.test_size(f, a.size) != a.size} + size_note = f"{a.size}" + ( + " (계열별: " + ", ".join(f"{f}={v}" for f, v in sorted(over.items())) + ")" + if over else "") + print(f"size={size_note} · 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) + nw = _safe_workers(a.workers) + if nw > 1: + print(f"워커 {nw}개로 돈다 (가용 {_avail_mb()}MB · 계열당 {PER_FAM_MB}MB 가정)\n", + flush=True) + + def _one_guarded(fam): + """한 계열. **판정 로직은 순차와 완전히 같다** — 감싸기만 한다.""" try: - row = one(fam, a.size, a.image, a.workdir, a.keep, a.verbose) + # 계열별 크기 표를 따른다 — `fpg` 는 800 에서 안 나눠떨어진다 + return one(fam, MF.test_size(fam, a.size), a.image, + a.workdir, a.keep, a.verbose) except subprocess.TimeoutExpired: - row = (fam, "TIMEOUT", "-", None) + return (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) + return (fam, "HARNESS_FAIL", f"{type(e).__name__}: {e}"[:110], None) + + res = [] + # ⚠️ **계열마다 작업 폴더가 따로다**(`workdir/`) — 겹치는 파일이 없어야 + # 병렬이 안전하다. `one()` 이 이미 그렇게 짜여 있다(정리도 그 폴더 단위). + # 여기서 `workdir` 하나를 공유하는 파일은 `results.json` 뿐이고, 그건 부모만 쓴다. + if nw <= 1: + for i, fam in enumerate(fams, 1): + print(f"[{i}/{len(fams)}] {fam} …", flush=True) + row = _one_guarded(fam) + 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) + else: + import concurrent.futures as _fut + with _fut.ThreadPoolExecutor(max_workers=nw) as ex: + futs = {ex.submit(_one_guarded, f): f for f in fams} + for i, fu in enumerate(_fut.as_completed(futs), 1): + row = fu.result() + res.append(row) + print(f"[{i}/{len(fams)}] {row[0]:22s} {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) + # ⚠️ **완료 순서로 오므로 다시 정렬한다.** 안 하면 실행마다 줄 순서가 달라져 + # 두 실행을 `diff` 로 못 댄다 — 회귀를 눈으로 보는 길이 막힌다. + res.sort(key=lambda r: fams.index(r[0])) with open(os.path.join(a.workdir, "results.json"), "w") as f: json.dump(res, f, indent=1, ensure_ascii=False) diff --git a/tools/verify/tracking/run_bytetrack_verify.cpp b/tools/verify/mmdet/tracking/run_bytetrack_verify.cpp similarity index 100% rename from tools/verify/tracking/run_bytetrack_verify.cpp rename to tools/verify/mmdet/tracking/run_bytetrack_verify.cpp diff --git a/tools/verify/mmpose/verify_pose.py b/tools/verify/mmpose/verify_pose.py new file mode 100644 index 0000000..357811a --- /dev/null +++ b/tools/verify/mmpose/verify_pose.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +"""mmpose 계열을 g2c 로 컴파일해 C++(ggml) 출력이 PyTorch 와 같은지 계열별로 잰다. + +`tools/verify/mmseg/verify_seg.py` 와 같은 자로 잰다 — 앵커도 NMS 도 없으니 +`backbone → (neck) → head.forward` 를 한 그래프로 굽고 **출력 텐서를 그대로 대조**한다. +디코드(히트맵 argmax·소수점 보정, SimCC 의 1D argmax)는 후처리라 그래프 밖이다. + + python verify_pose.py # metafile 에 있는 계열 전부 + python verify_pose.py hrnet # 골라서 + python verify_pose.py --list # 목록만 + +⚠️ **입력이 정방이 아니다.** topdown 은 사람 박스를 `codec.input_size`(대개 192x256, WxH)로 + 잘라 넣는다. 정방으로 재면 히트맵 크기가 config 와 달라져 **다른 것을 재게 된다.** +""" +import argparse +import glob +import os +import subprocess +import sys + +sys.stdout.reconfigure(line_buffering=True) + +HERE = os.path.dirname(os.path.abspath(__file__)) +V = os.path.abspath(os.path.join(HERE, "..", "..", "..")) +P = os.path.abspath(os.path.join(V, "..")) +FE = V + "/tools/frontend/mmpose" +FE_DET = V + "/tools/frontend/mmdet" +GGUF_PY = V + "/depend/llama/gguf-py" +PY = sys.executable + +MM = os.path.expanduser("~/mmbuild/mmpose") +CKPT = MM + "/checkpoints" +WORKDIR = "/tmp/visp-pose-verify" +OPT = "-O1" +L1_TOL = L2_TOL = 0.05 + + +def families(): + """`configs/**/metafile.yml` 에서 (계열, config, weights URL). **손으로 안 적는다.** + + mmpose 의 metafile 은 `configs//<...>//*.yml` 로 깊이가 들쭉날쭉하다 → + 계열 이름은 **metafile 이 있는 디렉터리 이름**으로 잡고, 같은 이름이 여럿이면 첫 것만. + """ + import yaml + out, seen = [], set() + for p in sorted(glob.glob(MM + "/configs/**/*.yml", recursive=True)): + try: + d = yaml.safe_load(open(p)) or {} + except Exception: + continue + models = d.get("Models") or [] + if not models: + continue + fam = os.path.basename(os.path.dirname(p)) + if fam in seen: + continue + m = models[0] + cfg = m.get("Config") or "" + if not cfg: + continue + cfg = cfg if cfg.startswith("/") else MM + "/" + cfg + seen.add(fam) + out.append((fam, cfg, m.get("Weights", ""))) + return out + + +# ⚠️ **메모리 가드.** 위키 `wsl-계속-터짐` 5번째 원인 — 폭주한 프로세스가 없어도 **합**이 +# 넘치면 WSL 이 통째로 죽는다(2026-08-27 실측: 내 스윕 3.6GB + 다른 python 2.25GB + +# VS Code 3.4GB + claude 세션 5개 1.5GB = 13GB 상한 초과 → OOM 8건). +# 가용 메모리가 이 밑이면 **새 계열을 안 띄우고 기다린다.** 느려질지언정 안 죽는다. +MIN_FREE_MB = int(os.environ.get("VISP_MIN_FREE_MB", "2500")) +# 한 서브프로세스가 이 이상 잡으면 **그놈만** 죽는다(VM 이 아니라). 위키의 인덱서 대책과 같다. +MAX_SUBPROC_GB = int(os.environ.get("VISP_MAX_SUBPROC_GB", "8")) + + +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 # 못 읽으면 가드를 끈다(측정 실패로 막지 않는다) + + +_MEM_GATE = __import__("threading").Lock() + + +def _wait_for_memory(fam): + """가용 메모리가 회복될 때까지 기다린다. + + ⚠️ **락으로 감싼다.** 워커가 여럿이면 둘이 동시에 검사를 통과한 뒤 **둘 다** 할당해 + 가드가 무의미해진다(`verify_seg.py` 와 같은 이유·같은 수법). 한 번에 하나만 + 문을 지나게 하고, 지난 뒤엔 바로 놓는다 — 할당은 서브프로세스가 하므로 + 그때까지 붙들 필요가 없다. + + ⚠️ 이 게이트는 `/proc/meminfo` 의 **시스템 전역** 값을 본다. 그래서 mmseg 스윕과 + mmpose 스윕을 **동시에 돌려도 서로를 자동으로 throttle 한다** — 두 프로세스가 + 서로를 몰라도 된다. 동시 운용할 때는 `VISP_MIN_FREE_MB` 를 올려 잡는다. + """ + import time + waited = 0 + with _MEM_GATE: + 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분을 기다려도 안 풀리면 그냥 간다(교착 방지) + print(f" … 10분 대기 후에도 부족 — 그대로 진행한다 ({fam})", flush=True) + break + + +def run(cmd, cwd=None, env=None, timeout=1800): + e = dict(os.environ) + e.setdefault("OMP_NUM_THREADS", "1") + # glibc 이 스레드마다 arena 를 잡아 RSS 가 부푼다. torch 서브프로세스에서 크게 온다. + e.setdefault("MALLOC_ARENA_MAX", "2") + if env: + e.update(env) + + def _limit(): + # ⚠️ **주소공간 상한.** 폭주하면 이 프로세스만 MemoryError 로 죽고 VM 은 산다. + # 위키 `wsl-계속-터짐` 의 인덱서 대책(`ulimit -v`)과 같은 수법이다. + import resource + n = MAX_SUBPROC_GB * (1 << 30) + try: + resource.setrlimit(resource.RLIMIT_AS, (n, n)) + except (ValueError, OSError): + pass + + try: + return subprocess.run(cmd, cwd=cwd, env=e, capture_output=True, + text=True, timeout=timeout, preexec_fn=_limit) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 124, "", "timeout") + + +def _last_error(text): + lines = [l.strip() for l in (text or "").splitlines() if l.strip()] + for l in reversed(lines): + if "Error" in l or "error" in l or "assert" in l.lower(): + return l + return lines[-1] if lines else "" + + +REF = r''' +import os, sys, numpy as np, torch +sys.path.insert(0, "%(FE)s"); sys.path.insert(0, "%(FE_DET)s") +import mmpose_wrap +H, W = mmpose_wrap.input_size("%(CFG)s") +m, shapes = mmpose_wrap.build("%(CFG)s", "%(CK)s", (H, W)) +torch.save(m, "pose.pt") +open("size.txt", "w").write(f"{W} {H}") +# 고정 시드 — 시드 없이 뽑으면 실행마다 숫자가 흔들려 회귀 대조가 흐려진다. +g = torch.Generator().manual_seed(0) +x = torch.randn(1, 3, H, W, generator=g) +with torch.no_grad(): + outs = m(x) +if isinstance(outs, torch.Tensor): + outs = (outs,) +sh = [] +for i, t in enumerate(outs): + a = t[0].detach().numpy().astype("float32") + np.ascontiguousarray(a).tofile("ref.out.%%d.bin" %% i) + sh.append(list(a.shape)) +open("ref.shapes.txt", "w").write("\n".join(" ".join(map(str, s)) for s in sh)) +np.ascontiguousarray(x[0].numpy().transpose(1, 2, 0)).tofile("in.bin") # 러너 입력(cwhn) +print("REF_OK", len(outs), W, H) +''' + + +def verify(fam, cfg, weights): + import shutil + d = os.path.join(WORKDIR, fam) + os.makedirs(d, exist_ok=True) + ck = os.path.join(CKPT, os.path.basename(weights)) if weights else "" + if not ck or not os.path.exists(ck): + return fam, "CKPT_NONE", "체크포인트 미다운로드 (--fetch 로 받는다)" + if not os.path.exists(cfg): + return fam, "CONFIG_NONE", os.path.basename(cfg) + + _wait_for_memory(fam) + for stale in ("pose.pt", "ref.shapes.txt", "size.txt"): + try: + os.remove(os.path.join(d, stale)) + except FileNotFoundError: + pass + + open(os.path.join(d, "ref.py"), "w").write( + REF % {"FE": FE, "FE_DET": FE_DET, "CFG": cfg, "CK": ck}) + r = run([PY, "ref.py"], d, {"PYTHONPATH": f"{FE}:{FE_DET}"}) + if not os.path.exists(os.path.join(d, "ref.shapes.txt")): + return fam, "REF_FAIL", _last_error(r.stderr)[:70] + W, H = [int(v) for v in open(os.path.join(d, "size.txt")).read().split()] + + gen = os.path.join(d, "out") + r = run([PY, "-c", ''' +import sys +sys.argv = ["g2c","--model","pose.pt","--name","Pose","--output","out","--input-shape","1,3,%d,%d"] +from shared.compile.pipeline import main; main() +''' % (H, W)], d, {"PYTHONPATH": f"{FE}:{FE_DET}:{P}:{GGUF_PY}"}) + if not os.path.exists(os.path.join(gen, "Pose.gguf")): + return fam, "COMPILE_FAIL", _last_error(r.stderr)[:70] + n_unhandled = open(os.path.join(gen, "Pose.cpp")).read().count("unhandled op") + + inc = os.path.join(gen, "inc", "visp", "arch") + os.makedirs(inc, exist_ok=True) + shutil.copy(os.path.join(gen, "Pose.h"), inc) + b = run(["g++", "-std=c++20", OPT, "-DARCH=Pose", + '-DVISP_ARCH_HEADER="visp/arch/Pose.h"', + "-I" + os.path.join(gen, "inc"), "-I" + V + "/src", "-I" + V + "/include", + "-I" + V + "/depend/llama/ggml/include", + V + "/tools/verify/common/run_dump.cpp", os.path.join(gen, "Pose.cpp"), + "-L" + V + "/build/lib", "-lvisioncpp", "-lggml", "-lggml-base", "-lggml-cpu", + "-Wl,-rpath," + V + "/build/lib", "-o", os.path.join(gen, "run_dump")], d) + if not os.path.exists(os.path.join(gen, "run_dump")): + return fam, "BUILD_FAIL", _last_error(b.stderr)[:70] + + for f in os.listdir(d): + if f.startswith("cpp.out.") and f.endswith(".bin"): + os.remove(os.path.join(d, f)) + x = run([os.path.join(gen, "run_dump"), os.path.join(gen, "Pose.gguf"), + os.path.join(d, "in.bin"), os.path.join(d, "cpp"), str(W), str(H)], d) + + import numpy as np + shapes = [[int(v) for v in ln.split()] + for ln in open(os.path.join(d, "ref.shapes.txt")).read().splitlines() + if ln.strip()] + worst_l1 = worst_l2 = 0.0 + for i, sh in enumerate(shapes): + 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", (_last_error(x.stderr) or f"러너가 out_{i} 를 안 냈다")[:70] + a = np.fromfile(pr, dtype="float32") + c = np.fromfile(pc, dtype="float32") + if a.size != c.size: + return fam, "SHAPE_MISMATCH", f"out_{i}: torch {a.size} vs 러너 {c.size}" + # torch CHW ↔ 러너 HWC. 안 맞추면 값이 아니라 배치가 어긋난다. + if len(sh) == 3: + ch, h, w = sh + c = c.reshape(h, w, ch).transpose(2, 0, 1).reshape(-1) + den = max(float(np.abs(a).sum()), 1e-9) + worst_l1 = max(worst_l1, float(np.abs(a - c).sum()) / den) + worst_l2 = max(worst_l2, float(np.linalg.norm(a - c)) + / max(float(np.linalg.norm(a)), 1e-9)) + ok = worst_l1 < L1_TOL and worst_l2 < L2_TOL + note = f"L1 {worst_l1:.2e} · L2 {worst_l2:.2e} · {W}x{H} · 출력 {len(shapes)}" + if n_unhandled: + note += f" · ⚠ unhandled op {n_unhandled}" + return fam, "PASS" if ok else "FAIL", note + + +def fetch(sel): + import urllib.request + os.makedirs(CKPT, exist_ok=True) + for fam, _cfg, w in sel: + if not w: + continue + dst = os.path.join(CKPT, os.path.basename(w)) + if os.path.exists(dst): + continue + print(f" 받는 중 {fam}: {os.path.basename(w)}", flush=True) + try: + urllib.request.urlretrieve(w, dst + ".part") + os.replace(dst + ".part", dst) + except Exception as e: + print(f" 실패 {fam}: {type(e).__name__}: {e}", flush=True) + + +def main(): + ap = argparse.ArgumentParser(prog="verify_pose") + ap.add_argument("families", nargs="*") + ap.add_argument("--list", action="store_true") + ap.add_argument("--fetch", action="store_true") + ap.add_argument("--workdir", default=None) + a = ap.parse_args() + global WORKDIR + if a.workdir: + WORKDIR = a.workdir + + all_fams = families() + sel = [f for f in all_fams if not a.families or f[0] in a.families] + if a.fetch: + fetch(sel) + if a.list: + for fam, cfg, w in all_fams: + has = os.path.exists(os.path.join(CKPT, os.path.basename(w))) if w else False + print(f"{'O' if has else '-'} {fam:24s} {os.path.basename(cfg)}") + print(f"\n계열 {len(all_fams)}") + return + + print(f"mmpose={MM} g2c={P} workdir={WORKDIR} tol=L1{L1_TOL}/L2{L2_TOL}") + print(f"{'계열':<26}{'판정':<14}비고") + print("-" * 92) + rows = [] + for i, (fam, cfg, w) in enumerate(sel, 1): + fam, verdict, note = verify(fam, cfg, w) + mark = "O" if verdict == "PASS" else ("X" if verdict == "FAIL" else "-") + print(f"[{i:3d}/{len(sel)}] {fam:<22} {mark} {verdict:<14} {note}") + rows.append((fam, verdict)) + print(f"\nPASS {sum(1 for _f, v in rows if v == 'PASS')}/{len(rows)}") + + +if __name__ == "__main__": + main() diff --git a/tools/verify/mmseg/mmseg_families.py b/tools/verify/mmseg/mmseg_families.py new file mode 100644 index 0000000..600e38b --- /dev/null +++ b/tools/verify/mmseg/mmseg_families.py @@ -0,0 +1,38 @@ +"""mmseg_families.py — 계열 → (대표 config, 체크포인트 URL) 의 **예외 목록**. + +기본은 `configs/<계열>/metafile.yaml` 의 첫 모델이다(`verify_seg.families()`). +손목록을 정본으로 쓰면 거기 없는 계열이 존재하지 않는 것처럼 보이므로 **열거는 그대로 +metafile 이 하고, 여기서는 틀린 줄만 덮어쓴다.** mmdet 쪽 +`tools/verify/mmdet/dense_head/mmdet_families.py` 의 `_OVERRIDE_LIST` 와 같은 구조다. + +⚠️ **저장소(`~/mmbuild/mmsegmentation`)의 metafile 을 직접 고치지 마라.** vendor 트리라 + 재클론하면 날아가고 왜 고쳤는지도 안 남는다. 예외는 전부 이 파일에 적는다. +""" + +# (계열, config 상대경로, 체크포인트 URL 또는 "" = metafile 것을 그대로 쓴다) +_OVERRIDE_LIST = [ + # ⚠️ metafile 오타. `configs/emanet/metafile.yaml:22` 가 + # `eemanet_r50-d8_4xb2-80k_cityscapes-512x1024.py`(맨 앞 `e` 가 둘)를 가리킨다. + # 실제 파일은 `emanet_r50-...` 로 **존재한다** — 「범위 밖」이 아니라 오타 하나로 + # `CONFIG_NONE` 이 되던 것이다(2026-08-31 확인, mmsegmentation main). + # 체크포인트 URL 은 metafile 것이 맞으므로 덮어쓰지 않는다. + ("emanet", "emanet/emanet_r50-d8_4xb2-80k_cityscapes-512x1024.py", ""), +] + +OVERRIDE = {f: (c, w) for f, c, w in _OVERRIDE_LIST} + + +def apply(fam, cfg, weights, configs_root): + """metafile 이 준 (cfg, weights) 에 예외를 얹어 돌려준다. + + `configs_root` 는 `/configs`. 예외가 없으면 받은 것을 그대로 낸다. + """ + import os + if fam not in OVERRIDE: + return cfg, weights + c, w = OVERRIDE[fam] + if c: + cfg = os.path.join(configs_root, c) + if w: + weights = w + return cfg, weights diff --git a/tools/verify/mmseg/probe_seg.py b/tools/verify/mmseg/probe_seg.py new file mode 100644 index 0000000..16c8ddc --- /dev/null +++ b/tools/verify/mmseg/probe_seg.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""mmseg 한 계열을 **층별로 갈라** 컴파일한다 — 값이 어디서 벌어지는지 찾는 도구. + +`verify_seg.py` 는 최종 출력 하나만 대조해 PASS/FAIL 을 낸다. FAIL 이 났을 때 +**어느 층에서 벌어졌는지**는 안 알려준다. 이 도구는 백본 4단계와 head 내부 단계를 +**한 그래프의 출력으로 같이 내보내** 단계마다 torch 와 댄다. + + python probe_seg.py fcn --stage backbone # 백본 4단계 + python probe_seg.py fcn --stage head # head 입력 + 내부 + cls_seg + python probe_seg.py fcn --stage final # 최종 seg_logits (verify_seg 와 같다) + +계열·체크포인트·입력 크기는 `verify_seg.py` 와 **같은 곳에서** 온다(metafile · crop_size). +따로 적어 두면 갈린다. + +⚠️ **head 를 두 번 부르지 마라.** 중간값을 꺼내려고 `h.convs(x)` 로 펼쳐 부른 뒤 + `h(f)` 도 부르면 같은 서브모듈이 그래프에 **두 번** 들어가 이름이 갈리고 + (`Sequential[decode_head]` vs `FCNHead[decode_head]`) 가중치 바인딩이 어긋난다. + 2026-08-27 에 그것 때문에 rel L1 1.94 라는 **가짜 실패**를 만들었다. + 아래 `_head_stages` 는 head.forward 를 그대로 펼친 것이고, 끝에서 다시 부르지 않는다. + +⚠️ **랜덤 초기화로 재지 마라** — `verify_seg.py` 와 같은 이유다. 항등 초기값이 + 빠진 연산을 덮는다. 체크포인트가 없으면 그 계열은 돌리지 않는다. + +status: 2026-08-27 `~/work/seg/seg_probe.py`(추적 안 되던 중복본)에서 저장소로 옮기며 + 512² 고정 → 계열별 crop, 손목록 3계열 → metafile 열거로 바꿨다. + **그 두 변경 뒤 아직 안 돌려봤다 — 첫 사용자는 결과를 의심하고 보라.** +""" +import argparse +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +V = os.path.abspath(os.path.join(HERE, "..", "..", "..")) # vision.cpp +P = os.path.abspath(os.path.join(V, "..")) # g2c 루트 +sys.path.insert(0, HERE) + +import verify_seg as VS # noqa: E402 + + +def _head_stages(h, feats): + """decode_head 를 펼쳐 단계별 텐서를 모은다. head 는 **한 번만** 탄다.""" + import torch + xin = feats[h.in_index] if isinstance(h.in_index, int) else \ + torch.cat([feats[i] for i in h.in_index], dim=1) + outs = [xin] # head 입력 (백본 마지막) + if hasattr(h, "psp_modules"): # PSPHead / PSPNet 계열 + # ⚠️ `PPM.forward` 가 pool → conv → **resize(입력 크기로 업샘플)** 까지 한다. + # 가지를 따로 부르면 (1,1)·(2,2)… 인 채로 나와 concat 이 깨진다. + ppm = list(h.psp_modules(xin)) + outs.extend(ppm) + y = h.bottleneck(torch.cat([xin] + ppm, dim=1)) + outs.append(y) + elif hasattr(h, "convs"): # FCNHead 계열 + y = h.convs(xin) + outs.append(y) + if getattr(h, "concat_input", False): + y = h.conv_cat(torch.cat([xin, y], dim=1)) + outs.append(y) + else: + return None # 펼치는 법을 모르는 head + outs.append(h.conv_seg(y)) # dropout 은 eval 에서 항등 + return outs + + +REF = r''' +import os, sys, numpy as np, torch +sys.path.insert(0, "%(FE)s"); sys.path.insert(0, "%(FE_DET)s"); sys.path.insert(0, "%(HERE)s") +import mmseg_wrap +from probe_seg import _head_stages +from mmseg.apis import init_model +import torch.nn as nn + +H, W = mmseg_wrap.crop_size("%(CFG)s") +mmseg_wrap.trace_friendly_ops() +import mmdet_wrap; mmdet_wrap.allow_mmengine_checkpoint_globals() +seg = init_model("%(CFG)s", "%(CK)s", device="cpu"); seg.eval() + + +class Probe(nn.Module): + def __init__(s, seg, stage): + super().__init__() + s.backbone = seg.backbone + s.neck = seg.neck if getattr(seg, "with_neck", False) else None + s.decode_head = seg.decode_head + s.stage = stage + + def forward(s, x): + f = s.backbone(x) + if s.neck is not None: + f = s.neck(f) + if s.stage == "backbone": + return tuple(f) + if s.stage == "final": + o = s.decode_head(f) + return o if isinstance(o, tuple) else (o,) + st = _head_stages(s.decode_head, f) + if st is None: + raise SystemExit("HEAD_UNKNOWN: 이 head 는 펼치는 법을 모른다 — --stage final 로 재라") + return tuple(st) + + +m = Probe(seg, "%(STAGE)s").eval() +torch.save(m, "seg.pt") +open("size.txt", "w").write(f"{W} {H}") +g = torch.Generator().manual_seed(0) # verify_seg 와 같은 고정 입력 +x = torch.randn(1, 3, H, W, generator=g) +with torch.no_grad(): + outs = m(x) +sh = [] +for i, t in enumerate(outs): + a = t[0].detach().numpy() + np.ascontiguousarray(a).tofile(f"ref.out.{i}.bin") + sh.append(list(a.shape)) +np.ascontiguousarray(x[0].numpy().transpose(1, 2, 0)).tofile("in.bin") +open("ref.shapes.txt", "w").write("\n".join(" ".join(map(str, s)) for s in sh)) +print("[ref]", sh, flush=True) +''' + + +def main(): + ap = argparse.ArgumentParser(prog="probe_seg") + ap.add_argument("family") + ap.add_argument("--stage", default="backbone", + choices=["backbone", "head", "final"]) + ap.add_argument("--workdir", default="/tmp/visp-seg-probe") + a = ap.parse_args() + + fams = {f[0]: f for f in VS.families()} + if a.family not in fams: + sys.exit(f"모르는 계열: {a.family} (verify_seg.py --list 로 확인)") + fam, cfg, w = fams[a.family] + ck = os.path.join(VS.CKPT, os.path.basename(w)) if w else "" + if not ck or not os.path.exists(ck): + sys.exit(f"{fam}: 체크포인트가 없다 — 랜덤으로 재면 안 된다 " + f"(verify_seg.py --fetch {fam})") + + d = os.path.join(a.workdir, f"{fam}-{a.stage}") + os.makedirs(d, exist_ok=True) + src = os.path.join(d, "_ref.py") + open(src, "w").write(REF % {"FE": VS.FE, "FE_DET": VS.FE_DET, "HERE": HERE, + "CFG": cfg, "CK": ck, "STAGE": a.stage}) + print(f"{fam} · stage={a.stage} · workdir={d}") + import subprocess + r = subprocess.run([VS.PY, src], cwd=d) + if r.returncode: + sys.exit(r.returncode) + print("\n기준값을 냈다. 컴파일·대조는 verify_seg.py 의 경로를 그대로 쓴다 —\n" + f" {d}/seg.pt · ref.out.*.bin · size.txt") + + +if __name__ == "__main__": + main() diff --git a/tools/verify/mmseg/verify_seg.py b/tools/verify/mmseg/verify_seg.py new file mode 100644 index 0000000..3db955c --- /dev/null +++ b/tools/verify/mmseg/verify_seg.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +"""mmseg 계열을 g2c 로 컴파일해 C++(ggml) 출력이 PyTorch 와 같은지 계열별로 잰다. + +mmdet 하네스(`tools/verify/mmdet/dense_head/verify_heads.py`)와 **재는 방식이 같고 훨씬 짧다** — +세그멘테이션은 앵커도 NMS 도 박스 디코드도 없어서 `backbone → (neck) → decode_head` 를 +한 그래프로 컴파일하고 **출력 텐서를 그대로 대조**하면 끝난다. + +점수는 **상대 L1/L2** 다. cosine 은 쓰지 않는다 — 스케일 불변이라 크기가 통째로 틀려도 +1.0 이 나온다. + + python verify_seg.py # metafile 에 있는 계열 전부 + python verify_seg.py fcn pspnet # 골라서 + python verify_seg.py --list # 목록만 + +⚠️ **랜덤 초기화로 재지 마라.** 항등 초기값(BN γ=1·β=0)이 빠진 연산을 덮는다. + 체크포인트가 없으면 그 계열은 CKPT_NONE 으로 남긴다 — **못 잰 것이지 통과가 아니다.** +""" +import argparse +import os +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import mmseg_families + +sys.stdout.reconfigure(line_buffering=True) + +HERE = os.path.dirname(os.path.abspath(__file__)) +V = os.path.abspath(os.path.join(HERE, "..", "..", "..")) # vision.cpp +P = os.path.abspath(os.path.join(V, "..")) # g2c 루트 +FE = V + "/tools/frontend/mmseg" +FE_DET = V + "/tools/frontend/mmdet" +GGUF_PY = V + "/depend/llama/gguf-py" +PY = sys.executable + +MM = os.path.expanduser("~/mmbuild/mmsegmentation") +CKPT = MM + "/checkpoints" +WORKDIR = "/tmp/visp-seg-verify" +SZ = 0 # 0 = config 의 crop 을 쓴다. `--size` 로만 덮어쓴다 +OPT = "-O1" +# 판정 기준: 출력 텐서의 상대 L1/L2. 저장소 관례(mmdet 하네스)와 같은 값을 쓴다. +L1_TOL = L2_TOL = 0.05 + + +def families(): + """`configs/*/metafile.yaml` 에서 (계열, config, weights URL) 을 읽는다. + + **손으로 적지 않는다** — 목록을 손으로 적으면 거기 없는 계열이 존재하지 않는 것처럼 + 보인다(mmdet 에서 `pisa`·`rpn` 이 실제로 그렇게 몇 주 동안 안 보였다). + 계열마다 metafile 의 **첫 모델**을 대표로 쓴다. + """ + import glob + import yaml + out = [] + for p in sorted(glob.glob(MM + "/configs/*/metafile.y*ml")): + fam = os.path.basename(os.path.dirname(p)) + try: + models = yaml.safe_load(open(p)).get("Models") or [] + except Exception: + continue + if not models: + continue + m = models[0] + cfg = MM + "/" + m["Config"] if not m["Config"].startswith("/") else m["Config"] + w = m.get("Weights", "") + # ⚠️ metafile 이 틀린 줄이 있다(emanet 의 config 이름 오타). vendor 트리를 고치면 + # 재클론에 날아가므로 **예외는 저장소 안 `mmseg_families.py` 에 적고** 여기서 + # 얹는다. 열거 자체는 그대로 metafile 이 한다 — 손목록은 정본이 아니다. + cfg, w = mmseg_families.apply(fam, cfg, w, MM + "/configs") + out.append((fam, cfg, w)) + return out + + +# ⚠️ **메모리 가드.** 위키 `wsl-계속-터짐` 5번째 원인 — 폭주한 프로세스가 없어도 **합**이 +# 넘치면 WSL 이 통째로 죽는다(2026-08-27 실측: 내 스윕 3.6GB + 다른 python 2.25GB + +# VS Code 3.4GB + claude 세션 5개 1.5GB = 13GB 상한 초과 → OOM 8건). +# 가용 메모리가 이 밑이면 **새 계열을 안 띄우고 기다린다.** 느려질지언정 안 죽는다. +MIN_FREE_MB = int(os.environ.get("VISP_MIN_FREE_MB", "2500")) +# 한 서브프로세스가 이 이상 잡으면 **그놈만** 죽는다(VM 이 아니라). 위키의 인덱서 대책과 같다. +MAX_SUBPROC_GB = int(os.environ.get("VISP_MAX_SUBPROC_GB", "8")) + + +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 # 못 읽으면 가드를 끈다(측정 실패로 막지 않는다) + + +_MEM_GATE = __import__("threading").Lock() + + +def _wait_for_memory(fam): + """가용 메모리가 회복될 때까지 기다린다. + + ⚠️ **락으로 감싼다.** 워커가 여럿이면 둘이 동시에 검사를 통과한 뒤 **둘 다** 할당해 + 가드가 무의미해진다. 한 번에 하나만 문을 지나게 하고, 지난 뒤엔 바로 놓는다 + (할당은 서브프로세스가 하므로 그때까지 붙들 필요가 없다). + """ + import time + waited = 0 + with _MEM_GATE: + 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분을 기다려도 안 풀리면 그냥 간다(교착 방지) + print(f" … 10분 대기 후에도 부족 — 그대로 진행한다 ({fam})", flush=True) + break + + +def run(cmd, cwd=None, env=None, timeout=1800): + e = dict(os.environ) + e.setdefault("OMP_NUM_THREADS", "1") + # glibc 이 스레드마다 arena 를 잡아 RSS 가 부푼다. torch 서브프로세스에서 크게 온다. + e.setdefault("MALLOC_ARENA_MAX", "2") + if env: + e.update(env) + + def _limit(): + # ⚠️ **주소공간 상한.** 폭주하면 이 프로세스만 MemoryError 로 죽고 VM 은 산다. + # 위키 `wsl-계속-터짐` 의 인덱서 대책(`ulimit -v`)과 같은 수법이다. + import resource + n = MAX_SUBPROC_GB * (1 << 30) + try: + resource.setrlimit(resource.RLIMIT_AS, (n, n)) + except (ValueError, OSError): + pass + + try: + return subprocess.run(cmd, cwd=cwd, env=e, capture_output=True, + text=True, timeout=timeout, preexec_fn=_limit) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess(cmd, 124, "", "timeout") + + +def _last_error(text): + lines = [l.strip() for l in (text or "").splitlines() if l.strip()] + for l in reversed(lines): + if "Error" in l or "error" in l or "assert" in l.lower(): + return l + return lines[-1] if lines else "" + + +REF = r''' +import os, sys, numpy as np, torch +sys.path.insert(0, "%(FE)s"); sys.path.insert(0, "%(FE_DET)s") +import mmseg_wrap +# ⚠️ **입력 크기를 하나로 고정하지 않는다.** config 의 crop 이 곧 그 모델이 보는 크기다 +# (512x1024 · 640x640 · 680x680 · 1024x1024 …). 고정하면 잰 것이 그 모델이 아니고, +# ViT 계열은 `pos_embed` 토큰 수가 안 맞아 아예 죽는다. +# ⚠️ 기본은 **config 의 crop** 이다. `--size` 는 진단용 덮어쓰기다(크기 탓인지 가를 때). +# 2026-08-27 까지 `--size` 는 설정만 되고 **아무 데도 안 쓰였다** — 헤더에는 512 라 +# 찍히는데 실제로는 crop 으로 쟀다. 거짓말하는 플래그였다. +_ov = "%(SIZE)s" +H, W = ([int(_ov), int(_ov)] if _ov else mmseg_wrap.crop_size("%(CFG)s")) +m, shapes = mmseg_wrap.build("%(CFG)s", "%(CK)s", (H, W)) +torch.save(m, "seg.pt") +open("size.txt", "w").write(f"{W} {H}") +# ⚠️ **고정 입력**을 쓴다. 시드 없이 뽑으면 실행마다 숫자가 1~5%% 흔들려 회귀 대조가 +# 흐려진다(mmdet 의 no-box 계열에서 실제로 겪었다). +g = torch.Generator().manual_seed(0) +x = torch.randn(1, 3, H, W, generator=g) +with torch.no_grad(): + outs = m(x) +if isinstance(outs, torch.Tensor): + outs = (outs,) +sh = [] +for i, t in enumerate(outs): + a = t[0].detach().numpy().astype("float32") + np.ascontiguousarray(a).tofile("ref.out.%%d.bin" %% i) + sh.append(list(a.shape)) +open("ref.shapes.txt", "w").write("\n".join(" ".join(map(str, s)) for s in sh)) +np.ascontiguousarray(x[0].numpy().transpose(1, 2, 0)).tofile("in.bin") # 러너 입력(cwhn) +print("REF_OK", len(outs)) +''' + + + +def _phase(fam, name): + """계열 안의 **단계**를 찍는다. + + 하네스는 원래 계열이 끝나야 한 줄을 냈다. 계열 하나가 10~20분이라 + 그동안 `tail -f` 가 조용해서 「멈춘 것」과 구분이 안 됐다. + `VISP_SEG_PHASE=0` 으로 끌 수 있다(회귀 로그를 옛 판과 줄 단위로 대조할 때). + """ + if os.environ.get("VISP_SEG_PHASE", "1") != "0": + print(f" · {fam}: {name}", flush=True) + + +def verify(fam, cfg, weights): + import shutil + d = os.path.join(WORKDIR, fam) + os.makedirs(d, exist_ok=True) + ck = os.path.join(CKPT, os.path.basename(weights)) if weights else "" + if not ck or not os.path.exists(ck): + return fam, "CKPT_NONE", "체크포인트 미다운로드 (--fetch 로 받는다)" + if not os.path.exists(cfg): + return fam, "CONFIG_NONE", os.path.basename(cfg) + + # ⚠️ **먼저 지운다.** 지난 실행의 산출물이 남아 있으면 export 가 실패해도 아래 존재 + # 검사를 통과해 **낡은 것으로 계속 간다**(mmdet 하네스에서 실제로 겪었다). + _wait_for_memory(fam) + for stale in ("seg.pt", "ref.shapes.txt", "size.txt"): + try: + os.remove(os.path.join(d, stale)) + except FileNotFoundError: + pass + + open(os.path.join(d, "ref.py"), "w").write( + REF % {"SIZE": (str(SZ) if SZ else ""), "FE": FE, "FE_DET": FE_DET, "CFG": cfg, "CK": ck}) + _phase(fam, "ref 생성 (torch 기준값)") + r = run([PY, "ref.py"], d, {"PYTHONPATH": f"{FE}:{FE_DET}"}) + if not os.path.exists(os.path.join(d, "ref.shapes.txt")): + return fam, "REF_FAIL", _last_error(r.stderr)[:70] + W, H = [int(v) for v in open(os.path.join(d, "size.txt")).read().split()] + + _phase(fam, "컴파일 (g2c → .cpp/.gguf)") + gen = os.path.join(d, "out") + r = run([PY, "-c", ''' +import sys +sys.argv = ["g2c","--model","seg.pt","--name","Seg","--output","out","--input-shape","1,3,%d,%d"] +from shared.compile.pipeline import main; main() +''' % (H, W)], d, {"PYTHONPATH": f"{FE}:{FE_DET}:{P}:{GGUF_PY}"}) + if not os.path.exists(os.path.join(gen, "Seg.gguf")): + return fam, "COMPILE_FAIL", _last_error(r.stderr)[:70] + src = open(os.path.join(gen, "Seg.cpp")).read() + n_unhandled = src.count("unhandled op") + + inc = os.path.join(gen, "inc", "visp", "arch") + os.makedirs(inc, exist_ok=True) + shutil.copy(os.path.join(gen, "Seg.h"), inc) + _phase(fam, "빌드 (g++)") + b = run(["g++", "-std=c++20", OPT, "-DARCH=Seg", + '-DVISP_ARCH_HEADER="visp/arch/Seg.h"', + "-I" + os.path.join(gen, "inc"), "-I" + V + "/src", "-I" + V + "/include", + "-I" + V + "/depend/llama/ggml/include", + V + "/tools/verify/common/run_dump.cpp", os.path.join(gen, "Seg.cpp"), + "-L" + V + "/build/lib", "-lvisioncpp", "-lggml", "-lggml-base", "-lggml-cpu", + "-Wl,-rpath," + V + "/build/lib", "-o", os.path.join(gen, "run_dump")], d) + if not os.path.exists(os.path.join(gen, "run_dump")): + return fam, "BUILD_FAIL", _last_error(b.stderr)[:70] + + for f in os.listdir(d): + if f.startswith("cpp.out.") and f.endswith(".bin"): + os.remove(os.path.join(d, f)) + _phase(fam, "실행 (ggml)") + x = run([os.path.join(gen, "run_dump"), os.path.join(gen, "Seg.gguf"), + os.path.join(d, "in.bin"), os.path.join(d, "cpp"), str(W), str(H)], d) + + import numpy as np + shapes = [[int(v) for v in ln.split()] + for ln in open(os.path.join(d, "ref.shapes.txt")).read().splitlines() + if ln.strip()] + worst_l1 = worst_l2 = 0.0 + for i, sh in enumerate(shapes): + 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", (_last_error(x.stderr) or f"러너가 out_{i} 를 안 냈다")[:70] + a = np.fromfile(pr, dtype="float32") + c = np.fromfile(pc, dtype="float32") + if a.size != c.size: + return fam, "SHAPE_MISMATCH", f"out_{i}: torch {a.size} vs 러너 {c.size}" + # ⚠️ **축 순서가 다르다.** torch 는 CHW, 러너(ggml)는 HWC 로 쓴다. 안 맞추면 값이 + # 아니라 배치가 어긋나 rel L1 이 1.4~2.0(= 무관한 두 텐서)으로 나온다. + if len(sh) == 3: + ch, h, w = sh + c = c.reshape(h, w, ch).transpose(2, 0, 1).reshape(-1) + den = max(float(np.abs(a).sum()), 1e-9) + worst_l1 = max(worst_l1, float(np.abs(a - c).sum()) / den) + worst_l2 = max(worst_l2, float(np.linalg.norm(a - c)) + / max(float(np.linalg.norm(a)), 1e-9)) + ok = worst_l1 < L1_TOL and worst_l2 < L2_TOL + note = f"L1 {worst_l1:.2e} · L2 {worst_l2:.2e} · {W}x{H} · 출력 {len(shapes)}" + # ⚠️ unhandled op 이 있으면 **통과해도 통과가 아니다** — 그 연산이 사라진 채 우연히 + # 수치가 맞은 것일 수 있다. 판정 옆에 반드시 남긴다. + if n_unhandled: + note += f" · ⚠ unhandled op {n_unhandled}" + return fam, "PASS" if ok else "FAIL", note + + +def fetch(sel): + import urllib.request + os.makedirs(CKPT, exist_ok=True) + for fam, _cfg, w in sel: + if not w: + continue + dst = os.path.join(CKPT, os.path.basename(w)) + if os.path.exists(dst): + continue + print(f" 받는 중 {fam}: {os.path.basename(w)}", flush=True) + try: + urllib.request.urlretrieve(w, dst + ".part") + os.replace(dst + ".part", dst) + except Exception as e: + print(f" 실패 {fam}: {type(e).__name__}: {e}", flush=True) + + +def main(): + ap = argparse.ArgumentParser(prog="verify_seg") + ap.add_argument("families", nargs="*") + ap.add_argument("--list", action="store_true", help="계열 목록만 찍는다") + ap.add_argument("--fetch", action="store_true", help="없는 체크포인트를 받는다") + ap.add_argument("--size", type=int, default=None) + ap.add_argument("--workdir", default=None) + ap.add_argument("--workers", type=int, default=2, + help="동시에 돌릴 계열 수(기본 2). ⚠️ 계열마다 torch 를 띄운다 — " + "12GB 에서 3을 넘기면 위험하다. 각 서브프로세스는 " + f"`ulimit -v {MAX_SUBPROC_GB}GB` 로 묶여 있어 폭주해도 " + "그 계열만 죽는다.") + a = ap.parse_args() + + global SZ, WORKDIR + SZ = a.size or 0 # 0 = 계열별 crop (기본) + if a.workdir: + WORKDIR = a.workdir + + all_fams = families() + sel = [f for f in all_fams if not a.families or f[0] in a.families] + if a.fetch: + fetch(sel) + if a.list: + for fam, cfg, w in all_fams: + has = os.path.exists(os.path.join(CKPT, os.path.basename(w))) if w else False + print(f"{'O' if has else '-'} {fam:22s} {os.path.basename(cfg)}") + print(f"\n계열 {len(all_fams)} · 체크포인트 보유 " + f"{sum(1 for _f, _c, w in all_fams if w and os.path.exists(os.path.join(CKPT, os.path.basename(w))))}") + return + + print(f"mmseg={MM} g2c={P} " + f"size={'계열별 crop' if not SZ else f'{SZ}² (--size 덮어쓰기)'} " + f"workdir={WORKDIR} tol=L1{L1_TOL}/L2{L2_TOL}") + print(f"{'계열':<24}{'판정':<14}비고") + print("-" * 86) + rows = [] + nw = max(1, int(a.workers)) + if nw == 1 or len(sel) == 1: + for i, (fam, cfg, w) in enumerate(sel, 1): + fam, verdict, note = verify(fam, cfg, w) + mark = "O" if verdict == "PASS" else ("X" if verdict == "FAIL" else "-") + print(f"[{i:3d}/{len(sel)}] {fam:<20} {mark} {verdict:<14} {note}") + rows.append((fam, verdict)) + else: + # 계열끼리 독립이라 병렬로 돌린다. 일은 전부 **서브프로세스**가 하므로 스레드로 + # 충분하다(GIL 은 subprocess 대기 중 풀린다). 계열당 52초 중 추론은 6초뿐이고 + # 나머지는 torch import·컴파일이라, 병렬화가 유일하게 큰 레버다. + # ⚠️ 완료 순서대로 찍는다 — 줄마다 계열 이름이 있어 순서가 섞여도 읽힌다. + from concurrent.futures import ThreadPoolExecutor, as_completed + idx = {f[0]: i for i, f in enumerate(sel, 1)} + done = 0 + with ThreadPoolExecutor(max_workers=nw) as ex: + futs = [ex.submit(verify, f, c, w) for f, c, w in sel] + for fu in as_completed(futs): + fam, verdict, note = fu.result() + mark = "O" if verdict == "PASS" else ("X" if verdict == "FAIL" else "-") + done += 1 + print(f"[{done:3d}/{len(sel)}] {fam:<20} {mark} {verdict:<14} {note}", + flush=True) + rows.append((fam, verdict)) + rows.sort(key=lambda r: idx.get(r[0], 0)) + n_pass = sum(1 for _f, v in rows if v == "PASS") + print(f"\nPASS {n_pass}/{len(rows)}") + + +if __name__ == "__main__": + main()