diff --git a/.github/scripts/create-export-env.py b/.github/scripts/create-export-env.py index e9dd9e954..29d8f3847 100644 --- a/.github/scripts/create-export-env.py +++ b/.github/scripts/create-export-env.py @@ -23,6 +23,23 @@ def isolated_env_ids(): ) +def _public_pypi_command(command): + """Remove the optional NVIDIA index arguments from an install command.""" + skip_next = False + filtered = [] + for token in command: + if skip_next: + skip_next = False + continue + if token in {"--extra-index-url", "--index-strategy"}: + skip_next = True + continue + if token in {"https://pypi.ngc.nvidia.com", "unsafe-best-match"}: + continue + filtered.append(token) + return filtered + + def build_env(env_id, root): """Build one export environment and run its smoke export commands.""" recipe = EXPORT_ENVS[env_id] @@ -35,25 +52,31 @@ def build_env(env_id, root): indexes = [token for flag, url in recipe["indexes"] for token in (flag, url)] index_strategy = ["--index-strategy", "unsafe-best-match"] if indexes else [] torch = [f"torch{recipe['torch']}"] if recipe["torch"] else [] - subprocess.run( - [ - "uv", - "pip", - "install", - "--python", - str(python), - "-e", - package, - "pytest", - *torch, - *recipe["requirements"], - *indexes, - *index_strategy, - "--torch-backend", - "cpu", - ], - check=True, - ) + install_command = [ + "uv", + "pip", + "install", + "--python", + str(python), + "-e", + package, + "pytest", + *torch, + *recipe["requirements"], + *indexes, + *index_strategy, + "--torch-backend", + "cpu", + ] + try: + subprocess.run(install_command, check=True) + except subprocess.CalledProcessError: + # NVIDIA's index is supplemental for TensorFlow/GraphSurgeon. If its DNS + # endpoint is unavailable, retry against public PyPI so CI remains usable. + if "https://pypi.ngc.nvidia.com" not in indexes: + raise + print("Supplemental NVIDIA PyPI index unavailable; retrying with public PyPI", flush=True) + subprocess.run(_public_pypi_command(install_command), check=True) if recipe["env"]: site_packages = next(venv.glob("lib/python*/site-packages")) diff --git a/.gitignore b/.gitignore index 544d5b12d..6f840c7e9 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,21 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +# Local dependency/bootstrap trees used by offline QA; never publish them. +/.qa-deps/ +/.qa-cmake-*/ +/.qa-tmp*/ +/.qa-pytest-*/ +**/.qa-cmake-*/ +**/.qa-tmp*/ +**/.qa-pytest-*/ +/.qa-audit-note.txt +*.qa-tmp/ +/work/pytest-*/ +/.qa-run-*/ +# Ad-hoc local verification directories +/.qa-env-*/ +/qa_tmp_*/ mlruns/ # Translations diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/README.md b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/README.md index b95fd6fb1..56fcc0af7 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/README.md +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/README.md @@ -1,369 +1,287 @@ -# YOLO-Master Cross-Platform Edge Inference Runtime +# YOLO-Master Cross-Platform Edge Deployment + +This example provides a C++17 command-line runtime and a reproducible +validation workflow for Issue #51. The runtime shares one preprocessing, +decoding, NMS, timing, and output contract across ONNX Runtime, NCNN, MNN, +and (when the SDK is installed) TensorRT. + +The repository contains code, schemas, and validation tools. It intentionally +does not contain private checkpoints, datasets, or generated predictions. A +metric or device claim is valid only when its model, image list, raw output, +environment record, and SHA256 manifest are archived together. + +## Scope and current evidence + +The implementation includes: + +- CMake discovery for ONNX Runtime, NCNN, MNN, and optional TensorRT; +- UTF-8-safe model and image paths on Windows; +- model metadata and output-shape validation before decoding; +- deterministic image-list handling with duplicate-stem and root-escape checks; +- ONNX/NCNN/MNN export, parity, mAP, INT8, benchmark, and manifest tools; +- Linux, Windows, macOS, ARM64 cross-build, and Jetson build entry points. + +The validated local smoke record is an Ubuntu 22.04 x86_64 YOLOv5s ONNX +single-image run (six detections, approximately 970.873 ms end to end). This +is an L1 functional check. It is not an EsMoE-N VisDrone/SKU-110K accuracy +result, an INT8 result, or a native ARM64/Jetson measurement. + +## Directory layout + +| Path | Purpose | +| --- | --- | +| `cpp/` | C++17 runtime and CMake build | +| `scripts/export_models.py` | Checked ONNX, NCNN, and MNN conversion | +| `scripts/eval_map.py` | Ultralytics-backed mAP evaluation | +| `scripts/eval_map_standalone.py` | Dependency-light mAP evaluation | +| `scripts/prediction_diff.py` | Per-image and per-box parity diagnostics | +| `scripts/quantize_int8.py` | Training-only INT8 calibration and manifest | +| `scripts/evidence_manifest.py` | Evidence creation, validation, and hash verification | +| `scripts/collect_environment.py` | Reproducible host and SDK record | +| `scripts/package_linux.sh` | Relocatable Linux bundle assembly | +| `environment.schema.json` | Host-record schema | +| `evidence-manifest.schema.json` | Dataset, model, prediction, and report schema | +| `TECHNICAL_REPORT.md` | Full protocol and rationale | +| `TECHNICAL_SUMMARY_ZH.md` | Chinese technical summary | + +## Dependencies + +Required for the image CLI: + +- CMake 3.16 or newer; +- a C++17 compiler; +- OpenCV 4.5 or newer (`core`, `imgproc`, and `videoio` for video input); +- ONNX Runtime for ONNX models. + +NCNN and MNN are optional at configuration time. A complete Issue #51 build +should set `REQUIRE_ORT=ON` and at least one of `REQUIRE_NCNN=ON` or +`REQUIRE_MNN=ON`; missing required SDKs then fail configuration instead of +silently producing a partial binary. + +## Build + +From this directory, configure a minimal image-inference build: -C++ Onnx-runtime NCNN MNN TensorRT Core ML Linux Windows Jetson macOS iOS - -This project provides a cross-platform inference runtime for [YOLO-Master](https://github.com/Tencent/YOLO-Master) object-detection models using [ONNX Runtime](https://onnxruntime.ai/), [NCNN](https://github.com/Tencent/ncnn), [MNN](https://github.com/alibaba/mnn), [TensorRT](https://github.com/nvidia/tensorrt), and [Core ML](https://github.com/apple/coremltools) backends. The supported targets are Linux, Windows 10/11, Jetson, and macOS, with CPU, [NVIDIA CUDA](https://developer.nvidia.com/cuda-toolkit), and [Apple Metal Performance Shaders](https://developer.apple.com/documentation/metalperformanceshaders) execution where available. The runtime can infer the model format and read class names and input size from model metadata. -This project provides a universal inference runtime for [YOLO-Master](https://github.com/Tencent/YOLO-Master) object-detection models, leveraging, [ONNX Runtime](https://onnxruntime.ai/), [NCNN](https://github.com/Tencent/ncnn), [MNN](https://github.com/alibaba/mnn), [TensorRT](https://github.com/nvidia/tensorrt), and [CoreML](https://github.com/apple/coremltools) backends. It runs on almost every platform: Linux, Windows (10/11), Jetson, MacOS, and **iOS (NEW!)**; supports CPU, [CUDA](https://developer.nvidia.com/cuda-toolkit), [MPS](https://developer.apple.com/documentation/metalperformanceshaders), and [ANE (on Apple devices)](https://machinelearning.apple.com). It's capable of auto-detecting the model format, class names, and input size -- designed for real-time, end-to-end edge deployment in some of the most challenging tasks (VisDrone, SKU-110K, AI-TOD-v2, etc.). - -

-  -    -    Edge Deployment Bundle — Architecture -  -

- ---- - -## 📱 Update (27-08-2026): YOLO-Master for iPhone v1.1.0 Beta Build 1 - -screnshots-framed - -
-
- -**🍾 Welcome to the 5th platform of YOLO-Master Edge. [Try it now.](https://testflight.apple.com/join/EVExpVHD)** - -Native iOS SwiftUI app for on-device YOLO-Master detection and segmentation, powered by Apple Core ML and the same YOLOMasterKit inference path as the macOS runtime. Everything runs locally on the Neural Engine, GPU, or CPU. Nothing you capture leaves the iPhone. Requires iPhone on iOS 17 or later. iPhone 13 and later models are recommended. - -### Features - -- **📹 Live** - Real-time camera detection with a dynamic performance visuliaztion (FPS tachometer, per-stage latency measures, and phone's thermal state), multi-cam with automatic lens switching and pinch zoom, tap-to-focus, torch, and a full-resolution shutter that renders the live overlay (boxes and segmentation masks) into the saved photo. Also supports live IoU/conf tuning without pausing the inference. - -- **📸 Photo** - Batch detection over images you pick from your library, up to 100 images at a time. Per-image and batch stats, segmentation masks, live conf/IoU tuning, and export of annotated images back to Photos. - -- **🎛️ Bench** - On-device benchmarking on all devices. A Cold Sweep measures every bundled model across compute units (Neural Engine (ANE), GPU, CPU) with expandable pre/inference/decode stage breakdowns, and a Sustained mode runs a thermal-throttle test with a live latency heatbeat sparkline and a colored state timeline. Pause/resume, a persistent run History vault (with per-run graphs) and CSV export. - -- **⚙️ Settings** - App info and an expandable About card (wihich explains the MoE architecture summary with Paper, Model / App Repo links), Licenses and Acknowledgements (from macOS build), a Privacy and Security summary, a CPU-inference opt-in for Live and Photo modes, erase-all benchmark history, and a Beta importer for your own trained Core ML models (.mlpackage / .mlmodelc / .mlmodel). - -### Performance - -All data are measured under Live mode on ANE with `YOLO-Master-v0.1-N` (COCO) for 3 min. - -| iPhone Model | SoC | End-to-end FPS | Thermal State after 1 min | -|---|---|---|---| -| iPhone 17 Pro Max | A19 Pro | 51 | 🟢 | -| iPhone Air | A19 Pro | 36 | 🟠 | -| iPhone 15 Pro Max | A17 Pro | 42 | 🟠 | - -### Install (Public Beta) - -The app is distributed through TestFlight. Install the TestFlight app on App Store, then open the invite link below to start testing it: - -https://testflight.apple.com/join/EVExpVHD - -### Build from source - -```zsh -brew install xcodegen -cd ios && xcodegen # this generates YOLOMasterIOS.xcodeproj +```bash +cmake -S cpp -B cpp/build -DCMAKE_BUILD_TYPE=Release \ + -DPORTABLE=ON \ + -DONNXRUNTIME_ROOT=/opt/onnxruntime \ + -DNCNN_ROOT=/opt/ncnn \ + -DUSE_MNN=OFF \ + -DREQUIRE_ORT=ON -DREQUIRE_NCNN=ON \ + -DALLOW_NO_BACKENDS=OFF +cmake --build cpp/build --parallel ``` -Drop your Core ML models into directory `ios/Models/`, set your signing team ID in `ios/project.yml`, open the project, and run. See `ios/README.md` for details. - -### Privacy & License (iOS App Update) - -The app has no internet connection. No data leaves the device, and we don't collect anything. See PRIVACY.md for detailed terms. - -The app is licensed under AGPL-3.0, consistent with YOLO-Master and Ultralytics; coremltools is BSD-3-Clause. It is released for research and personal experience only, and any direct commercial use of this app is prohibited. - ---- - -## 🚀 Update (12-08-2026): YOLO-Master Edge v1.1.0 is up! - -**One release, every platform: [macOS](https://github.com/skywalker-lt/yolo-master-edge/releases/download/v1.1.0/yolomaster-edge-mac-1.1.0.zip) / Windows [CPU](https://github.com/skywalker-lt/yolo-master-edge/releases/download/v1.1.0/yolomaster-edge-win-x64-1.1.0.zip) + [CUDA](https://github.com/skywalker-lt/yolo-master-edge/releases/download/v1.1.0/yolomaster-edge-win-x64-gpu_cuda12-1.1.0.zip) / Linux [CPU](https://github.com/skywalker-lt/yolo-master-edge/releases/download/v1.1.0/yolomaster-edge-linux-x64-1.1.0.tar.gz) + [CUDA](https://github.com/skywalker-lt/yolo-master-edge/releases/download/v1.1.0/yolomaster-edge-linux-x64-gpu_cuda12-1.1.0.tar.gz) / [Jetson Orin](https://github.com/skywalker-lt/yolo-master-edge/releases/download/v1.1.0/yolomaster-edge-jetson-orin-1.1.0.tar.gz).** +For MNN as the secondary backend, use `-DMNN_ROOT=/opt/mnn +-DUSE_MNN=ON -DREQUIRE_MNN=ON`. For a full video build, omit `-DPORTABLE=ON`. +The configure summary lists every enabled backend and its resolved library. -v1.1.0 brings the same feature set to all runners (macOS / Windows GUIs, Linux and Jetson TensorRT CLIs): +The same source builds on Windows with MSVC or MinGW. Replace the SDK paths +with Windows paths and pass `-DOpenCV_DIR=`. For ARM64 cross +compilation, use `-DCMAKE_TOOLCHAIN_FILE=cpp/aarch64-toolchain.cmake` and +record the sysroot and compiler in the environment manifest. A cross-compiled +binary is not evidence of a native device run. -- **🔪 Slicing (Sparse SAHI):** sliced inference for small objects on large images, a faithful port of upstream [YOLO-Master](https://github.com/Tencent/YOLO-Master)'s Sparse SAHI Mode plus a traditional dense-tiling variant, with adjustable tile size and per-run statistics. -- **🍇 Cluster-Weighted NMS:** a new NMS mode that refines each box as the weighted average of its detection cluster; tunable sigma, live everywhere including the webcam. -- **📤 Annotation export:** turn detections into training data as **YOLO TXT / COCO JSON / Pascal VOC XML** from images, folders, or videos (with frame sampling); segmentation models export real mask polygons. Rendered images and annotated videos export too. -- **🔎 Zoom & pan on GUI:** cursor-anchored zoom up to 8x on images and paused video in both GUIs. -- **📦 New: prebuilt Linux x86_64 bundles** (self-contained, glibc 2.35+, all three backends + ffmpeg video) and a **Jetson Orin bundle** with the TensorRT backend now supporting the full feature set. +## Runtime profiles -
+The generic defaults are intended for interactive inference. For an Issue #51 +accuracy run, always select an explicit profile so the resolved protocol is +printed in the log and benchmark sidecar. -Screenshot 2026-08-12 at 5 35 43 PM Screenshot 2026-08-12 at 5 36 12 PM +### VisDrone -
+`--profile visdrone` resolves to: -**Full notes: [Release Page](https://github.com/skywalker-lt/yolo-master-edge/releases/tag/v1.1.0).** +| Parameter | Value | +| --- | ---: | +| input | 640 x 640 | +| confidence | 0.001 | +| NMS IoU | 0.70 | +| max detections | 300 | +| class policy | 10-class VisDrone mapping | +| multi-label | enabled | +| resize | aspect-preserving letterbox, RGB/NCHW, float32/255 | ---- +### SKU-110K -## ✨ Update (27-07-2026): YOLO-Master Windows 10/11 Runner (**GUI**) on ONNX/ncnn/MNN backends with **GPU Acceleration** -**Download the [CPU runner](https://github.com/skywalker-lt/yolo-master-edge/releases/download/v1.0.0-windows/YOLO-Master-Windows-1.0.0.zip) / [CUDA runner](https://github.com/skywalker-lt/yolo-master-edge/releases/download/v1.0.0-windows/YOLO-Master-Windows-CUDA-1.0.0.zip).** +`--profile sku110k` resolves to a 1280-square input, confidence 0.25, NMS IoU +0.60, max detections 300, and the one-class SKU-110K mapping. Explicit command +line values are recorded and take precedence over profile defaults. -Now the Windows C++ edge runner has an improved backend and dedicated GUI. **YOLO-Master Windows Runner GUI** provides a C++ edge inference backend that bundles [ONNX](https://onnxruntime.ai/), [ncnn](https://github.com/Tencent/ncnn), and [MNN](https://github.com/alibaba/MNN) with **GPU acceleration**, together with a [Dear ImGui](https://github.com/ocornut/imgui) frontend. It includes the same default `YOLO-Master-v0.1-seg-N` segmentation model as the macOS runner. +## Quick smoke run -
- -48 2 50 2 -49 2 - -
- -- **Three Backends in One App** ONNX, ncnn, and MNN all ship in single executable. Inference backends can be switched with a single click. -- **GPU Acceleration for All Backends** up to **4x speedup** with CUDA-accelerated inference on consumer devices. (please refer to the inference speed comparison table in [Relases](https://github.com/skywalker-lt/yolo-master-edge/releases/tag/v1.0.0-windows)) - -See the [Windows release notes](https://github.com/skywalker-lt/yolo-master-edge/releases/tag/v1.0.0-windows) for build and benchmark details. - ---- - -## 🍎 Update (17-07-2026): YOLO-Master Core ML Runner for macOS (GUI) - -**[Download](https://github.com/skywalker-lt/yolo-master-edge/releases/download/v1.0.0-macos/YOLO-Master-CoreML-Runner-1.0.0.zip) and try it now!** - -Alongside the Linux and Windows C++ runtimes, this project provides a native macOS runner, **YOLO-Master Core ML Runner**: a [SwiftUI](https://developer.apple.com/xcode/swiftui/) frontend over an Apple [Core ML](https://developer.apple.com/documentation/coreml) backend for on-device [YOLO-Master](https://github.com/Tencent/YOLO-Master) inference. It ships with a default `YOLO-Master-v0.1-seg-N` segmentation model for an immediate smoke test. - -Screenshot1 Screenshot2 -Screenshot3 Screenshot4 - -- **Detection & Segmentation:** Runs both bounding-box detectors and instance-segmentation models, with anti-aliased mask overlays and a Masks / Boxes / Both toggle. -- **Images, Video & Live Camera:** Infers single images, whole folders (batch), and MP4 video, plus a low-latency **live webcam** mode with a real-time FPS / ms-per-frame readout. -- **⭐️ Real-Time Tuning:** Confidence, IoU, box style, labels, and letterbox/stretch preprocessing are adjustable at runtime. The forward pass is cached, so tuning redraws without rerunning inference. -- **Signed & Notarized:** A **universal** (Apple Silicon + Intel) bundle, **Developer-ID signed and notarized by Apple**; it installs by double-clicking on macOS 14+. - -See the [macOS release notes](https://github.com/skywalker-lt/yolo-master-edge/releases/tag/v1.0.0-macos) for build and distribution details. - ---- - -## ✨ Benefits +```bash +export LD_LIBRARY_PATH=/opt/onnxruntime/lib:$LD_LIBRARY_PATH +./cpp/build/yolomaster_edge \ + --model artifacts/model.onnx \ + --source test-data/image.jpg \ + --backend onnx \ + --profile visdrone \ + --out runs/smoke \ + --save-txt runs/smoke/labels \ + --csv runs/smoke/timing.csv \ + --benchmark-json runs/smoke/benchmark.json +``` -- **Universal CLI Binary for Linux and Windows:** A single executable integrates **ONNX Runtime**, **NCNN** and **MNN** backends; the backend, class names, and input size are auto-detected from the model — no recompilation or any dataset YAML needed at runtime. -- **Verified Accuracy:** Reproduces the PyTorch original to **< 0.5%** mAP50-95 across ONNX / NCNN / MNN, and **< 1.0%** under INT8 quantization, on 548 VisDrone validation images. -- **Deployment-Friendly:** Cross-platform [CMake](https://cmake.org/) build producing **self-contained and relocatable bundles** for Linux x86_64 and Windows 10/11 — installable by unzip, no dependencies on the target. -- **GUI:** Windows 10/11 and macOS provide GUI runners that integrate the CLI bundle functionality and support GPU acceleration. -- **GPU Acceleration:** Supports FP32 CPU inference and [NVIDIA CUDA](https://developer.nvidia.com/cuda-toolkit) through the ONNX Runtime CUDA Execution Provider on Linux and Windows; Windows also supports NCNN [Vulkan](https://vulkan.org) and MNN [OpenCL](https://opencl.org), NVIDIA Jetson Orin supports a native TensorRT backend (JetPack 7), and macOS uses [MPS](https://developer.apple.com/documentation/metalperformanceshaders) through Core ML. +Use `--no-save` for a load-only check. A missing model, invalid output shape, +unsupported device, or incomplete NCNN pair returns a non-zero status with a +diagnostic message. -## ☕ Note +## Issue #51 acceptance workflow -The exported models embed their class names, input size, and stride as ONNX/NCNN/MNN metadata, so the runtime configures itself from the model file. Post-processing is tuned for the vertical domain — aspect-ratio-preserving letterbox, per-class **multi-label** NMS, and a low default confidence threshold appropriate for VisDrone's small, dense objects. +The following commands are the canonical order. Run all backends against the +same ordered image list and with the same class mapping and profile. -## 📦 Exporting Models +### 1. Freeze the validation set -Pre-built models (trained on VisDrone) are attached to the [Releases](https://github.com/skywalker-lt/yolo-master-edge/releases) page. To export your own trained [YOLO-Master](https://github.com/Tencent/YOLO-Master) checkpoint, use the Ultralytics `export` mode. +Convert native annotations to the repository's YOLO label format, then create +an ordered UTF-8 list. The standard VisDrone validation split contains 548 +images; SKU-110K must report its actual split and count. -### ONNX +```bash +find /data/VisDrone/images/val -type f \\ + \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' \) \\ + | sort > artifacts/visdrone-val.list +``` -```python -from ultralytics import YOLO +Use the list file for every PyTorch and edge run. Directory enumeration is not +an equivalent protocol. The manifest tool rejects missing files, unsupported +suffixes, duplicate stems, and paths outside the declared image root. -# Load a trained YOLO-Master-EsMoE-N checkpoint -model = YOLO("EsMoE-N_VisDrone.pt") +### 2. Export and check models -# opset=12 for broad compatibility (ORT + NCNN + MNN) -# simplify=True runs onnxsim; dynamic=False fixes the input shape for C++ deployment -model.export(format="onnx", opset=12, simplify=True, dynamic=False, imgsz=640) +```bash +python scripts/export_models.py \ + --weights runs/esmoe_n/weights/best.pt \ + --out artifacts/export \ + --imgsz 640 --opset 17 ``` -### NCNN (via pnnx) and MNN +The exporter runs ONNX checker and simplification by default and records the +conversion command, output names, input shape, and SHA256. NCNN requires a +matching `.param` and `.bin`; MNN conversion remains explicitly marked +incomplete until its predictions pass the same parity gate. -```bash -# NCNN — Ultralytics uses pnnx under the hood -yolo export model=EsMoE-N_VisDrone.pt format=ncnn imgsz=640 +### 3. Run the C++ backends -# MNN — convert the exported ONNX with MNN's converter -mnnconvert -f ONNX --modelFile esmoe_n_visdrone_sim.onnx --MNNModel esmoe_n_visdrone.mnn --bizCode edge +```bash +./cpp/build/yolomaster_edge --model artifacts/export/model.onnx \ + --source artifacts/visdrone-val.list --profile visdrone \ + --backend onnx --threads 4 --warmup 10 --runs 100 \ + --out runs/onnx --save-txt runs/onnx/labels \ + --csv runs/onnx/timing.csv --benchmark-json runs/onnx/benchmark.json + +./cpp/build/yolomaster_edge --model artifacts/export/model.mnn \ + --source artifacts/visdrone-val.list --profile visdrone \ + --backend mnn --threads 4 --warmup 10 --runs 100 \ + --out runs/mnn --save-txt runs/mnn/labels \ + --csv runs/mnn/timing.csv --benchmark-json runs/mnn/benchmark.json ``` -For more details on exporting, refer to the [Ultralytics Export documentation](https://docs.ultralytics.com/modes/export/). - -### Core ML +Use `--backend ncnn` with the exported NCNN directory when that SDK is enabled. +Keep the `--profile`, image list, thread count, warm-up count, and repeat count +identical across backends. -```zsh -# detector or segmenter (task auto-detected) -python coreml_export/export_coreml.py --weights model.pt --imgsz 640 --out model.mlpackage +### 4. Evaluate accuracy -# YOLO-Master default imgsz is 800 for AI-TOD models — pass --imgsz accordingly -python coreml_export/export_coreml.py --weights yolo-master-v0.1-N_aitodv2.pt --imgsz 800 --out v0.1-N.mlpackage +Use converted YOLO labels for the formal gate. Native VisDrone annotation rows +are supported only for diagnostics because their ignored-region semantics are +not identical to YOLO labels. -# sunsmarterjie/yolov12 checkpoints (split qk+v area-attention) — stock ultralytics + the flag -python coreml_export/export_coreml.py --weights yolov12x.pt --imgsz 640 --out yolov12x.mlpackage --yolov12-aattn - -# a LoRA fine-tune: merge the trained adapters first -python coreml_export/export_coreml.py --weights base.pt --merge-lora-dir lora_adapter/ --imgsz 640 --out ft.mlpackage +```bash +python scripts/eval_map.py \ + --preds runs/onnx/labels \ + --images artifacts/visdrone-val.list \ + --image-root /data/VisDrone/images/val \ + --labels /data/VisDrone/labels/val \ + --classes visdrone --label-format yolo \ + --routing-semantics dense_fallback \ + --imgsz 640 --conf 0.001 --iou 0.70 --max-det 300 --multi-label \ + --min-images 500 \ + --reference-json runs/pytorch/map.json \ + --max-abs-delta-pp 0.5 \ + --json runs/onnx/map.json ``` +The FP32 budget is 0.5 percentage points. Use 1.0 percentage point only for +an explicitly identified INT8 run. The evaluator reports both absolute +percentage-point and relative-percent deltas; do not mix the two units. -## ⚙️ Dependencies - -Ensure you have the following dependencies installed (not required if you only want to smoke-test the pre-built bundles): - -### Linux & Windows - -| Dependency | Version | Notes | -| :------------------------------------------------------------------ | :------------ | :------------------------------------------------------------------------------------------------------------- | -| [ONNX Runtime](https://onnxruntime.ai/docs/install/) | >=1.18 | Download pre-built binaries or build from source. Use the GPU build for the CUDA Execution Provider. | -| [NCNN](https://github.com/Tencent/ncnn/releases) | recent | Tencent NCNN; on Windows use the `windows-vs2022` prebuilt. | -| [OpenCV](https://opencv.org/releases/) | >=4.5.0 | Used for image preprocessing (`core` + `imgproc`). | -| C++ Compiler | C++17 Support | Needed for ``. ([GCC](https://gcc.gnu.org/), [Clang](https://clang.llvm.org/), MSVC 2022/2026) | -| [CMake](https://cmake.org/download/) | >=3.16 | Cross-platform build system generator. | -| [CUDA Toolkit](https://developer.nvidia.com/cuda-toolkit) (Optional)| 12.x | Required for GPU acceleration via ONNX Runtime's CUDA Execution Provider (match your ONNX Runtime GPU build). | -| [MNN](https://github.com/alibaba/MNN) (Optional) | >=3.0 | Only for the third export format / benchmarking. | - -> **Note:** The CUDA Execution Provider is ABI-coupled to a CUDA major version — use the ONNX Runtime GPU build that matches your CUDA Toolkit (e.g. the CUDA-12 build with CUDA 12.x), or you'll hit loader errors. - - -### macOS -| | Version | Notes | -| :------------------------------------------------------------------ | :------------ | :------------------------------------------------------------------------------------------------------------- | -| macOS | Sonoma or newer (14.0+) | SwiftUI API floor (onKeyPress, zero-param onChange) | -| [Xcode Command Line Tools](https://developer.apple.com/documentation/xcode/installing-the-command-line-tools/) | Xcode 15+ | Install with xcode-select --install. Provides swift, codesign, ditto. Full Xcode GUI not required for a build. | -| [Swift toolchain](https://www.swift.org/swiftly/documentation/swiftly/install-toolchains/) | 5.9+ | swift-tools-version:5.9 in Package.swift; ships with the CLT/Xcode above. Build: swift build -c release --package-path mac. | -| Apple SDK frameworks | macOS 14+ SDK (system) | SwiftUI, AppKit, AVFoundation, Core ML, Core Image, Core Video, ImageIO, QuartzCore, etc. | - - -## 🛠️ Build Instructions - -### CLI (Linux & Windows) - -1. **Clone the Repository:** - - ```bash - git clone https://github.com/skywalker-lt/yolo-master-edge.git - cd yolo-master-edge/cpp - ``` - -2. **Create Build Directory:** - - ```bash - mkdir build && cd build - ``` - -3. **Configure with CMake:** - Point CMake at your extracted ONNX Runtime and NCNN SDKs via `ONNXRUNTIME_ROOT` and `NCNN_ROOT`. - - ```bash - # Example for Linux (adjust paths as needed) - cmake .. -DCMAKE_BUILD_TYPE=Release \ - -DONNXRUNTIME_ROOT=/path/to/onnxruntime \ - -DNCNN_ROOT=/path/to/ncnn - ``` - - ```bat - :: Example for Windows, from the "x64 Native Tools Command Prompt" - cmake .. -DCMAKE_BUILD_TYPE=Release ^ - -DOpenCV_DIR=C:/dev/opencv/build/x64/vc16/lib ^ - -DONNXRUNTIME_ROOT=C:/dev/onnxruntime-win-x64 ^ - -DNCNN_ROOT=C:/dev/ncnn-windows-vs2022/x64 - ``` - - **CMake Options:** - - `-DONNXRUNTIME_ROOT=`: **(Required)** Path to the extracted ONNX Runtime library. - - `-DNCNN_ROOT=`: **(Required)** Path to the extracted NCNN library. - - `-DCMAKE_BUILD_TYPE=Release`: (Optional) Build with optimizations. - - `-DPORTABLE=ON`: (Optional, Linux) Slim build for a small self-contained bundle (image inference only). - - If CMake struggles to find OpenCV, set `-DOpenCV_DIR=/path/to/opencv/build`. - -4. **Build the Project:** - Use the build tool generated by CMake (Make, Ninja, or Visual Studio). - - ```bash - # Using CMake's generic build command (works with Make, Ninja, MSBuild) - cmake --build . --config Release - ``` - -5. **Locate Executable:** - The compiled executable (`yolomaster_edge`, or `yolomaster_edge.exe` on Windows) is located in the `build` directory. On Windows the required backend and OpenCV DLLs are auto-copied next to it. - -### Windows GUI (with CUDA) - -1. **Clone the Repository** - ```shell - git clone https://github.com/skywalker-lt/yolo-master-edge.git - cd yolo-master-edge/gui - ``` - -2. **Copy and Edit the Paths** - ```bat - copy sdk-paths.example.cmd sdk-paths.cmd - ``` - Edit `sdk-paths.cmd` with your locations. It is gitignored. Leave a backend blank to skip it. - -4. **Build** - ```bat - build.cmd :: configure + build Release - build.cmd run :: build, then launch - build.cmd clean :: wipe build\ first - ``` - Output: `gui\build\Release\yolomaster_gui.exe` - - > If PowerShell blocks `.ps1` scripts, use `.cmd` scripts as they are not subject to execution policy. `build.ps1` is equivalent and takes the same paths as parameters. - -### macOS - -1. **Clone the Repository:** - ```bash - git clone https://github.com/skywalker-lt/yolo-master-edge.git - cd yolo-master-edge/cpp - ``` - -2. **Build the App and Run** - ```zsh - xcode-select --install - swift run -c release --package-path mac YOLOMasterApp - ``` - -## 🚀 Usage (CLI) - -Run the executable, pointing it at a model and a source (image, directory, video, or `dataset.yaml`): +### 5. Check per-image parity ```bash -./yolomaster_edge --model ../../models/esmoe_n_visdrone_sim.onnx \ - --source path/to/image_or_dir \ - --conf 0.25 --out out -``` - -The backend is inferred from the model (`.onnx` → ONNX Runtime, an NCNN directory or `.param` → NCNN, `.mnn` → MNN, `.engine`/`.trt` → TensorRT), and class names and input size are read from model metadata. Common options: - -```text ---backend auto | onnx | ncnn | mnn | trt (default: auto-detect) ---device backend-dependent: cpu, cuda, vulkan, opencl, trt, coreml ---conf confidence threshold (default 0.25; lower for dense scenes) ---iou NMS IoU threshold (default 0.50) ---multi-label one detection per class >= conf per anchor (matches Ultralytics val mAP) ---save-txt dir to write predictions ('class conf x1 y1 x2 y2') ---out dir for annotated outputs --no-save / --quiet +python scripts/prediction_diff.py \ + --reference runs/pytorch/labels \ + --candidate runs/onnx/labels \ + --images artifacts/visdrone-val.list \ + --image-root /data/VisDrone/images/val \ + --iou 0.50 --min-iou 0.99 \ + --json runs/onnx/prediction-diff.json \ + --csv runs/onnx/prediction-diff.csv ``` -See `tests/run_tests.sh` for the 16-test robustness battery. - -## 🤖 Jetson Orin (Native TensorRT) +The report includes matched and unmatched counts, coordinate and confidence +deltas, and IoU percentiles. A parity claim must cite this report, not only a +single visual example. -A prebuilt aarch64 runner for **Jetson Orin** (Nano / NX / AGX) on **JetPack 7** is attached to the [Releases](https://github.com/skywalker-lt/yolo-master-edge/releases) page. It bundles OpenCV and uses JetPack's TensorRT + CUDA; the per-device FP16 engine is built once with the included script. +### 6. Archive and verify evidence ```bash -tar xzf yolomaster_edge-jetson-orin-jp7.tar.gz && cd yolomaster_edge-jetson-orin-jp7 -./build_engine.sh # builds the FP16 engine for this device (once, ~10-15 min) -./yolomaster_edge --model models/esmoe_n_fp16.engine --source --classes visdrone --out out +python scripts/evidence_manifest.py create \ + --dataset visdrone --split val \ + --images artifacts/visdrone-val.list \ + --image-root /data/VisDrone/images/val \ + --labels /data/VisDrone/labels/val \ + --predictions runs/onnx/labels \ + --checkpoint runs/esmoe_n/weights/best.pt \ + --training-metadata artifacts/training-provenance.json \ + --model onnx=artifacts/export/model.onnx \ + --report map=runs/onnx/map.json \ + --report timing=runs/onnx/timing.csv \ + --command "./cpp/build/yolomaster_edge --profile visdrone ..." \ + --acceptance --output runs/onnx/evidence.json + +python scripts/evidence_manifest.py verify runs/onnx/evidence.json \ + --acceptance \ + --images-root /data/VisDrone/images/val \ + --labels-root /data/VisDrone/labels/val \ + --predictions-root runs/onnx/labels \ + --models-root artifacts/export \ + --checkpoint-root runs/esmoe_n/weights \ + --reports-root runs/onnx ``` -On an Orin Nano 4 GB the FP16 engine runs at **35.7 FPS** (27.8 ms) with **mAP50-95 0.2029 (−0.07 pp vs FP32)**. FP16 is the recommended target for this model because the area-attention path is not quantized, making INT8 both slower and less accurate here. To build from source, the [`jetson/`](jetson/) scripts drive the engine build and packaging; see [`jetson/README.md`](jetson/README.md) and [`jetson/DEPLOYMENT_LOG.md`](jetson/DEPLOYMENT_LOG.md). +Publish the verified evidence directory as an immutable Release artifact or +equivalent. Keep large models, images, and predictions out of the source PR. -## 📊 Results +## INT8 protocol -Inference performed on full 548 VisDrone validation images against the PyTorch original (`mAP50-95 = 0.2036`), using identical settings (conf 0.001, NMS IoU 0.7, multi-label). +Calibration images must come from the training split, contain at least 300 +files, and be disjoint from validation by content SHA256. Quantization produces +an explicit calibration manifest and reports `acceptance_ready: false` until an +INT8 prediction run has passed `eval_map.py` with the 1.0 percentage-point gate. -| Inference Backend | Device | mAP50-95 | Δ vs PyTorch | End-to-end Latency | FPS | -| :------------------------ | :------- | :------- | :----------- | :------ | :---- | -| ONNX | CPU | 0.2034 | −0.02 pp | 40 ms | 25.0 | -| ONNX (CUDA) | H200 SXM | 0.2033 | −0.03 pp | 7.8 ms | 128 | -| ONNX (CUDA) | RTX 5070Ti Laptop | 0.2033 | −0.03 pp | 9.0 ms | 111 | -| NCNN | CPU | 0.2034 | −0.02 pp | 80 ms | 12.5 | -| NCNN (Vulkan) | RTX 5070Ti Laptop | 0.2034 | −0.02 pp | 20.2 ms | 49.5 | -| MNN | CPU | 0.2034 | −0.02 pp | 74 ms | 13.5 | -| MNN (OpenCL) | RTX 5070Ti Laptop | 0.2034 | −0.02 pp | 19.1 ms | 52.4 | -| INT8 mixed ¹ | CPU | 0.1952 | −0.84 pp | 137 ms | 7.2 | -| TensorRT FP16 | Jetson Orin Nano 4GB | 0.2029 | −0.07 pp | 27.8 ms | 35.7 | -| Core ML | Apple M4 Max | N/A (no validator bundled) | N/A | 17.4 ms | 57.4 | +## Performance protocol -CPU latencies are x86 @ 4 threads on one host; mAP is identical across FP32 formats because they are of the same graph. The Jetson row is a native TensorRT FP16 engine, measured on-device. +Use the same host, CPU affinity, input size, thread count, warm-up count, and +repeat count for all backends. Archive preprocessing, inference, postprocessing, +end-to-end mean/P50/P95/P99, FPS, compiler, runtime versions, and host data. +Virtual-machine results must be labelled as VM measurements and must not be +presented as Jetson or ARM64 performance. -> ¹ INT8 is *slower* than FP32 on CPU — its throughput payoff needs INT8 tensor cores, not x86 CPUs. The CPU INT8 result is **accuracy evidence** (−0.84 percentage points, within budget); on the actual accelerator, FP16 also wins on Orin because the attention path is not quantized (see the TensorRT row and [`TECHNICAL_REPORT.md`](TECHNICAL_REPORT.md) Section 9). +## Evidence levels -See [`TECHNICAL_REPORT.md`](TECHNICAL_REPORT.md) for the full methodology, INT8 quantization deep-dive, and numerical parity analysis. +| Level | Minimum evidence | Permitted conclusion | +| --- | --- | --- | +| L0 | Contract tests, parser checks, CMake diagnostics | Interfaces and static gates work | +| L1 | Real model plus one image or a small subset | Load, preprocess, decode, and output work | +| L2 | At least 500 fixed images, reference JSON, predictions, and hashes | Auditable FP32 accuracy | +| L3 | L2 plus 300 disjoint calibration images and INT8 gate | Auditable INT8 accuracy | +| L4 | Native builds on two platforms with raw logs and parity artifacts | Cross-platform deployment result | +Do not upgrade an evidence level by filling a template with estimated values. -## 🤝 Contributing +## License -Contributions are welcome! If you find any issues or have suggestions for improvements, please feel free to open an issue or submit a pull request on the [project repository](https://github.com/skywalker-lt/yolo-master-edge). +The example follows the license of the surrounding YOLO-Master repository. diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/TECHNICAL_REPORT.md b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/TECHNICAL_REPORT.md index 4b657f2c4..4860190f8 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/TECHNICAL_REPORT.md +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/TECHNICAL_REPORT.md @@ -1,296 +1,385 @@ -# A Technical Analysis of Cross-Platform Edge Deployment of YOLO-Master - -End-to-end deployment of **YOLO-Master** to the edge, spanning export formats (ONNX / NCNN / MNN / Core ML / TensorRT), mixed-precision INT8, runtime with GPU acceleration on all backends, two native GUI runners, cross-platform builds for Linux / Windows / Jetson / macOS, and accuracy-latency validation against the PyTorch original. - - - - Edge Deployment Bundle — Architecture - - -- Source code: **[Edge Deployment Repo](https://github.com/skywalker-lt/yolo-master-edge)** -- Download pre-built bundles & GUI Apps: **[Releases](https://github.com/skywalker-lt/yolo-master-edge/releases)** - -The evaluation focuses on **YOLO-Master-EsMoE-N** on VisDrone, as specified by Issue #51. - -## 🖥️ System Configuration - -This table lists the system configurations used for the deployment measurements in this report. - -| Host | Linux x86_64 (Docker) | Windows x86_64 | Nvidia Jetson | Mac | -|:---:|:---:|:---:|:---:|:---:| -| Device | Datacenter Server | ROG Strix SCAR 18 | Jetson Orin Nano Super (4GB) DevKit | MacBook Pro 16" (2024) | -| CPU | 2x Intel Xeon 8568Y+ | Intel Core U9-275HX | Arm Cortex-A78AE | Apple **M4 Max CPU** | -| System RAM | 2,048 GB | 32 GB | 4 GB (unified) | 48 GB (unified) | -| GPU | **H200 SXM** | **RTX 5070 Ti Laptop** | Orin GPU, (GA10B, **`sm87`**) | Apple **M4 Max GPU** | -| VRAM | 141 GB / GPU | 12 GB | 4 GB (unified) | 48 GB (unified) | -| OS / platform | Ubuntu Server 22.04 LTS | Windows 11 x64 | JetPack 7 / Ubuntu 24.04 | macOS 26.3 (Tahoe) | - ---- -## 📋 Table of Contents - -- [1. How and why the MoE internals dictate the deployment strategy](#-1-how-and-why-the-moe-internals-dictate-the-deployment-strategy) -- [2. Exports](#-2-exports) -- [3. INT8 quantization (the substantive part)](#-3-int8-quantization-the-substantive-part) -- [4. The inference runtime](#%EF%B8%8F-4-the-inference-runtime) -- [5. Accuracy validation](#-5-accuracy-validation) -- [6. GPU acceleration across all three backends](#-6-gpu-acceleration-across-all-three-backends) -- [7. Latency and throughput](#%EF%B8%8F-7-latency-and-throughput) -- [8. Cross-platform builds and distribution](#-8-cross-platform-builds-and-distribution) -- [9. Embedded GPU deployment: Jetson Orin](#-9-embedded-gpu-deployment-jetson-orin) -- [10. GUI runners for Windows & macOS](#-10-gui-runners-for-windows-and-macos) -- [11. Future work](#-11-future-work) - ---- - -## 🔎 1. How and why the MoE internals dictate the deployment strategy - -EsMoE-N is not a CNN YOLO like the older v8/v9 variants. Three structural properties drove the downstream deployment decisions: - -1. **Mixture-of-Experts `ES_MOE`:** During training and inference, the router sparsely selects experts. This path is export-hostile because it contains data-dependent control flow, but `ES_MOE.forward` switches to a **dense** unroll under `torch.onnx.is_in_onnx_export()`: a static loop over the full expert list using `Conv/Pool/Softmax/Mul/Add`, with no dynamic dispatch. The `--no-sparse-eval` dense path is the **numerically faithful** one. It avoids sparse-inference collapse and improves export determinism. -2. **Area-attention `A2C2f`:** The backbone contains transformer-style attention blocks. These reshape activations to `[1, 1600, 192]` internally, which is where the static-shape assumptions of downstream quantizers and tracers break (as discussed later). -3. **A stride-8/16/32 detection head** (P3/P4/P5) where the classification branch produces raw logits fed through a terminal sigmoid. At 640×640 that is `80^2 + 40^2 + 20^2 = 8400` anchors, matching the exported `[1, 14, 8400]` output (4 box + 10 VisDrone classes). This branch is the most quantization-sensitive component in the network, for the reason explained in Section 3. - -Accordingly, the model exports cleanly because the dense MoE path uses standard operators, while the attention blocks and detection head remain sensitive to INT8 quantization and third-party conversion. - -## 📦 2. Exports - -### 2.1 ONNX via onnxsim (opset 12) - -Exported to a fully **static** graph, input `images [1,3,640,640]`, output `output0 [1,14,8400]`, 628 nodes, IR 7, and simplified with onnxsim. Opset 12 was chosen carefully for compatibility. It loads unchanged under ORT 1.18 / 1.20 / 1.27 and converts cleanly to *both* NCNN and MNN. - -The export emitted a shape-inference warning on the attention transpose (`.../attn/Transpose_output_0 source:{1,1600,192} target:{}`, later resolved by ONNX Runtime's lenient merge). ORT resolves the shapes at runtime, but the warning is a **leading indicator**: tools that require fully static shape propagation may reject this graph (Section 3.5, MNN quantization, and Section 2.4, coremltools). - -Ultralytics metadata (class names, `imgsz`, `stride`, `task`) is also embedded into the ONNX `metadata_props`, which the C++ runtime reads to auto-configure itself. - -### 2.2 NCNN via pnnx - -Exported via **pnnx** (PyTorch/ONNX → pnnx IR → ncnn), instead of the legacy `onnx2ncnn`. pnnx preserves higher-level operator semantics and emits a clean graph. The param file was validated: magic `7767517`, **561 layers / 665 blobs**, input blob `in0`, sigmoid-terminated head. A `metadata.yaml` sidecar carries the same names/imgsz so the ncnn path is self-contained and self-describing like the ONNX one. - -### 2.3 MNN via mnnconvert - -Converted with `mnnconvert` (ONNX → MNN), which emits the same graph structure as ONNX/ncnn and permits direct tensor comparison against the source graph (Section 5.3). - -### 2.4 Core ML - -Core ML export (`coreml_export/export_coreml.py`) exports a **mlprogram** `.mlpackage` carrying the metadata used by the Apple device application (`names`, `imgsz`, `output`, `task`, plus `proto`/`nm` for seg models). Conversion can run on **Linux**; only prediction requires macOS, keeping export in the same environment as the other formats. - -Three failure modes had to be addressed; each reflects one of the structures described in Section 1: - -- **Dynamic shapes → `aten::Int`:** coremltools' Torch frontend cannot lower the data-dependent integer extraction produced by the attention reshapes. Constant-folding first resolves the shapes to literals: `jit.freeze` + `run_frozen_optimizations` run before conversion. -- **MoE telemetry → `aten::copy_`:** The trace failed with "No matching select or slice" **not** in the expert computation, but in `ES_MOE`'s in-place auxiliary-loss bookkeeping used only during training. Routing was valid; the telemetry was the root cause. -- **Area-attention static shapes:** An eager warmup pass bakes each layer's concrete spatial dimensions so the area reshapes fold to static shapes. For `sunsmarterjie/yolov12` checkpoints (for testing and comparison), `--yolov12-aattn` additionally swaps in the split qk+v attention variant those weights expect. - -The script also handles **segmentation** (detects the two-output signature and writes `task=segment` with `proto`/`nm`) and **LoRA fine-tunes** (`--merge-lora-dir` merges adapters before export, since a merged LoRA is a static graph whereas routed MoLoRA cannot be traced). - -**Validation:** The Core ML path currently has **no mAP number**. The macOS app does not bundle a metric harness, and the `eval_map.py` pipeline used for the other formats consumes `--save-txt` output from the C++ runtime, which the Swift app does not produce. A validator is planned for a future Core ML Runner update. - -## 🔢 3. INT8 quantization findings - -The requirement was ≤ 1.0% mAP gap under INT8 with ≥ 300 images for calibration. The naive pipeline *fails*. - -### 3.1 The collapse: full INT8 emits nothing - -Static per-channel INT8 over the whole graph produces a model that runs, returns the correct output tensor shape, contains **no NaNs**, **and detects nothing**, mAP=0.0000. - -Isolating the output tensor shows why: The box-regression channels are intact (`min 0, max 644, mean 210`, matching FP32); however the **classification channels are zero** (`max = 0.0000`, zero scores above 0.001). This indicates that the failure is concentrated in the class head. - -The mechanism is as follows: the class branch emits wide-dynamic-range *logits* consumed by a sigmoid. Per-tensor/per-channel MinMax calibration maps that range to 256 INT8 levels; the small positive logits corresponding to real detections fall *below one quantization step* and round to a value whose sigmoid is approximately 0. The non-linearity turns this quantization error into a complete signal cutoff. Box regression, by contrast, is a smooth linear readout with no saturating nonlinearity downstream and therefore tolerates INT8. This asymmetry of **robust regression but degraded classification** is the key diagnostic. - -### 3.2 Localizing the sensitivity - -Retaining the detection head (`/model.25/`, 85 nodes) in FP32 while quantizing the remaining nodes recovers the model to **mAP50-95=0.1924, ∆ −1.12 pp** versus PyTorch. The model is functional but remains outside the stated budget; the residual loss is concentrated in two quantization-sensitive structures: - -- **MoE router:** Expert mixing is a softmax over routing logits. INT8 trims the precision of routing weights. -- **Area-attn:** Attention scores pass through a softmax whose output is sensitive to input scale; INT8 on QK path shifts the attention distribution. - -Both are the same failure class as the head: **a softmax/sigmoid amplifying a quantization perturbation.** This aligns with established LLM quantization strategies. - -### 3.3 The mixed-precision outcome - -The fix is node-level precision: keep the three softmax/sigmoid-bearing blocks, head (`/model.25/`), attention (`/attn/`), router (`routing`), **289 nodes** in FP32, INT8 everything else. The progression is diagnostic: - -| Configuration | mAP50-95 | Δ vs PyTorch (percentage points) | -|---|---|---| -| Full INT8 | 0.0000 | collapse | -| head FP32 | 0.1924 | −1.12 pp | -| head + attention + router FP32 | **0.1952** | **−0.84 pp ✅** | - -Final model: **10.9 → 5.4 MB (2.0×)**, with an mAP50-95 difference of **−0.84 percentage points**, within the 1.0-point requirement. The improvement follows from retaining the operators that violate the smooth, non-saturating assumption of PTQ in higher precision. - -### 3.4 Calibration engineering - -Three implementation details were decisive: - -- **Letterbox-matched calibration.** Calibrators default to a plain resize; the model is trained on **letterboxed** input. Calibrating on the wrong preprocessing biases every activation range. We pre-letterbox 300+ VisDrone *train* images (no val leakage) to 640×640 and calibrate on those, so the calibration distribution matches inference exactly. -- **QOperator, and the opset floor.** Per-channel INT8 emits `DequantizeLinear` with an `axis` attribute, which is **only valid at opset ≥ 13**; the opset-12 export must be lifted (we upgrade to 17 in-line) or the quantized model is an invalid graph. QOperator (`QLinearConv`/`QLinearMatMul`) is chosen over QDQ for CPU execution. -- **MinMax over Percentile.** Percentile/entropy calibration builds a histogram per activation tensor; on a graph with hundreds of attention/MoE intermediates and hundreds of images, that becomes computationally expensive without an observed accuracy benefit. The exclusions remove the outlier-sensitive layers, so MinMax on the remaining convolutions is faster and sufficient. - -### 3.5 Third-party INT8 toolchains and attention-shape handling - -MNN's offline quantizer (`mnnquant`) aborts immediately on this model -- `std::length_error: cannot create std::vector larger than max_size()` -- before any calibration runs. The cause is precisely the `[1,1600,192]` attention reshapes flagged in Section 2.1: the quantizer allocates buffers from statically-inferred tensor dimensions, and the dynamically-shaped attention intermediate reads back as a garbage size. MNN executes this graph fine at *inference* (it resolves shapes lazily); its *quantizer* assumes static shapes. This is a limitation of the tool's static-shape contract, not of the model, and it is not configurable. The ONNXRuntime quantizer, which tolerates dynamic intermediates, is the correct vehicle for this architecture. - -### 3.6 Where INT8 provides a benefit, and where it does not - -On x86 CPU, INT8 is *slower* than FP32, measured at **137 ms/frame vs 49 ms for FP32 on the same host, ~2.8× slower** (7.2 vs 19.5 FPS; the 40 ms in Section 7 is the canonical 4-thread benchmark on the reference host, so use the paired figures here for the ratio). The QDQ/QOperator kernels don't engage INT8 SIMD paths that beat the well-tuned FP32 convolutions, and the FP32 $\leftrightarrow$ INT8 boundaries around the excluded blocks add conversion overhead. This is expected, not a defect: INT8's throughput win is a property of **INT8 tensor-core hardware**, not of desktop CPUs. - -The natural next hypothesis was that tensor-core hardware would invert the result. **It does not for this model**: Section 9 shows that the calibrated TensorRT INT8 engine on Orin is both slower and less accurate than FP16. The mixed-precision assignment keeps the compute-dominant area-attention path out of INT8, so INT8 does not accelerate the dominant computation. More generally, **PTQ throughput is bounded by the fraction of computation that can be quantized**; for attention-heavy architectures, that fraction may be small. The ONNX INT8 result is therefore treated as **accuracy evidence** (−0.84 percentage points, within the stated budget), while FP16 GPU execution provides the measured throughput benefit (Section 6). - -## ⚙️ 4. The inference runtime - -### 4.1 Universal binary - -One executable (`yolomaster_edge`) with **four backends** (ONNXRuntime, NCNN, MNN, TensorRT) behind a common interface (`backend_factory.hpp`). Backend, class names, and input size are **auto-detected** from the model (`.onnx` → ORT, a directory or `.param` → ncnn, `.mnn` → MNN, `.engine` → TensorRT; metadata read from ONNX `metadata_props` or the ncnn `metadata.yaml` sidecar), so the same binary serves any exported YOLO-Master variant with no recompilation. +# Technical Note: Auditable Edge Deployment for Issue #51 + +## Abstract + +This note describes the implementation and the measurement protocol supplied +for Issue #51, which concerns edge inference of vertically trained YOLO-Master +models. The contribution is a backend-independent C++ runtime together with +export, validation, quantization and evidence tooling. The design treats +reproducibility as part of the deployment interface: a metric is considered a +result only when the model, ordered image set, command line, software +environment and raw predictions can be verified from a content-addressed +manifest. + +The source tree contains no EsMoE-N checkpoint, VisDrone images or generated +prediction directory. This document therefore specifies an executable method +and the implementation boundaries; it does not claim a new mAP, FPS or ARM64 +measurement. + +## Contribution and verification status + +The table below is the short reviewer-facing record. It separates implementation +evidence from measurements that require a user-supplied checkpoint and data. + +| Contribution | Reproducible artifact | Status in this checkout | +| --- | --- | --- | +| Unified C++17 inference path | ONNX Runtime, NCNN and MNN adapters with shared preprocessing, decoding and NMS | L0 contract-checked | +| Export and conversion checks | ONNX checker/simplifier, NCNN pair/sidecar validation, MNN conversion diagnostics | L0 structural checks | +| Accuracy and parity protocol | Ordered image manifest, mAP evaluator, percentage-point gate and per-image prediction diff | L0 tooling | +| INT8 protocol | Training-only calibration selection (>=300 images), hash disjointness and protected nodes | L0 tooling | +| Linux functional smoke | Ubuntu 22.04 x86_64, YOLOv5s ONNX, one image and six detections | L1 smoke evidence | +| EsMoE-N acceptance result | VisDrone/SKU-110K full split, mAP, INT8 and a second native platform | Pending checkpoint/data/logs | + +The smoke run is a functional check of the runtime and is not an EsMoE-N +accuracy claim. No result is promoted to an acceptance claim until its model, +image set, predictions and raw logs are available for independent verification. + +## 1. Evaluation objective + +The target comparison is a single trained checkpoint evaluated through +PyTorch and at least two deployment formats (ONNX Runtime plus NCNN or MNN). +Every path must consume the same ordered validation images and the same +post-processing parameters. The minimum acceptance record is: + +* a fixed validation list with at least 500 images (the common VisDrone split + contains 548 images); +* a PyTorch/reference metric JSON and per-image predictions for each backend; +* a model and image-list SHA256 digest; +* an explicit EsMoE routing-semantic record shared by the reference and export; +* a latency log with fixed thread count, warm-up, repeat count and host details. + +Optional INT8 evaluation adds a training-only calibration list of at least 300 +images. The calibration and validation sets are checked for content overlap, +not merely for different filenames. + +These requirements make the evaluation record independent of a particular +machine, exporter ordering or directory traversal. The same protocol is used +for every backend and platform so that a reported difference has a traceable +cause. + +### 1.1 Training provenance and image-manifest control + +The checkpoint is treated as an experimental input rather than as an +interchangeable model file. A submission record should therefore include the +base-model or repository revision, dataset release and split, class mapping, +epoch count, optimizer and learning-rate schedule, random seed, +deterministic-setting, training software versions and the SHA256 of the best +checkpoint. The exact training command and the selected checkpoint should be +archived alongside the exported graphs. + +The validation population is materialized before inference. For the standard +VisDrone validation split this is an ordered list of 548 image paths; the list +is reused unchanged by PyTorch, ONNX Runtime, NCNN and MNN. The manifest stores +one record per image (relative path, byte count and SHA256), rejects duplicate +stems and computes an ordered-list digest. A run with fewer than 500 images is +diagnostic only. This distinction prevents a convenient subset or a stale +prediction directory from being presented as a full-split result. + +Metric JSON records both the path-only `image_manifest_sha256` and the +content-aware `image_list_sha256`. The latter hashes ordered +`relative-path SHA256` rows and therefore matches the evidence manifest. The +reference gate compares both fields, so replacing an image while retaining its +filename invalidates the comparison. For a list stored outside the dataset +tree, `--image-root` defines the normalization boundary; entries outside that +boundary are rejected. + +The same control applies to INT8 calibration. Calibration images are selected +from the training split, deterministically ordered and content-hash compared +with the validation records. At least 300 images are required, and any hash +overlap invalidates the calibration evidence even when filenames differ. + +## 2. Runtime architecture + +The C++ runner is organized into four layers: + +1. **Input and timing.** `main.cpp` resolves image, directory, video, dataset + YAML and newline-delimited image-list sources. Dataset `val` accepts a + scalar or YAML sequence; list inputs preserve their declared order, while + directory inputs are sorted deterministically. The runner records failures + and emits per-image timing rows. +2. **Preprocessing.** `common.cpp` implements centered letterbox (padding + value 114), BGR-to-RGB conversion and NCHW `float32 / 255` packing. Stretch + mode is available only as an explicit diagnostic override. +3. **Backend adapters.** `ort_backend.cpp`, `ncnn_backend.cpp` and + `mnn_backend.cpp` load a graph once and expose the same `Backend` interface. + Optional native TensorRT support is compiled only with a TensorRT 10.x SDK; + older TensorRT releases should use the ONNX Runtime TensorRT execution + provider path. +4. **Shared decoding.** Raw tensors are normalized to the feature-major + `[features, anchors]` layout, decoded to original-image pixel coordinates, + filtered with class-aware NMS and capped at `max_det`. Segmentation + prototypes are carried separately so annotation export does not alter box + results. + +The backend factory infers a format from the model path, accepts case-insensitive +suffixes, and reports an error for an ambiguous NCNN directory. Metadata is +used for names and input size when available; explicit profiles take precedence +for domain-critical class mappings. + +## 3. Canonical post-processing profiles + +The profile is part of the run contract and is printed in the console header. +The VisDrone profile is: + +| Parameter | Value | +| --- | ---: | +| Input | 640 x 640 | +| Confidence threshold | 0.001 | +| NMS IoU threshold | 0.70 | +| Maximum detections | 300 | +| Small-object confidence floor | disabled (`--small-conf=-1`); area threshold 1024 px^2 | +| Class policy | ten canonical VisDrone classes | +| Decode | multi-label per anchor | +| Resize | centered letterbox, pad 114 | + +The SKU-110K profile uses a 1280-square input, confidence 0.25, IoU 0.60 and +the one-class mapping. Callers may override thresholds for deployment, but the +resulting values are recorded and must not be compared with a run using a +different protocol. + +The runner also exposes an optional area-adaptive confidence floor for dense +small-object scenes. When `--small-conf` is non-negative, candidates whose +decoded area in the original image is below `--small-area` use +`min(conf, small-conf)` before class-aware NMS. This setting is intentionally +disabled in the canonical profile; enable it only for a separately identified +NMS sweep and record both values in the manifest. The implementation matches +the thresholding rule in `scripts/mnn_val.py`. + +The evidence manifest and metric JSON carry both numeric values, including the +disabled sentinel (`small_conf=-1`). A reference/candidate delta gate rejects +reports that omit or change either field. + +### 3.1 EsMoE routing semantics + +EsMoE has two materially different inference paths. Eager PyTorch inference +may dispatch only the top-k experts (`native_sparse`), whereas static export +must evaluate and blend all experts when the exporter cannot lower the +data-dependent dispatch (`dense_fallback`). These paths are not interchangeable +baselines: a backend comparison is valid only when the reference and exported +run declare the same `protocol.routing_semantics`. The export summary records +the selected path and the number of layers whose routing flags were changed; +the evidence manifest and both metric evaluators preserve that field for the +strict delta gate. Models without an MoE block use `not_applicable`. + +### 3.2 Class-count safety + +Class metadata is a frequent source of silent parity failures. For an explicit +vertical profile, the runner selects the canonical mapping even when +`--classes auto` is supplied. If the loaded model also exposes names and its +class count disagrees with that profile, startup fails with the expected and +observed counts. A generic/default run continues to use model metadata and +requires the evaluator's `--classes` choice to match the checkpoint. + +### 3.3 Tensor-shape safety + +ONNX outputs are accepted only when a floating-point rank-3 tensor has batch +one, a plausible feature dimension (`4 + nc` or larger) and a positive anchor +dimension. Both `[1, features, anchors]` and `[1, anchors, features]` are +normalized. When several rank-3 tensors remain equally plausible after the +feature/anchor checks, the ONNX and MNN adapters fail explicitly instead of +depending on exporter ordering. Rank-4 outputs are treated as segmentation +prototypes only after their dimensions are validated. MNN and NCNN apply the +same rank, dimension, finite-value and layout checks before entering the shared +decoder; MNN status-returning API calls are checked and an unavailable +accelerator is retried with a CPU session. This turns a wrong export into an +explicit diagnostic instead of a plausible-looking empty prediction file. +The MNN adapter requires float32 public input/output tensors; quantized graphs +remain eligible when quantization is internal and the graph boundary stays +float32. + +## 4. Export and conversion + +`scripts/export_models.py` is a wrapper around the model's native exporter. It +uses a static square input, runs ONNX checker/simplification by default and +writes an export summary containing the checkpoint digest, requested formats, +graph checks and NCNN pair status. `--no-simplify` is retained for diagnosis +only and requires `--allow-unsimplified`. + +NCNN conversion may emit names other than `in0` and `out0`. The exporter writes +the actual input/output/prototype names to both `.metadata.yaml` and +the shared `metadata.yaml` (the latter retains legacy compatibility). The runtime validates +each declared name against the parsed `.param` graph before inference. When no +sidecar is present it resolves a unique graph endpoint, retains the historical +`in0`/`out0`/`out1` fallback, and fails closed when multiple terminal tensors make +the roles ambiguous. A prototype explicitly declared by metadata is mandatory; +missing it is an error rather than a silent box-only result. For a directory input, +exactly one matching `.param`/`.bin` pair is required unless the conventional +`model.ncnn.*` pair is present. + +MNN conversion is intentionally not treated as acceptance evidence. The +converter output must load in the MNN runtime, produce a finite detection +tensor, and pass the same per-image metric gate before it is listed as a +validated backend. + +## 5. Accuracy evaluation + +There are two evaluators because a deployment host may not have the full +training environment: + +* `eval_map.py` delegates AP matching to Ultralytics and is the preferred + formal path when PyTorch is available; +* `eval_map_standalone.py` implements the same ten IoU thresholds with only + NumPy and standard-library dependencies. + +Both parsers are strict about column count, finite values, class range and +positive geometry. The formal path requires one prediction and one label file +for every image outside `--smoke`, rejects duplicate stems and records the +ordered image-list digest. Native VisDrone rows are accepted for diagnosis; +formal runs should use the official `visdrone2yolo` conversion so ignored +regions have defined semantics. + +The result JSON exposes two distinct units: ```text ---backend auto | onnx | ncnn | mnn | trt ---device backend-dependent (cpu, cuda, vulkan, opencl, coreml, trt) +delta_mAP50-95_pp = (candidate - reference) * 100 +delta_mAP50-95_pct = (candidate - reference) / reference * 100 ``` -Source can be an image, a directory, a video, or a `dataset.yaml`. Eighteen robustness tests (corrupt images, missing files, image-size mismatch, backend inference, and output-collision handling) pass on all platforms (`cpp/run_tests.sh`). - -### 4.2 Preprocessing - -Aspect-ratio-preserving **letterbox** (min-side scale, 114 padding) → RGB `/255` NCHW, matching training. The letterbox metadata (scale & pad) is threaded through decode so boxes map back to original-image pixel coordinates in float with no intermediate rounding. - -### 4.3 Decode, NMS, and the mAP-parity subtlety - -An early version of the C++ pipeline read **1.19 mAP points low** despite bit-accurate inference. We found the cause in the decode: ultralytics `val` uses **`multi_label=True`**, one detection per class scoring at or above the threshold per anchor, not a single argmax. Reproducing that (`--multi-label` mode) recovered the gap exactly (0.3375 → 0.3494 mAP50). NMS is **per-class** (`agnostic=False`), implemented with a class-offset trick (shift each box by `class_id × (2·max(image dimension) + 8192)` so cross-class boxes never suppress each other), and capped at 300 detections. Default `conf` is low, appropriate to VisDrone's small/dense objects; `--conf`/`--iou` are tunable per deployment. - -### 4.4 Instance Segmentation - -The runtime also decodes instance segmentation models. A segmenter emits a second output, the prototype tensor, alongside the detection tensor; the exported metadata carries `task=segment` plus `proto`/`nm` so the runtime dispatches on the model. Masks are reconstructed by combining the per-instance mask coefficients with the prototypes cropped to each box and thresholded; the GUI runners composite them with anti-aliasing to avoid serrated edges. - -The shipped default for both GUI runners is **`YOLO-Master-v0.1-seg-N`** (`task: segment`, imgsz 640, stride 32, **COCO-80** classes), exported to ONNX / MNN / ncnn. Note the domain change: the detection results throughout this report are VisDrone 10-class, while the bundled segmenter is COCO-80. **No segmentation mAP is reported** -- the metric harness in Section 5 is detection-only, and extending it to mask AP is future work (Section 11). - -### 4.5 Dependency cut for a portable bundle - -The first self-contained Linux bundle was **231 shared libraries, 129 MB** since Ubuntu's `libopencv_imgcodecs` links **GDAL**, which transitively pulls in PostgreSQL (`libpq`), MySQL, `libpoppler` (PDF), HDF5, and the GIS stack, and `libopencv_dnn` pulls protobuf. An object detector does not need a Postgres client. We removed both by replacing `cv::imread`/`imwrite` with **stb_image** and `cv::dnn::NMSBoxes`/`blobFromImage` with a hand-written NMS and a manual NCHW pack. That drops the OpenCV surface to **core + imgproc only**: 231 → 10 libraries, **129 → 35 MB**, at a cost of a **0.087%** detection-count difference (stb vs OpenCV JPEG decoders diverge by sub-LSB pixel values on a handful of borderline boxes), inside tolerance. On Linux the binary is `$ORIGIN`-rpath'd and verified to run with no `LD_LIBRARY_PATH`; on Windows the MSVC runtime is bundled so targets need no VC++ Redistributable. - -## 📊 5. Accuracy validation - -### 5.1 Methodology - -Every model (PyTorch, ONNX, NCNN, MNN, INT8, CUDA, Vulkan, OpenCL, TensorRT) is scored through a single path: predictions at **conf 0.001, NMS iou 0.7, multi-label, cap 300** (ultralytics `val` settings), fed to ultralytics' own `DetMetrics` + `box_iou` + `match_predictions` (`eval_map.py`). This common procedure makes the numbers comparable across formats and directly comparable to the ultralytics reference. ONNX/ncnn/MNN/GPU predictions are produced by the C++ runtime; the Jetson uses a dependency-free reimplementation of the same harness `eval_map_standalone.py`. Core ML is the one path outside this harness and will be included later (Section 2.4). - -### 5.2 Results (548 VisDrone val images) - -| Model | Device | mAP50-95 | Δ mAP50-95 vs PyTorch (percentage points) | -|---|---|---|---| -| **PyTorch (reference)** | -- | 0.2036 | -- | -| ONNX | CPU | 0.2034 | **−0.02 pp** | -| NCNN | CPU | 0.2034 | **−0.02 pp** | -| MNN | CPU | 0.2034 | **−0.02 pp** | -| ONNX (CUDA) | H200 SXM | 0.2033 | **−0.03 pp** | -| ONNX (CUDA) | RTX 5070Ti Laptop | 0.2033 | **−0.03 pp** | -| NCNN (Vulkan, FP16) | RTX 5070Ti Laptop | 0.2034 | **−0.02 pp** | -| MNN (OpenCL, FP16) | RTX 5070Ti Laptop | 0.2034 | **−0.02 pp** | -| TensorRT FP16 | Jetson Orin Nano 4GB | 0.2029 | **−0.07 pp** | -| INT8 (mixed) | CPU | 0.1952 | **−0.84 pp** | - -All three FP32 CPU export formats land on **identical** mAP (0.2034), as expected for the same graph, at **−0.02 percentage points** from PyTorch, 25× inside the 0.5-point target. INT8 is **−0.84 percentage points**, inside the 1.0-point target. (The INT8 mAP50 drop is larger, −1.27 percentage points, reflecting slightly softer classification confidences at INT8; the budget is defined on mAP50-95, which passes.) - -The GPU rows extend the CPU measurements and expose a backend-dependent performance pattern (see Section 6). - -### 5.3 Numerical parity, isolating format from pipeline - -Because the FP32 formats share a graph, we verify fidelity directly rather than only through mAP. Feeding **identical letterboxed inputs** to MNN and the source ONNX across 100 val images yields **max|Δ| = 0.096, mean|Δ| = 9.7e-05** on the raw `[1,14,8400]` output. The max is a single box-coordinate least-significant bit (coordinates run to ~640; 0.096 px is nothing); the mean is negligible. Detection counts over the full set are effectively equal (ONNX 157,464 vs ncnn 157,465 at conf 0.001). This distinguishes *format equivalence* from *coincidentally similar mAP*. - -The same analysis identified a **false alarm** on the CUDA path: a raw `max|Δ| = 2.31` was traced to FP32 box-coordinate variance in a single anchor, while functional mAP remained identical. A scalar max-absolute-difference gate would therefore reject a functionally equivalent model; separating box and class channels provides the relevant diagnostic. - -## 🚀 6. GPU acceleration across all three backends - -Each backend has a different native accelerator, and the runtime maps a single **Device: CPU / GPU** switch onto all of them: **ONNX → CUDA**, **ncnn → Vulkan**, **MNN → OpenCL**, with ncnn and MNN running **FP16** on the GPU. Every backend falls back to CPU cleanly and surfaces the reason when a provider is unavailable, which matters in a GUI where the user cannot read a stderr log. - -Measured on the same 548 VisDrone images, one consumer laptop GPU (Win11): - -| Backend | CPU | GPU | Speedup | -|---|---|---|---| -| ONNX → CUDA | 40 ms | **9.0 ms** | 4.4× | -| MNN → OpenCL (FP16) | 74 ms | **19.1 ms** | 3.9× | -| NCNN → Vulkan (FP16) | 80 ms | **20.2 ms** | 4.0× | - -Three findings: - -**FP16 on the GPU is accuracy-neutral.** The ncnn-Vulkan and MNN-OpenCL FP16 paths both score **0.2034 mAP50-95, identical to their FP32 CPU counterparts**, and CUDA scores 0.2033. This contrasts with Section 3: half-precision is a *uniform* reduction in mantissa that the softmax/sigmoid structures tolerate, whereas INT8 is a *range mapping* that the same structures amplify. For this architecture, FP16 is therefore the preferred accelerator precision and INT8 offers no corresponding benefit. - -**The x86 backend ranking is preserved on GPU.** ORT retains an approximately 2× lead over MNN and ncnn on the GPU (9.0 versus 19.1/20.2 ms), similar to the CPU results (40 versus 74/80 ms). The ARM comparison remains untested because the Jetson path uses TensorRT (Section 9) rather than ncnn or MNN. - -**The model does not saturate a datacenter GPU.** An H200 SXM (7.8 ms) is only ~15% faster than an RTX 5070Ti Laptop (9.0 ms). At nano scale with a 640×640 input, per-launch overhead and memory traffic dominate, not FLOPs -- so for this model class a consumer GPU is the sensible deployment target and the datacenter part buys almost nothing. - -## ⏱️ 7. Latency and throughput - -Per-frame inference, VisDrone val: - -| Platform | Backend | Device | infer (ms) | FPS | -|---|---|---|---|---| -| Linux CPU (4-thread) | ONNX (ORT) | CPU | 40.0 | 25.0 | -| Windows 11 CPU | ONNX (ORT) | CPU | 37.6 | 25.4 | -| Linux CPU (4-thread) | MNN | CPU | 74.0 | 13.5 | -| Linux CPU (4-thread) | NCNN | CPU | 80.0 | 12.5 | -| Windows 11 CPU | NCNN | CPU | 80.1 | 12.2 | -| Linux CPU (4-thread) | ONNX INT8 (mixed) | CPU | 137 | 7.2 | -| Linux | ONNX (ORT) | **CUDA / H200 SXM** | 7.8 | **128** | -| Windows | ONNX (ORT) | **CUDA / RTX 5070Ti Laptop** | 9.0 | **111** | -| Windows | MNN | **OpenCL / RTX 5070Ti Laptop** | 19.1 | 52.4 | -| Windows | NCNN | **Vulkan / RTX 5070Ti Laptop** | 20.2 | 49.5 | -| Jetson Orin Nano 4GB | **TensorRT FP16** | Orin iGPU | 27.8 | 35.7 | -| macOS (M4 Max) | **Core ML** | MPS / ANE | 17.4 | 57.4 | - -CPU latencies are measured on one x86 host with four threads. The ordering is consistent with the respective optimization targets: **ORT is approximately 2× faster than MNN and NCNN on x86**, while the latter runtimes prioritize mobile and ARM deployments; the ordering persists on the GPU (Section 6). **INT8 is the slowest row** for the reasons in Section 3.6. The Core ML row is latency-only (no mAP, Section 2.4); its 17.4 ms result places the M4 Max between the consumer GPU and CPU measurements at a lower power envelope. - -No single format fits every deployment target; the results are therefore reported for four deployment distributions below. - -## 🌐 8. Cross-platform builds and distribution - -A single CMake tree now targets **four platforms**: - -| Platform | Toolchain | Backends | Distribution | -|---|---|---|---| -| Linux x86_64 | GCC / CMake | ONNX (+CUDA EP), NCNN, MNN | 35 MB `$ORIGIN`-rpath'd tarball, 10 libs, verified isolated | -| Windows 10/11 x64 | VS 2022 / 2026, MSVC 19.5x | ONNX (+CUDA), NCNN (+Vulkan), MNN (+OpenCL) | Self-contained zip, MSVC runtime bundled; lean and CUDA variants | -| macOS 14+ | Swift 5.9+, Xcode CLT | Core ML | Universal (Apple Silicon + Intel) `.app`, Developer-ID signed and **notarized** | -| Jetson Orin (JetPack 7) | aarch64 GCC / CMake | TensorRT (priority), ONNX, NCNN, MNN | aarch64 tarball, OpenCV bundled, TensorRT/CUDA from JetPack | - -The Windows port surfaced three concrete portability issues, each fixed in the build system rather than worked around: `Ort::Session` takes `const wchar_t*` on Windows (a platform `ORTCHAR_T` shim); the prebuilt OpenCV config doesn't recognize the VS 2026 toolset and reports an empty runtime (point `OpenCV_DIR` at the concrete `vc16/lib` config); and the exe needs the MSVC runtime on clean targets (bundled via `InstallRequiredSystemLibraries`). SDK locations live in a gitignored `sdk-paths.cmd` rather than in the build scripts, and `.cmd` entry points are provided alongside `.ps1` because PowerShell execution policy blocks the latter on default Windows installs. - -The Windows GUI ships in two flavours: a lean bundle that gets GPU inference through ncnn-Vulkan and MNN-OpenCL using only the graphics driver, and a considerably larger CUDA bundle that additionally carries the cuDNN and CUDA runtime libraries for the fastest ONNX path. Neither requires a CUDA toolkit installation on the target. The executable is **not code-signed** (that needs a paid certificate), so SmartScreen warns on first launch; the macOS app, by contrast, is signed and notarized and installs by double-click. - -## 🤖 9. Embedded GPU deployment: Jetson Orin - -The runtime was taken to a **Jetson Orin Nano 4 GB**. The same CMake produces a native aarch64 binary with a `trt_backend` that deserializes a prebuilt engine and runs it via `enqueueV3`, joining the other backends behind the same interface. The engine is built on-device with `trtexec` from the exported ONNX. - -**Result.** The FP16 engine runs at **27.8 ms/frame GPU compute (35.7 FPS)**, and the on-device accuracy over the full 548 VisDrone val set is **mAP50-95 0.2029, ∆ −0.07 pp versus the PyTorch FP32 baseline** (0.2036), matching the x86 ONNX result to within 0.2 mAP points. - -**FP16, not INT8.** Section 3.6 reserved the INT8 *throughput* evaluation for this path, based on the expectation that tensor-core INT8 would invert the CPU result. For this model it does not. The mixed-precision assignment from Section 3 keeps the attention blocks, head, and router in higher precision, so INT8 leaves the compute-heavy area-attention on FP32/FP16 kernels; combined with TensorRT's lower INT8 accuracy relative to the ONNX Runtime path, the calibrated INT8 engine measures **0.3202 mAP50 at 21.7 FPS, slower and less accurate than FP16**. When attention dominates computation and is not quantized, **FP16 is the appropriate embedded target**. Taken together with Section 6, FP16 is the preferred target on every tested accelerator: it is accuracy-neutral on desktop GPUs and incurs minimal accuracy cost on Jetson Orin, whereas INT8 does not provide a benefit for this model. - -**Build notes.** Two toolchain specifics are worth recording. On sm87 with TRT 10.16.2 a pure-FP16 build fails at low builder-optimization levels (the timing model references an sm80 shader that has no sm87 base); `--builderOptimizationLevel=3` selects tactics by on-device profiling instead and builds cleanly. And an ONNXRuntime-quantized QDQ model must use symmetric activations and non-quantized bias to be accepted by TensorRT's parser `quantize_int8.py --symmetric`. The Nano 4 GB version also needs swap for the engine *build* (inference itself uses only ~20 MB). - -## 💻 10. GUI runners for Windows and macOS - -The CLI is efficient for benchmarking and batch work but is less accessible for interactive use. Two native GUI runners use the same pipeline. - -**macOS -- YOLO-Master CoreML Runner** (v1.0.0-macos). A SwiftUI frontend over Core ML: `YOLOMasterKit` carries the pipeline (preprocess, detect, annotate, image I/O) and the app layer adds camera and UI. Universal binary, Developer-ID signed & notarized, macOS 14+ (the SwiftUI API floor -- `onKeyPress`, zero-parameter `onChange`). - -Screen1 - -**Windows -- YOLO-Master Windows Runner GUI** (v1.0.0-windows). Native Win32 + Direct3D 11 + Dear ImGui. The important architectural property is that it **compiles the CLI's own runtime sources** (`cpp/common.cpp`, the backend implementations, `stb_impl.cpp`) rather than reimplementing them, so letterbox, decode, per-class NMS, and the class palette are identical to the validated CLI path. Sharing the translation unit is what lets Section 5's mAP table apply to what the user actually runs. It carries all three backends in one executable, switchable at runtime, which makes backend comparison on the same image a single-click operation. - -49 2 - -The shared interaction model is based on two design decisions: - -- **Forward once, tune efficiently.** Confidence, IoU, box style, labels, and letterbox-vs-stretch all redraw from a cached forward pass. Inference never re-runs when a threshold moves, so the controls remain usable at interactive rates even where a frame costs 80 ms. Candidates are cached down to a 0.05 confidence floor so lowering the threshold remains immediate. -- **Two-phase media handling.** Folders and videos are inferred once with a progress bar, then browsed or scrubbed from cache. A 30 fps clip therefore plays back at 30 fps regardless of model speed, because inference is off the playback path entirely. The webcam path instead infers on a background thread with drop-late-frames, trading completeness for latency. - -## 🔖 11. Future work -- **Core ML accuracy validation.** The one backend without an mAP number (Section 2.4). A `--save-txt`-compatible dump from the macOS app would feed directly into `eval_map.py` and make the Core ML row comparable with the other entries in Section 5.2. -- **Segmentation metrics.** The harness is detection-only; the bundled `v0.1-seg-N` is validated visually. Mask AP against the COCO protocol would close it. -- **ARM-native backend comparison.** Section 6 leaves the original hypothesis that ncnn and MNN close the gap on ARM -- untested since the Jetson work went through TensorRT. Running the ncnn-Vulkan and MNN-OpenCL paths on the Orin would settle it on the hardware they were designed for. -- **Non-NVIDIA GPUs.** The Vulkan and OpenCL paths should already work on AMD, Intel, and Ascend hardware. -- **Production drone platform (DJI Manifold 3).** VisDrone is aerial/drone imagery, so the natural production target is an onboard drone computer. [DJI Manifold 3](https://enterprise.dji.com/manifold-3) is an **NVIDIA Orin NX-based** enterprise edge computer purpose-built for drones where the exact aarch64 + TensorRT path in Section 9 deploys onto it directly. Validating this pipeline on the Manifold 3 exercises **real-time on-drone inference in operational conditions** (aerial surveillance, infrastructure inspection, search-and-rescue), closing the loop from VisDrone training to production drone edge deployment. - ---- - -## Reproducibility +Use `--max-abs-delta-pp` for an absolute percentage-point budget. The +relative `--max-abs-delta-pct` option is retained for compatibility and cannot +be combined with the absolute gate. The relative gate requires a positive +reference metric; either gate requires the same image-list/protocol metadata. + +When a gate fails, `scripts/prediction_diff.py` matches same-class boxes by IoU +and reports missing boxes, confidence differences and coordinate differences +per image. It accepts the same BOM-tolerant, quoted ordered image list as the +metric evaluators and can enforce an explicit `--image-root`; this prevents a +diagnostic report from silently analyzing a different file set. The report +separates preprocessing/decode errors from genuine model quality changes +without rerunning inference. + +## 6. INT8 calibration + +The quantization helper is deliberately conservative. It: + +1. selects a deterministic, sorted calibration list; +2. requires at least 300 images; +3. applies the same letterbox/RGB/NCHW preprocessing contract; +4. compares calibration image content hashes with the validation list; +5. records the selected list digest, quantizer settings and exclusion patterns. + +The default exclusion patterns protect detection-head, attention and routing +nodes when they exist. A pattern matching no graph node is an error, preventing +a command-line typo from silently changing the precision recipe. The generated +summary is always marked `acceptance_ready: false`; only a subsequent full +prediction/evaluation run can establish an INT8 result. + +## 7. Benchmark methodology + +Latency comparisons are meaningful only when the following are held constant: + +* ordered image list and image decoding path; +* input size, precision, confidence/IoU policy and maximum detections; +* CPU/GPU device, runtime build and thread count; +* warm-up count and timed repeat count. + +The runner reports preprocessing, inference, postprocessing and end-to-end +times per image, followed by mean, P50, P95, P99 and FPS. With +`--benchmark-json`, an optional sidecar records the resolved protocol, host +platform, compiler, CPU model, logical CPU count and build date; `--csv` retains +the per-image timing rows. The evidence manifest additionally records exact file +hashes. Virtual-machine +measurements are valid diagnostics but must be labelled as VM results and must +not be generalized to ARM or Jetson hardware. + +Capture a separate host/toolchain snapshot before the run: + +```bash +python3 scripts/collect_environment.py \ + --repo-root . --backend onnx --execution-provider cpu \ + --threads 4 --warmup 2 --runs 20 \ + --output artifacts/environment.json +``` -The C++ runtime, GUI frontends (`gui/`, `mac/`), Core ML exporter (`coreml_export/`), validation scripts (`quantize_int8.py`, `eval_map.py`, `eval_map_standalone.py`, `mnn_val.py`, `mnn_parity.py`, `package_linux.sh`), and Jetson tooling (`jetson/`, including `DEPLOYMENT_LOG.md`) are included in this repository. Exported models and prebuilt bundles for Linux, Windows, macOS, and Jetson Orin are available from the [release page](https://github.com/skywalker-lt/yolo-master-edge/releases). +The collector is dependency-free and reports missing optional tools explicitly. +Its output follows [`environment.schema.json`](environment.schema.json) and can +be attached as `--report environment=artifacts/environment.json` when creating +the evidence manifest. + +For publication, report the results in a table whose values point to the +corresponding manifest and raw logs. The table is intentionally a schema, not +a set of default numbers: + +| Backend | Export/checkpoint digest | Image count/list digest | mAP50-95 | Delta (pp) | End-to-end P50/P95/P99 | FPS | Host and runtime | +| --- | --- | --- | ---: | ---: | --- | ---: | --- | +| PyTorch reference | recorded in manifest | recorded in manifest | from metric JSON | -- | from timing log | from timing log | recorded in manifest | +| ONNX Runtime | recorded in manifest | recorded in manifest | from metric JSON | from metric JSON | from timing log | from timing log | recorded in manifest | +| NCNN or MNN | recorded in manifest | recorded in manifest | from metric JSON | from metric JSON | from timing log | from timing log | recorded in manifest | + +No cell is a result until the reviewer can recompute it from the stated model +digest, image-list digest and per-image predictions. Compute latency and +end-to-end latency are reported separately; a virtual-machine value is labelled +as such. + +## 8. Evidence manifest + +`evidence_manifest.py` and `evidence-manifest.schema.json` define the release +boundary. An acceptance manifest contains: + +* dataset name/split, ordered image records and a list digest; +* required training provenance, including base-model revision, dataset + version, epoch/seed configuration and the exact training command; +* the complete protocol and class profile; +* checkpoint and every exported model, each with file hashes; +* labels and predictions with matching counts; +* calibration records and an explicit disjointness assertion for INT8; +* environment, source revision, command line, content-hashed metric/benchmark + reports and gate values. + +`validate` checks the structure; `verify` additionally recomputes hashes under +the supplied roots. The template intentionally leaves labels and predictions +null and cannot pass the acceptance validator. This prevents a release note or +an empty directory from being mistaken for a completed experiment. + +## 9. Build and portability controls + +The CMake target enables each backend only when its headers and library are +found. `REQUIRE_ORT`, `REQUIRE_NCNN` and `REQUIRE_MNN` turn missing SDKs into +configuration errors; `ALLOW_NO_BACKENDS` is reserved for dependency-light +CLI diagnostics. On Windows, model and image paths are converted from UTF-8 to +UTF-16 before opening; the JPEG writer uses the same wide-path handling. On +Linux, the release script computes the recursive shared-library closure and +sets an `$ORIGIN/lib` RPATH while leaving system glibc and accelerator drivers +to the target host. + +The ARM64 toolchain file describes cross-compilation but does not claim that a +cross-compiled binary has run on hardware. A native Jetson run must archive the +binary, engine, device/software versions and raw log together with the same +manifest. + +## 10. Reproducibility status of this checkout + +The repository-level contract tests cover profile resolution, parser failures, +shape normalization, evidence-manifest gates and prediction diagnostics. A +previous Ubuntu 22.04 smoke run loaded a YOLOv5s ONNX model on one image; that +is a functional L1 check, not an EsMoE-N VisDrone accuracy result. The full +Issue #51 acceptance record remains intentionally pending until a real +EsMoE-N checkpoint, dataset split and target-platform logs are supplied. + +This boundary is important: a reproducible procedure is useful only when its +limitations are stated as precisely as its successes. + +## 11. Publication record + +The public submission should contain a compact result table followed by links to +the machine-readable evidence. Use one row per backend and keep the protocol +identical across rows: + +| Backend | Model/checkpoint SHA256 | Image-list SHA256 | Images | mAP50-95 | Delta (pp) | P50/P95/P99 (ms) | FPS | Platform | +| --- | --- | --- | ---: | ---: | ---: | --- | ---: | --- | +| PyTorch reference | evidence manifest | evidence manifest | N | metric JSON | -- | timing CSV | timing CSV | environment JSON | +| ONNX Runtime | evidence manifest | evidence manifest | N | metric JSON | metric JSON | timing CSV | timing CSV | environment JSON | +| NCNN or MNN | evidence manifest | evidence manifest | N | metric JSON | metric JSON | timing CSV | timing CSV | environment JSON | + +The accompanying text should state the dataset release and split, checkpoint +provenance, preprocessing and NMS parameters, runtime versions, thread policy, +warm-up/repeat counts, and the exact commands used. A platform is listed as +validated only when both compilation and an inference run were executed on that +platform. Cross-compilation, CI compilation, or a virtual-machine smoke test +must be labelled accordingly and must not be presented as native device +evidence. + +For a short discussion post, use `TECHNICAL_SUMMARY_ZH.md` as the narrative and +attach the evidence manifest, metric JSON, timing CSV/JSON, prediction archive, +environment snapshot and model/export summaries. Keep all unavailable fields +explicitly marked as pending until the corresponding files can be verified. diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/TECHNICAL_SUMMARY_ZH.md b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/TECHNICAL_SUMMARY_ZH.md new file mode 100644 index 000000000..81c90f2a2 --- /dev/null +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/TECHNICAL_SUMMARY_ZH.md @@ -0,0 +1,290 @@ +# Issue #51 边缘部署与一致性验证技术总结 + +## 摘要 + +本文档给出 YOLO-Master EsMoE-N 在 VisDrone 或 SKU-110K 垂直场景中的 +边缘部署验证协议、实现边界与证据要求。文档的目标是使一次实验能够被 +第三方在另一台 Linux 或 Windows 机器上复核,而不是仅报告一个不可追溯 +的 mAP 或 FPS 数字。 + +本分支已经提供 ONNX Runtime、NCNN 与 MNN 的导出辅助程序、C++ 推理运行 +时、统一的 letterbox 与后处理、mAP 评估器、量化辅助程序以及 CMake 构建 +入口。当前仓库不包含 EsMoE-N 权重、VisDrone 数据集、548 张验证图像的 +逐图预测文件或目标机器的完整原始日志。因此,本分支目前的结论是 +“验证基础设施可复现”,不能据此声称已经完成 Issue #51 所要求的完整 +训练、跨后端精度验收或 ARM64 实机部署。 + +## 面向评审的成果摘要 + +本提交将 Issue #51 的部署要求落实为一套可执行、可审计的验收流程。成果 +与证据边界如下,便于在 Issue 评论或 Pull Request 描述中直接引用: + +| 成果项 | 可核对的实现或产物 | 当前证据等级 | +|---|---|---| +| 统一 C++ 推理入口 | ONNX Runtime、NCNN、MNN 后端适配器;同一预处理、解码和 NMS | L0(代码与契约测试) | +| 导出与转换检查 | ONNX checker/简化、NCNN param/bin 配对和 sidecar 名称、MNN 转换提示 | L0(结构检查) | +| 精度验收协议 | 固定有序图像清单、`eval_map.py`、百分点门禁、逐图 prediction diff | L0(工具可运行) | +| INT8 校准协议 | 至少 300 张训练图像、内容 hash 去重、默认敏感节点保留 FP32 | L0(工具可运行) | +| Linux 运行验证 | Ubuntu 22.04 x86_64 上 YOLOv5s ONNX 单图 smoke:6 个检测框 | L1(功能 smoke) | +| EsMoE-N 完整结果 | VisDrone/SKU-110K、至少 500 张验证图、mAP 与双平台日志 | 待目标机器补充 | + +这里的 L0/L1/L2--L4 与第 3 节定义一致。表中“工具可运行”只表示接口、 +输入校验和错误门禁已经实现;只有同时归档模型、图像清单、预测、日志及 +SHA256 后,才可将相应项目升级为正式验收结果。 + +该实现覆盖 ONNX Runtime、NCNN 与 MNN 的 C++17 推理路径,并将导出检查、 +预处理/后处理、精度评估、INT8 校准和证据归档纳入同一协议。现有契约测试 +与 Ubuntu 22.04 x86_64 的单图 smoke 证明了基础链路可运行;完整 EsMoE-N +精度、量化和目标设备性能仍须在真实 checkpoint、数据集及硬件上按本协议 +复现后报告。 + +## 1. 问题定义与验收边界 + +Issue #51 的验收对象是垂直场景下的模型部署闭环,至少应覆盖以下内容: + +| 类别 | 最低要求 | 本分支状态 | +|---|---|---| +| 数据与模型 | VisDrone 或 SKU-110K 微调 checkpoint | 需由目标机器补充 | +| 导出 | ONNX 加简化、opset/checker 校验;NCNN 或 MNN 转换 | 脚本已提供 | +| 预处理 | 与训练一致的 RGB、NCHW、宽高比保持 letterbox | 运行时已提供 | +| 后处理 | 类别感知 NMS、可复现阈值、最大检测数 | 运行时已提供 | +| 精度 | 同一有序验证集不少于 500 张,报告 mAP50 与 mAP50-95 | 评估器已提供,实测数据待补 | +| INT8(可选) | 训练图像校准不少于 300 张,验证集严格隔离 | 量化脚本已提供,精度门禁待补实测 | +| 性能 | 固定线程、预热、重复次数,报告均值/P50/P95/P99/FPS | C++ CSV 入口已提供 | +| 平台 | 至少两个目标平台完成 CMake 构建与运行 | 当前仅有 Linux x86_64 smoke 记录 | + +“已提供”表示代码路径和输入校验存在;“实测数据待补”表示没有把 +第三方报告中的数字写成当前仓库的实验结果。 + +### 1.1 训练与数据来源记录 + +实验结果的可复核性依赖训练来源,而不仅是导出文件。正式提交应记录 +基础模型或代码提交号、微调数据集版本及划分、类别映射、训练 epoch、 +随机种子、最佳 checkpoint 路径和 SHA256,以及导出所用的 Ultralytics、 +PyTorch、ONNX Runtime 和转换工具版本。若这些信息缺失,mAP 数字即使能够 +复现,也无法确认对应的是同一模型和同一数据划分。本分支没有替代这些 +信息的 checkpoint,必须由目标机器补齐。 + +建议将训练元数据按下表归档,并与 `best.pt` 放在同一证据包中: + +| 项目 | 记录内容 | +|---|---| +| 代码与基础模型 | Git commit、模型配置或基础权重版本 | +| 数据 | 数据集发布版本、train/val 划分、类别映射、图像数量 | +| 优化设置 | epoch、batch size、优化器、学习率策略、随机种子、deterministic 设置 | +| 软件环境 | Python、PyTorch、Ultralytics、导出器和运行时版本 | +| 主机记录 | `scripts/collect_environment.py` 生成主机、编译器、SDK 和可选 GPU 信息;缺失项显式标记,不以默认值代替 | +| 产物 | checkpoint 路径、文件大小、SHA256、导出命令 | + +表中每一项均应对应可读取的配置或原始日志;缺失项应标记为 +“未记录”,不得以默认值补齐。 + +## 2. 可复核实验协议 + +### 2.1 固定输入集合 + +验证集必须先生成一个有序的图像清单,并在所有后端复用。建议使用 +VisDrone 的 548 张验证图像;若使用 SKU-110K,应记录实际数量和划分来源。 +清单不得包含重复 stem,否则 C++ 的 TXT 输出会发生覆盖风险。 + +对于标准 VisDrone 验证划分,建议先生成并冻结 548 行的 UTF-8 路径清单, +再将该清单的 SHA256 写入 manifest。所有后端、PyTorch 基线和性能测试均应 +按同一行序读取;目录遍历顺序不能作为隐含协议。若实际划分不是 548 张, +应在结果中报告真实数量,并说明筛选规则。 + +C++ runner 的 `--source` 可直接接收该清单(`.txt`)。清单中的相对路径以 +清单所在目录为基准解析,空行和 `#` 注释行忽略;缺失文件、不支持的后缀及 +重复文件 stem 会在推理前报错。这样可确保逐图 TXT 文件与 manifest 一一对应, +而不是依赖不同文件系统的目录枚举顺序。 + +```bash +python scripts/evidence_manifest.py create \ + --dataset visdrone --split val \ + --images artifacts/visdrone-val.list \ + --image-root /data/VisDrone/images/val \ + --labels /data/VisDrone/labels/val \ + --predictions artifacts/onnx_txt \ + --checkpoint runs/esmoe_n/weights/best.pt \ + --training-metadata artifacts/training-provenance.json \ + --model onnx=artifacts/esmoe_n.onnx \ + --model mnn=artifacts/esmoe_n.mnn \ + --report metrics=artifacts/onnx_map.json \ + --command "./yolomaster_edge --profile visdrone --source artifacts/visdrone-val.list" \ + --acceptance \ + --output artifacts/onnx-evidence.json +``` + +该命令对每张图记录相对路径、字节数和 SHA256,并记录清单摘要、运行平台、 +Git 提交号和后处理参数。模型和数据不应直接提交到 Git;建议将它们与 +预测、日志和清单一起放入 GitHub Release,并在技术总结中引用 Release 的 +SHA256。 + +两个 mAP 评估器分别记录仅包含有序相对路径的 +`image_manifest_sha256`,以及对每一行 `相对路径 + 文件 SHA256` 计算的 +`image_list_sha256`。后者与 evidence manifest 使用同一算法。参考结果门禁 +同时比较这两个字段;即使文件名与顺序未变,只要图像内容被替换,比较也会 +失败,从而避免数据版本漂移被误判为跨后端一致。若清单位于数据集目录之外, +必须使用 `--image-root` 指定归一化根目录;根目录之外的条目会被拒绝。 + +将证据包从 Ubuntu 传回 Windows 或 Release 目录后,可用 `verify` 重新核对 +文件内容: + +```bash +python scripts/evidence_manifest.py verify artifacts/onnx-evidence.json \ + --acceptance \ + --images-root /data/VisDrone/images/val \ + --labels-root /data/VisDrone/labels/val \ + --predictions-root artifacts/onnx_txt \ + --models-root artifacts \ + --checkpoint-root runs/esmoe_n/weights \ + --calibration-root /data/VisDrone/images/train +``` + +### 2.2 预处理与后处理 + +VisDrone 的默认 profile 为 `imgsz=640`、`conf=0.001`、`iou=0.70`、 +`max_det=300`、`multi_label=true`。输入采用居中 letterbox,填充值为 +114,颜色顺序为 RGB,张量布局为 NCHW,数值归一化为 `float32/255`。 +运行时的通用默认值仍保留给普通检测场景;验收命令应显式写出 +`--profile visdrone`,并在日志中保留最终生效的参数。 + +EsMoE 还必须记录路由语义:静态 ONNX/NCNN 导出采用 +`dense_fallback`,PyTorch 基线须使用相同路径;只有在后端确实保留 +top-k dispatch 并完成独立核验时,才可标记为 `native_sparse`。不同语义的 +结果不得直接计算精度差值。 + +小目标场景可在单独的 NMS sweep 中比较 `conf`、`small_conf`、面积阈值、 +IoU 与 `max_det`。sweep 的结果不能替代固定协议下的主验收结果;每一次 +比较都必须使用同一图像清单和同一类别映射。 + +### 2.3 精度指标及单位 + +评估器同时输出两种差异,避免把单位混用: + +* `delta_mAP50-95_pp = (candidate - reference) * 100`,单位为百分点; +* `delta_mAP50-95_pct = (candidate - reference) / reference * 100`,单位为相对百分比。 + +Issue #51 的验收命令推荐使用 `--max-abs-delta-pp 0.5`(FP32)和 +`--max-abs-delta-pp 1.0`(INT8)。旧参数 `--max-abs-delta-pct` 仅用于 +明确的相对百分比比较,不能与百分点门禁同时使用。 + +正式评估必须使用 `visdrone2yolo` 转换后的 YOLO 标签。原生 VisDrone +`x,y,w,h,score,category,...` 行仅用于诊断,不能作为忽略区域语义已经 +定义的正式验收标签。 + +```bash +python scripts/eval_map.py \ + --preds artifacts/onnx_txt \ + --images artifacts/visdrone-val.list \ + --image-root /data/VisDrone/images/val \ + --labels /data/VisDrone/labels/val \ + --label-format yolo --classes visdrone \ + --routing-semantics dense_fallback \ + --imgsz 640 --conf 0.001 --iou 0.70 --max-det 300 --multi-label \ + --min-images 500 \ + --reference-json artifacts/pytorch_map.json \ + --max-abs-delta-pp 0.5 \ + --json artifacts/onnx_map.json +``` + +### 2.4 性能测量 + +各后端必须在相同 CPU、输入尺寸、线程数和图像顺序下测量。建议先预加载 +图像,进行至少 10 次预热,然后进行 100 次以上重复,并将预处理、推理、 +后处理和端到端耗时分别记录。报告至少包含均值、P50、P95、P99 与 FPS, +同时给出 CPU 型号、运行时版本、编译器、线程数和精度模式。运行器可通过 +`--benchmark-json` 输出包含协议、主机架构、编译器、CPU、逻辑 CPU 数、 +构建日期和汇总统计的机器可读 sidecar;`--csv` 保留逐图计时。虚拟机结果 +只能标记为虚拟机结果,不应外推为 ARM 或 Jetson 实机性能。 + +主机与工具链信息可在运行前由无第三方依赖的 +`scripts/collect_environment.py` 采集,并按 `environment.schema.json` 校验。 +该记录应与 benchmark sidecar 和 evidence manifest 一并归档;它描述运行 +条件,不构成独立的精度或性能结论。 + +发布时建议使用统一结果表,并为每个单元格保留证据引用: + +| 后端 | 模型/清单摘要 | 图像数 | mAP50-95 | 相对参考差值(百分点) | 端到端 P50/P95/P99 | FPS | 主机与运行时 | +|---|---|---:|---:|---:|---|---:|---| +| PyTorch 基线 | manifest / JSON | N(VisDrone 为标准 548;SKU-110K 按实际划分) | JSON | -- | CSV | CSV | manifest | +| ONNX Runtime | manifest / JSON | N(VisDrone 为标准 548;SKU-110K 按实际划分) | JSON | JSON | CSV | CSV | manifest | +| NCNN 或 MNN | manifest / JSON | N(VisDrone 为标准 548;SKU-110K 按实际划分) | JSON | JSON | CSV | CSV | manifest | + +该表是报告结构模板,不是本分支的实验结果;只有在模型摘要、图像清单 +摘要、逐图预测和原始日志均可核验时,才可填入数值。 + +## 3. 证据分级 + +为避免把 smoke test 误写成完整验收,采用以下分级: + +| 级别 | 内容 | 可支持的结论 | +|---|---|---| +| L0 | Python 契约测试、CMake 诊断构建、参数错误处理 | 接口与静态约束成立 | +| L1 | 真实 ONNX/图片单图或小子集运行 | 模型加载、预处理、解码链路可运行 | +| L2 | >=500 张固定验证集、参考 JSON、逐图预测和 SHA256 | 可审计的 FP32 精度验收 | +| L3 | L2 加 >=300 张独立校准集、INT8 mAP 门禁 | 可审计的 INT8 验收 | +| L4 | 两个平台原生构建、同图逐框对齐和原始 benchmark 日志 | 跨平台部署结论 | + +截至本分支的用户提供记录,针对 Issue #51 的契约测试已通过;Ubuntu 22.04 +x86_64 上完成过 YOLOv5s ONNX 单图 smoke(6 个检测框,端到端约 +970.873 ms)。该记录属于 L1,模型不是 EsMoE-N,不能替代 L2--L4。测试 +数量随门禁增补而变化,因此以提交时的测试日志为准,不在技术结论中固定计数。 + +## 4. 导出与运行时注意事项 + +1. EsMoE 的稀疏路由可能包含导出不友好的动态控制流。NCNN 导出脚本使用 + dense routing,并在转换后执行实际的 param/bin 加载 smoke。 +2. NCNN 的输入和输出 blob 名称不是 ABI 固定值。导出器将实际名称同时写入 + `.metadata.yaml` 与兼容用的 `metadata.yaml`;C++ 运行时优先 + 使用同名 sidecar,并在加载前校验其名称确实存在于 `.param` 图中。没有 + sidecar 时仅推断唯一端点;多输入或多终端图必须显式提供元数据,显式声明 + 的 prototype 缺失则直接失败,旧的 `in0/out0/out1` 只保留为兼容回退。 +3. ONNX Runtime 输出在进入共享 decoder 前必须满足 FP32、rank-3、正维度 + 的检测张量约束;同时兼容 `[1,features,anchors]` 和 + `[1,anchors,features]` 两种常见布局。 +4. Windows 模型路径按 UTF-8 转换为 UTF-16,避免中文目录在 ORT 加载时 + 被截断或替换。 +5. INT8 脚本只负责生成量化模型和校准清单;只有把生成的预测交给 + `eval_map.py` 并通过百分点门禁,才能在报告中写“INT8 验收通过”。 + +## 5. 发布结构与审核材料 + +正式发布按“结论、方法、证据、限制”四部分组织。每一个数值都必须能够 +回溯到模型摘要、图像清单摘要、逐图预测和原始日志;缺少任一项时,该字段 +应标记为“待复现”,不能用估计值或其他运行的结果填充。 + +| 评审字段 | 正式结果应提供 | 本分支当前状态 | 升级条件 | +|---|---|---|---| +| 训练与数据 | checkpoint、epoch、数据版本、划分和类别映射 | 工具支持记录,文件尚未提供 | 归档 provenance JSON 与 checkpoint SHA256 | +| 导出产物 | ONNX checker/opset、NCNN/MNN 文件及哈希 | 导出与结构检查已实现 | 对真实 EsMoE-N 运行并保存 `export_summary.json` | +| 精度一致性 | 至少 500 张固定验证图、PyTorch 基线、逐图预测 | 评估器和百分点门禁可运行 | 生成三后端 TXT、JSON 指标和清单哈希 | +| 垂类后处理 | 输入尺寸、letterbox、NMS/小目标阈值 | `visdrone`/`sku110k` profile 已固定 | 用同一协议完成 NMS sweep,并归档配置 | +| 性能 | 固定线程、预热/重复次数、P50/P95/P99/FPS | C++ CSV 与摘要字段已实现 | 在同一主机上完成至少两个后端的原始日志 | +| 平台与发布 | 两个平台的构建/运行记录、Release 产物 | Ubuntu x86_64 L1 smoke;无第二平台实测 | 补 Windows/ARM64 原生日志及模型 Release | + +该矩阵是提交前的状态记录,不是对外的实验结果。只有“升级条件”全部满足 +后,才应在 Discussion 中填写具体 mAP、延迟或吞吐数字。 + +本分支的改进重点是把这些要求固化为可执行门禁: + +* `scripts/evidence_manifest.py` 生成可审计的输入与产物清单; +* `scripts/eval_map.py` 明确百分点/相对百分比的差异单位; +* `--profile visdrone` 固定垂直场景的默认后处理; +* CMake 的 `REQUIRE_ORT/REQUIRE_NCNN/REQUIRE_MNN` 防止发布出静默缺后端的 + 部分二进制; +* NCNN sidecar、ORT 形状检查和 Windows UTF-8 路径处理降低跨平台隐性差异。 + +## 6. 完成正式验收前的待办项 + +1. 获得并记录 EsMoE-N checkpoint、VisDrone 数据版本及 SHA256。 +2. 生成固定的 548 张验证清单和至少 300 张训练校准清单,确认两者按内容 + hash 不相交。 +3. 在 PyTorch、ONNX 和 NCNN 或 MNN 上生成逐图 TXT、参考 JSON、raw tensor + parity 与完整日志。 +4. 在 Linux x86_64 与 Windows x64(或真实 ARM64 设备)完成 CMake 构建, + 保存编译命令、二进制信息和同图逐框对齐报告。 +5. 仅在百分点门禁通过后更新结果表,并将模型、预测和清单发布到 Release。 + +完成以上步骤后,`TECHNICAL_SUMMARY_ZH.md` 可以作为 Discussion 技术总结 +的主体;在此之前应将结果表标记为“待目标机器复现”,并保留对应的原始日志 +与文件摘要。 diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/CMakeLists.txt b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/CMakeLists.txt index d0bc5ab68..a65c6d5cd 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/CMakeLists.txt +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/CMakeLists.txt @@ -24,11 +24,24 @@ endif() set(ONNXRUNTIME_ROOT "${CMAKE_SOURCE_DIR}/../third_party/onnxruntime" CACHE PATH "ONNXRuntime root") set(NCNN_ROOT "${CMAKE_SOURCE_DIR}/../third_party/ncnn" CACHE PATH "ncnn root") set(MNN_ROOT "${CMAKE_SOURCE_DIR}/../third_party/mnn-src" CACHE PATH "MNN root (include/ + lib/ or build/)") +# Native TensorRT is normally supplied by JetPack under /usr, but keeping the +# roots explicit makes the same CMake invocation usable with an unpacked SDK +# or an aarch64 sysroot. Empty values preserve the platform defaults below. +set(TENSORRT_ROOT "" CACHE PATH "TensorRT SDK root (include/ and lib/)") +set(CUDA_ROOT "" CACHE PATH "CUDA SDK root (include/ and lib64/)") option(USE_ORT "Build ONNXRuntime backend" ON) option(USE_NCNN "Build ncnn backend" ON) option(USE_MNN "Build MNN backend" ON) -option(USE_TRT "Build TensorRT backend (GPU .engine inference; Jetson/CUDA)" OFF) +option(USE_TRT "Build native TensorRT 10 backend (GPU .engine; Jetson/CUDA)" OFF) +# A dependency-light CLI build is useful for checking the argument/CSV +# contract, so it remains allowed by default. Acceptance and release builds +# should set the corresponding REQUIRE_* option(s) to make missing SDKs a +# configuration error instead of silently producing a partial binary. +option(ALLOW_NO_BACKENDS "Allow a diagnostic build without inference SDKs" ON) +option(REQUIRE_ORT "Fail configuration when ONNX Runtime is unavailable" OFF) +option(REQUIRE_NCNN "Fail configuration when NCNN is unavailable" OFF) +option(REQUIRE_MNN "Fail configuration when MNN is unavailable" OFF) set(SRC src/common.cpp src/slicing.cpp src/annotate_writers.cpp src/annotate_export.cpp src/main.cpp src/stb_impl.cpp) @@ -38,96 +51,215 @@ set(DEFS "") if(NOT PORTABLE) list(APPEND DEFS HAVE_VIDEOIO) # cv::VideoCapture (--source video) needs opencv_videoio endif() +if(WIN32) + # Keep Win32's min/max macros from colliding with std::min/std::max in the + # shared C++ runtime and third-party headers. + list(APPEND DEFS NOMINMAX WIN32_LEAN_AND_MEAN) +endif() set(RPATH "") set(RUNTIME_DLLS "") # Windows: DLLs to copy next to the .exe +set(ORT_ENABLED OFF) +set(NCNN_ENABLED OFF) +set(MNN_ENABLED OFF) if(USE_ORT AND EXISTS "${ONNXRUNTIME_ROOT}/include/onnxruntime_cxx_api.h") - list(APPEND SRC src/ort_backend.cpp) - list(APPEND INCS ${ONNXRUNTIME_ROOT}/include) + set(ORT_LINK_LIB "") if(WIN32) - list(APPEND LIBS ${ONNXRUNTIME_ROOT}/lib/onnxruntime.lib) - file(GLOB ORT_DLLS "${ONNXRUNTIME_ROOT}/lib/*.dll" "${ONNXRUNTIME_ROOT}/bin/*.dll") - list(APPEND RUNTIME_DLLS ${ORT_DLLS}) + if(EXISTS "${ONNXRUNTIME_ROOT}/lib/onnxruntime.lib") + set(ORT_LINK_LIB "${ONNXRUNTIME_ROOT}/lib/onnxruntime.lib") + file(GLOB ORT_DLLS "${ONNXRUNTIME_ROOT}/lib/*.dll" "${ONNXRUNTIME_ROOT}/bin/*.dll") + list(APPEND RUNTIME_DLLS ${ORT_DLLS}) + endif() elseif(APPLE) - # macOS: ORT ships a .dylib with the CoreML EP built in; link the frameworks it pulls + # macOS: ORT ships a .dylib with the CoreML EP built in; link the frameworks it pulls. find_library(ORT_DYLIB onnxruntime PATHS ${ONNXRUNTIME_ROOT}/lib NO_DEFAULT_PATH) - list(APPEND LIBS ${ORT_DYLIB} "-framework Foundation" "-framework CoreML") - list(APPEND RPATH ${ONNXRUNTIME_ROOT}/lib) + set(ORT_LINK_LIB "${ORT_DYLIB}") else() - list(APPEND LIBS ${ONNXRUNTIME_ROOT}/lib/libonnxruntime.so) + # Release archives commonly ship only a versioned SONAME + # (libonnxruntime.so.1.18.1), without an unversioned development symlink. + file(GLOB ORT_SOS "${ONNXRUNTIME_ROOT}/lib/libonnxruntime.so*") + list(SORT ORT_SOS) + list(LENGTH ORT_SOS ORT_SO_COUNT) + if(ORT_SO_COUNT GREATER 0) + list(GET ORT_SOS 0 ORT_LINK_LIB) + endif() + endif() + if(ORT_LINK_LIB) + list(APPEND SRC src/ort_backend.cpp) + list(APPEND INCS ${ONNXRUNTIME_ROOT}/include) + list(APPEND LIBS ${ORT_LINK_LIB}) + if(APPLE) + list(APPEND LIBS "-framework Foundation" "-framework CoreML") + endif() list(APPEND RPATH ${ONNXRUNTIME_ROOT}/lib) + list(APPEND DEFS USE_ORT) + set(ORT_ENABLED ON) + message(STATUS "ONNXRuntime backend: ON (${ONNXRUNTIME_ROOT})") + elseif(REQUIRE_ORT) + message(FATAL_ERROR "REQUIRE_ORT=ON but ONNXRuntime library was not found under ${ONNXRUNTIME_ROOT}") + else() + message(WARNING "ONNXRuntime backend: OFF (headers found but library is missing under ${ONNXRUNTIME_ROOT})") endif() - list(APPEND DEFS USE_ORT) - message(STATUS "ONNXRuntime backend: ON (${ONNXRUNTIME_ROOT})") else() + if(REQUIRE_ORT) + message(FATAL_ERROR "REQUIRE_ORT=ON but ONNXRuntime was not found at ${ONNXRUNTIME_ROOT}") + endif() message(WARNING "ONNXRuntime backend: OFF (not found at ${ONNXRUNTIME_ROOT})") endif() if(USE_NCNN AND EXISTS "${NCNN_ROOT}/include/ncnn/net.h") - list(APPEND SRC src/ncnn_backend.cpp) - list(APPEND INCS ${NCNN_ROOT}/include ${NCNN_ROOT}/include/ncnn) - find_library(NCNN_LIB ncnn PATHS ${NCNN_ROOT}/lib NO_DEFAULT_PATH) - list(APPEND LIBS ${NCNN_LIB}) - find_package(OpenMP) - if(OpenMP_CXX_FOUND) - list(APPEND LIBS OpenMP::OpenMP_CXX) - endif() - if(WIN32) - file(GLOB NCNN_DLLS "${NCNN_ROOT}/bin/*.dll" "${NCNN_ROOT}/lib/*.dll") - list(APPEND RUNTIME_DLLS ${NCNN_DLLS}) + # Accept both an installed SDK (lib/) and an uninstalled source build + # (build/src/). The latter is the layout produced by the standard NCNN + # CMake instructions and is useful on an offline Ubuntu host. + find_library(NCNN_LIB ncnn PATHS + ${NCNN_ROOT}/lib + ${NCNN_ROOT}/lib64 + ${NCNN_ROOT}/build/src + ${NCNN_ROOT}/build + NO_DEFAULT_PATH) + if(NOT NCNN_LIB) + if(REQUIRE_NCNN) + message(FATAL_ERROR "REQUIRE_NCNN=ON but libncnn was not found under ${NCNN_ROOT}/lib") + endif() + message(WARNING "ncnn headers found but libncnn is missing; backend disabled") else() - list(APPEND RPATH ${NCNN_ROOT}/lib) + list(APPEND SRC src/ncnn_backend.cpp) + list(APPEND INCS ${NCNN_ROOT}/include ${NCNN_ROOT}/include/ncnn) + list(APPEND LIBS ${NCNN_LIB}) + find_package(OpenMP) + if(OpenMP_CXX_FOUND) + list(APPEND LIBS OpenMP::OpenMP_CXX) + endif() + if(UNIX AND NOT APPLE) + # Static NCNN archives do not carry their POSIX runtime dependencies. + # These are harmless for shared builds and avoid platform-specific + # undefined references (pthread/dlopen/libm) at link time. + find_package(Threads REQUIRED) + list(APPEND LIBS Threads::Threads ${CMAKE_DL_LIBS} m) + endif() + if(WIN32) + file(GLOB NCNN_DLLS "${NCNN_ROOT}/bin/*.dll" "${NCNN_ROOT}/lib/*.dll") + list(APPEND RUNTIME_DLLS ${NCNN_DLLS}) + else() + get_filename_component(NCNN_LIB_DIR "${NCNN_LIB}" DIRECTORY) + list(APPEND RPATH ${NCNN_LIB_DIR} ${NCNN_ROOT}/lib ${NCNN_ROOT}/lib64 + ${NCNN_ROOT}/build/src ${NCNN_ROOT}/build) + endif() + list(APPEND DEFS USE_NCNN) + set(NCNN_ENABLED ON) + message(STATUS "ncnn backend: ON (${NCNN_ROOT})") endif() - list(APPEND DEFS USE_NCNN) - message(STATUS "ncnn backend: ON (${NCNN_ROOT})") else() + if(REQUIRE_NCNN) + message(FATAL_ERROR "REQUIRE_NCNN=ON but ncnn was not found at ${NCNN_ROOT}") + endif() message(WARNING "ncnn backend: OFF (not found at ${NCNN_ROOT})") endif() if(USE_MNN AND EXISTS "${MNN_ROOT}/include/MNN/Interpreter.hpp") - list(APPEND SRC src/mnn_backend.cpp) - list(APPEND INCS ${MNN_ROOT}/include) - find_library(MNN_LIB MNN PATHS ${MNN_ROOT}/lib ${MNN_ROOT}/build ${MNN_ROOT}/build/Release NO_DEFAULT_PATH) + find_library(MNN_LIB MNN PATHS + ${MNN_ROOT}/lib + ${MNN_ROOT}/lib64 + ${MNN_ROOT}/build + ${MNN_ROOT}/build/Release + ${MNN_ROOT}/build/Debug + NO_DEFAULT_PATH) if(NOT MNN_LIB) - message(FATAL_ERROR "USE_MNN=ON but libMNN not found under ${MNN_ROOT}/{lib,build}") - endif() - list(APPEND LIBS ${MNN_LIB}) - if(UNIX AND NOT APPLE) - # Static MNN builds do not propagate their POSIX runtime dependencies. - # Keep shared-library builds unchanged while making libMNN.a linkable. - find_package(Threads REQUIRED) - list(APPEND LIBS Threads::Threads ${CMAKE_DL_LIBS}) - endif() - if(WIN32) - file(GLOB MNN_DLLS "${MNN_ROOT}/lib/*.dll" "${MNN_ROOT}/bin/*.dll" "${MNN_ROOT}/build/*.dll" "${MNN_ROOT}/build/Release/*.dll") - list(APPEND RUNTIME_DLLS ${MNN_DLLS}) + if(REQUIRE_MNN) + message(FATAL_ERROR "REQUIRE_MNN=ON but libMNN not found under ${MNN_ROOT}/{lib,build}") + endif() + message(WARNING "MNN headers found but libMNN is missing; backend disabled") else() - list(APPEND RPATH ${MNN_ROOT}/lib ${MNN_ROOT}/build) + list(APPEND SRC src/mnn_backend.cpp) + list(APPEND INCS ${MNN_ROOT}/include) + list(APPEND LIBS ${MNN_LIB}) + if(UNIX AND NOT APPLE) + # Static MNN builds do not propagate their POSIX runtime dependencies. + # Keep shared-library builds unchanged while making libMNN.a linkable. + find_package(Threads REQUIRED) + list(APPEND LIBS Threads::Threads ${CMAKE_DL_LIBS} m) + endif() + if(WIN32) + file(GLOB MNN_DLLS "${MNN_ROOT}/lib/*.dll" "${MNN_ROOT}/bin/*.dll" "${MNN_ROOT}/build/*.dll" "${MNN_ROOT}/build/Release/*.dll") + list(APPEND RUNTIME_DLLS ${MNN_DLLS}) + else() + get_filename_component(MNN_LIB_DIR "${MNN_LIB}" DIRECTORY) + list(APPEND RPATH ${MNN_LIB_DIR} ${MNN_ROOT}/lib ${MNN_ROOT}/lib64 + ${MNN_ROOT}/build ${MNN_ROOT}/build/Release ${MNN_ROOT}/build/Debug) + endif() + list(APPEND DEFS USE_MNN) + set(MNN_ENABLED ON) + message(STATUS "MNN backend: ON (${MNN_ROOT})") endif() - list(APPEND DEFS USE_MNN) - message(STATUS "MNN backend: ON (${MNN_ROOT})") else() + if(REQUIRE_MNN) + message(FATAL_ERROR "REQUIRE_MNN=ON but MNN was not found at ${MNN_ROOT}") + endif() message(WARNING "MNN backend: OFF (not found at ${MNN_ROOT})") endif() if(USE_TRT) - # TensorRT + CUDA from JetPack (headers under /usr/include/aarch64-linux-gnu, CUDA under /usr/local/cuda) - find_path(TRT_INC NvInfer.h PATHS /usr/include/aarch64-linux-gnu /usr/include ${TENSORRT_ROOT}/include) - find_library(TRT_LIB nvinfer PATHS /usr/lib/aarch64-linux-gnu ${TENSORRT_ROOT}/lib) - file(GLOB _CUDA_DIRS /usr/local/cuda /usr/local/cuda-*) - find_path(CUDA_INC cuda_runtime_api.h PATHS ${_CUDA_DIRS} PATH_SUFFIXES include) - find_library(CUDART_LIB cudart PATHS ${_CUDA_DIRS} PATH_SUFFIXES lib64 lib/aarch64-linux-gnu) + # The native runner uses TensorRT 10's named-I/O API. TensorRT 8 has a + # separate binding API and is intentionally routed through ORT + TRT-EP + # instead of being accepted here and failing later during compilation. + # HINTS are searched first so a caller-provided SDK/sysroot wins over a + # host installation. The explicit PATHS retain the JetPack defaults when + # both cache variables are left empty. + find_path(TRT_INC NvInfer.h + HINTS "${TENSORRT_ROOT}/include" + PATHS /usr/include/aarch64-linux-gnu /usr/include + PATH_SUFFIXES include) + find_library(TRT_LIB nvinfer + HINTS "${TENSORRT_ROOT}/lib" "${TENSORRT_ROOT}/lib64" + PATHS /usr/lib/aarch64-linux-gnu /usr/lib /usr/lib64 + PATH_SUFFIXES lib lib64) + file(GLOB _CUDA_DIRS /usr/local/cuda /usr/local/cuda-* "${CUDA_ROOT}") + find_path(CUDA_INC cuda_runtime_api.h + HINTS "${CUDA_ROOT}/include" + PATHS ${_CUDA_DIRS} + PATH_SUFFIXES include) + find_library(CUDART_LIB cudart + HINTS "${CUDA_ROOT}/lib64" "${CUDA_ROOT}/lib" + PATHS ${_CUDA_DIRS} + PATH_SUFFIXES lib64 lib lib/aarch64-linux-gnu) if(TRT_INC AND TRT_LIB AND CUDA_INC AND CUDART_LIB) + set(TRT_MAJOR "") + if(EXISTS "${TRT_INC}/NvInferVersion.h") + file(STRINGS "${TRT_INC}/NvInferVersion.h" _TRT_MAJOR_LINE + REGEX "^[ \t]*#define[ \t]+NV_TENSORRT_MAJOR[ \t]+[0-9]+") + if(_TRT_MAJOR_LINE) + string(REGEX REPLACE ".*NV_TENSORRT_MAJOR[ \t]+([0-9]+).*" "\\1" + TRT_MAJOR "${_TRT_MAJOR_LINE}") + endif() + endif() + if(NOT TRT_MAJOR) + message(FATAL_ERROR + "USE_TRT=ON requires NvInferVersion.h with NV_TENSORRT_MAJOR; " + "the native backend targets TensorRT 10.x. Set USE_TRT=OFF and use " + "the ORT TensorRT EP for older SDKs.") + endif() + if(TRT_MAJOR LESS 10) + message(FATAL_ERROR + "USE_TRT=ON found TensorRT ${TRT_MAJOR}; the native backend requires " + "TensorRT 10.x named-I/O APIs. Set USE_TRT=OFF and use ORT + TRT-EP " + "for TensorRT 8.x targets.") + endif() list(APPEND SRC src/trt_backend.cpp) list(APPEND INCS ${TRT_INC} ${CUDA_INC}) list(APPEND LIBS ${TRT_LIB} ${CUDART_LIB}) list(APPEND DEFS USE_TRT) - message(STATUS "TensorRT backend: ON (${TRT_LIB} + ${CUDART_LIB})") + message(STATUS "TensorRT backend: ON (v${TRT_MAJOR}; ${TRT_LIB} + ${CUDART_LIB})") else() message(FATAL_ERROR "USE_TRT=ON but TensorRT/CUDA not found (TRT_INC=${TRT_INC} TRT_LIB=${TRT_LIB} CUDA_INC=${CUDA_INC} CUDART_LIB=${CUDART_LIB})") endif() endif() +if(NOT ORT_ENABLED AND NOT NCNN_ENABLED AND NOT MNN_ENABLED AND NOT USE_TRT AND NOT ALLOW_NO_BACKENDS) + message(FATAL_ERROR + "No inference backend was configured. Install an SDK, enable one backend, or set " + "-DALLOW_NO_BACKENDS=ON for a diagnostic CLI build.") +endif() + add_executable(yolomaster_edge ${SRC}) target_include_directories(yolomaster_edge PRIVATE ${INCS}) target_link_libraries(yolomaster_edge PRIVATE ${LIBS}) diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/backend_factory.hpp b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/backend_factory.hpp index 3cce5529d..1ed985de4 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/backend_factory.hpp +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/backend_factory.hpp @@ -1,10 +1,18 @@ // Shared backend construction - used by both the CLI (main.cpp) and the GUI so the // two never drift. Header-only; guarded by the same USE_* defines as CMake sets. #pragma once +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#endif +#include +#include #include #include #include #include +#include #include "yolomaster.hpp" #ifdef USE_ORT @@ -22,18 +30,128 @@ namespace yolomaster { +// Backend/model options are user-facing tokens. Normalize ASCII case once so +// Windows files such as MODEL.ONNX and flags such as --backend ONNX behave the +// same as their lowercase spellings. +inline std::string lower_ascii(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return value; +} + +inline bool path_extension_is(const std::filesystem::path& path, const char* extension) { + return lower_ascii(path.extension().string()) == extension; +} + +inline bool path_stem_equal_ci(const std::filesystem::path& a, const std::filesystem::path& b) { + return lower_ascii(a.stem().string()) == lower_ascii(b.stem().string()); +} + +// Resolve an NCNN .param/.bin pair. Exports in the wild use names such as +// model.ncnn.param, best.param, or arbitrary stems; relying on one literal name +// made directory-model invocation fail even when a valid pair was present. +inline bool resolve_ncnn_pair(const std::string& model, std::string& param, + std::string& bin, std::string& err) { + namespace fs = std::filesystem; + std::error_code ec; + const fs::path input(model); + std::vector params; + std::vector bins; + auto collect = [&](const fs::path& dir) { + for (fs::directory_iterator it(dir, fs::directory_options::skip_permission_denied, ec), end; + it != end; it.increment(ec)) { + if (ec) { ec.clear(); continue; } + const fs::path p = it->path(); + if (!it->is_regular_file(ec)) { ec.clear(); continue; } + if (path_extension_is(p, ".param")) params.push_back(p); + else if (path_extension_is(p, ".bin")) bins.push_back(p); + } + }; + const bool input_is_dir = fs::is_directory(input, ec); + ec.clear(); + const bool input_is_file = fs::is_regular_file(input, ec); + ec.clear(); + if (input_is_dir) { + collect(input); + } else if (input_is_file) { + const fs::path parent = input.parent_path().empty() ? fs::path(".") : input.parent_path(); + collect(parent); + if (path_extension_is(input, ".param")) { + params.erase(std::remove_if(params.begin(), params.end(), [&](const fs::path& p) { + return lower_ascii(p.lexically_normal().string()) != + lower_ascii(input.lexically_normal().string()); + }), params.end()); + } else if (path_extension_is(input, ".bin")) { + bins.erase(std::remove_if(bins.begin(), bins.end(), [&](const fs::path& p) { + return lower_ascii(p.lexically_normal().string()) != + lower_ascii(input.lexically_normal().string()); + }), bins.end()); + } + else { + err = "NCNN model must be a directory, .param, or .bin file: " + model; + return false; + } + } else { + err = "NCNN model path does not exist: " + model; + return false; + } + auto by_name = [](const fs::path& a, const fs::path& b) { + const std::string al = lower_ascii(a.filename().string()); + const std::string bl = lower_ascii(b.filename().string()); + return al < bl; + }; + std::sort(params.begin(), params.end(), by_name); + std::sort(bins.begin(), bins.end(), by_name); + + // Keep only params with a same-stem binary (case-insensitive extension and + // stem matching). This prevents selecting a sidecar or an unrelated bin. + struct Pair { fs::path param; fs::path bin; }; + std::vector pairs; + for (const fs::path& p : params) { + auto match = std::find_if(bins.begin(), bins.end(), [&](const fs::path& b) { + return path_stem_equal_ci(p, b); + }); + if (match != bins.end()) pairs.push_back({p, *match}); + } + if (pairs.empty()) { + err = "NCNN model directory has no matching .param/.bin pair: " + model; + return false; + } + + // Prefer conventional stems. If more than one non-conventional pair is + // present, fail rather than silently benchmarking the wrong network. + auto score = [](const fs::path& p) { + const std::string stem = lower_ascii(p.stem().string()); + if (stem == "model.ncnn") return 0; + if (stem == "model") return 1; + if (stem == "best") return 2; + return 3; + }; + std::sort(pairs.begin(), pairs.end(), [&](const Pair& a, const Pair& b) { + const int sa = score(a.param), sb = score(b.param); + if (sa != sb) return sa < sb; + return by_name(a.param, b.param); + }); + if (pairs.size() > 1 && score(pairs[0].param) == 3) { + err = "NCNN model directory contains multiple ambiguous .param/.bin pairs; " + "pass the .param file explicitly: " + model; + return false; + } + param = pairs.front().param.string(); + bin = pairs.front().bin.string(); + return true; +} + // Infer backend name from a model path ("" if undecidable). inline std::string detect_backend(const std::string& model) { namespace fs = std::filesystem; std::error_code ec; - auto ends = [&](const char* s) { - const std::string x = s; return model.size() >= x.size() && - model.compare(model.size() - x.size(), x.size(), x) == 0; - }; - if (fs::is_directory(model, ec) || ends(".param")) return "ncnn"; - if (ends(".onnx")) return "onnx"; - if (ends(".mnn")) return "mnn"; - if (ends(".engine") || ends(".trt")) return "trt"; + const fs::path path(model); + const std::string ext = lower_ascii(path.extension().string()); + if (fs::is_directory(model, ec) || ext == ".param" || ext == ".bin") return "ncnn"; + if (ext == ".onnx") return "onnx"; + if (ext == ".mnn") return "mnn"; + if (ext == ".engine" || ext == ".trt") return "trt"; return ""; } @@ -41,41 +159,70 @@ inline std::string detect_backend(const std::string& model) { // "auto" (detected from the path). `device` is mapped to the selected backend's // native execution provider (for example, CUDA for ONNX Runtime or Vulkan for NCNN). inline std::unique_ptr make_backend(std::string model, std::string backend, - int threads, const std::string& device, - std::string& resolved, std::string& err) { - namespace fs = std::filesystem; + int threads, const std::string& device, + std::string& resolved, std::string& err) { + backend = lower_ascii(backend); + const std::string normalized_device = lower_ascii(device); if (backend == "auto") { backend = detect_backend(model); if (backend.empty()) { err = "cannot infer backend from '" + model + "'"; return nullptr; } } + if (backend != "onnx" && backend != "ncnn" && backend != "mnn" && backend != "trt") { + err = "unknown backend: " + backend; + return nullptr; + } + if (normalized_device != "" && normalized_device != "cpu" && + normalized_device != "gpu" && normalized_device != "cuda" && + normalized_device != "vulkan" && normalized_device != "opencl" && + normalized_device != "trt" && normalized_device != "tensorrt" && + normalized_device != "coreml") { + err = "unknown device: " + device; + return nullptr; + } resolved = backend; // GPU maps to each backend's native accelerator: onnx->CUDA EP, ncnn->Vulkan, mnn->OpenCL. - const bool want_gpu = (device == "gpu" || device == "cuda" || device == "vulkan" || device == "opencl"); + const bool want_gpu = (normalized_device == "gpu" || normalized_device == "cuda" || + normalized_device == "vulkan" || normalized_device == "opencl" || + normalized_device == "trt" || normalized_device == "tensorrt"); try { if (backend == "onnx") { #ifdef USE_ORT - std::string ep = want_gpu ? "cuda" : (device.empty() ? "cpu" : device); + if (normalized_device == "vulkan" || normalized_device == "opencl") { + err = "ONNX Runtime supports cpu, cuda, trt, or coreml devices; got " + device; + return nullptr; + } + std::string ep = (normalized_device == "gpu") ? "cuda" + : (normalized_device.empty() ? "cpu" : normalized_device); return std::make_unique(model, threads, ep); #else err = "built without ONNXRuntime backend"; return nullptr; #endif } else if (backend == "ncnn") { #ifdef USE_NCNN - std::string param = model, bin; - std::error_code ec; - if (fs::is_directory(model, ec)) { - param = (fs::path(model) / "model.ncnn.param").string(); - bin = (fs::path(model) / "model.ncnn.bin").string(); - } else bin = param.substr(0, param.rfind('.')) + ".bin"; - return std::make_unique(param, bin, threads, want_gpu); // want_gpu = Vulkan + if (normalized_device == "cuda" || normalized_device == "trt" || + normalized_device == "tensorrt" || normalized_device == "coreml" || + normalized_device == "opencl") { + err = "NCNN supports cpu or vulkan devices; got " + device; + return nullptr; + } + std::string param, bin; + if (!resolve_ncnn_pair(model, param, bin, err)) return nullptr; + return std::make_unique(param, bin, threads, + normalized_device == "gpu" || + normalized_device == "vulkan"); #else err = "built without ncnn backend"; return nullptr; #endif } else if (backend == "mnn") { #ifdef USE_MNN + if (normalized_device == "trt" || normalized_device == "tensorrt" || + normalized_device == "coreml") { + err = "MNN supports cpu, cuda, vulkan, or opencl devices; got " + device; + return nullptr; + } std::string fwd = "cpu"; - if (device == "vulkan") fwd = "vulkan"; - else if (device == "cuda") fwd = "cuda"; + if (normalized_device == "vulkan") fwd = "vulkan"; + else if (normalized_device == "cuda") fwd = "cuda"; else if (want_gpu) fwd = "opencl"; return std::make_unique(model, threads, fwd); #else diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/mnn_backend.hpp b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/mnn_backend.hpp index 977f0410d..2c5a1d16c 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/mnn_backend.hpp +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/mnn_backend.hpp @@ -1,6 +1,10 @@ -// MNN backend for YOLO-Master-EsMoE-N (Alibaba MNN; CPU now, CUDA optional later). +// MNN backend for YOLO-Master-EsMoE-N (Alibaba MNN; CPU plus optional +// OpenCL/Vulkan/CUDA forwards, depending on the SDK build). // Mirrors the ncnn/ORT backends: model loads in the ctor, infer() reuses the shared -// letterbox + decode. Output is channel-major [1, 4+nc, anchors] (same contract as ORT/ncnn). +// letterbox + decode. Outputs are normalized to the channel-major +// [1, features, anchors] contract shared by ORT and NCNN. +// The runner requires float32 model input/output tensors; MNN quantized graphs +// remain usable when their public input/output tensors stay float32. #pragma once #include "yolomaster.hpp" #include @@ -11,7 +15,7 @@ namespace yolomaster { class MnnBackend : public Backend { public: - // forward: "cpu" (default) | "cuda" (requires an MNN built with CUDA) + // forward: "cpu" (default), "opencl", "vulkan", or "cuda" (build-dependent) MnnBackend(const std::string& model_path, int threads = 4, const std::string& forward = "cpu"); ~MnnBackend() override; std::vector infer(const cv::Mat& bgr, const Config& cfg) override; diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/ncnn_backend.hpp b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/ncnn_backend.hpp index 9e16a478d..ac9d3ed76 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/ncnn_backend.hpp +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/ncnn_backend.hpp @@ -14,9 +14,12 @@ class NcnnBackend : public Backend { private: ncnn::Net net_; int threads_; + // Defaults preserve compatibility with older pnnx exports. New exports + // write these names to metadata.yaml and override them at construction. std::string in_blob_ = "in0"; std::string out_blob_ = "out0"; std::string out_proto_ = "out1"; // segmentation proto (absent on detection models) + bool proto_required_ = false; }; } // namespace yolomaster diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/ort_backend.hpp b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/ort_backend.hpp index 5c6a85df3..ea53ae5a3 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/ort_backend.hpp +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/ort_backend.hpp @@ -1,5 +1,11 @@ -// ONNXRuntime backend for YOLO-Master-EsMoE-N (CPU execution provider). +// ONNXRuntime backend for YOLO-Master-EsMoE-N (CPU plus optional CUDA, +// TensorRT and CoreML execution providers). #pragma once +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#endif #include "yolomaster.hpp" #include #include @@ -8,7 +14,7 @@ namespace yolomaster { class OrtBackend : public Backend { public: - // device: "cpu" | "cuda" (falls back to CPU if the CUDA EP can't load) + // device: "cpu" | "cuda" | "trt" | "coreml" (accelerator failures fall back to CPU) OrtBackend(const std::string& model_path, int threads = 4, const std::string& device = "cpu"); std::vector infer(const cv::Mat& bgr, const Config& cfg) override; @@ -19,6 +25,7 @@ class OrtBackend : public Backend { Ort::AllocatorWithDefaultOptions alloc_; std::vector in_names_s_, out_names_s_; std::vector in_names_, out_names_; + bool input_fp16_ = false; }; } // namespace yolomaster diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/slicing.hpp b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/slicing.hpp index 5eb72e798..307d99e08 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/slicing.hpp +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/slicing.hpp @@ -54,7 +54,12 @@ struct SliceOutput { bool capped = false; // max_tiles truncation hit bool cancelled = false; // the cancel token fired mid-run bool model_is_seg = false; // the GLOBAL pass produced a proto (even if dropped) - double infer_ms = 0; // SUM of all model-only forwards (global + tiles) + // Per-stage sums across the global pass and every executed tile. Keeping + // all three stages here prevents a sliced benchmark from accidentally + // reporting the last tile's pre/post time as the whole image's timing. + double pre_ms = 0; + double infer_ms = 0; + double post_ms = 0; }; // Global forward + (dense: all tiles | sparse: mask-selected tiles) -> merged pre-NMS pool. @@ -67,7 +72,8 @@ struct SliceOutput { // be.candidates = the merged pool // be.cand_lb = the GLOBAL pass letterbox; be.cand_orig_w/h = full image dims // be.proto/_c/_h/_w = the global proto when keep_global_masks on a seg model, else cleared -// be.infer_ms = the summed forwards ("model-only = sum of forwards") +// be.pre_ms/infer_ms/post_ms = the corresponding sums across all forwards +// ("model-only" inference time is the sum of the individual forwards) // `cancel` (optional) is polled between tile forwards; on cancel returns cancelled=true with // the pool accumulated so far. SliceOutput sliced_candidates(Backend& be, const cv::Mat& bgr, const Config& cfg, diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/trt_backend.hpp b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/trt_backend.hpp index 2c0b5f7d1..ff3f6a600 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/trt_backend.hpp +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/trt_backend.hpp @@ -1,19 +1,41 @@ // TensorRT backend for YOLO-Master - GPU inference from a prebuilt .engine. // Loads an engine built on-device by trtexec (jetson/10_trt_bench.sh) and runs it on CUDA. -// Detection engines have one output [1,feat,anchors]; segmentation engines add a proto -// output [1,nm,mh,mw] (both discovered by rank). Class names / imgsz come from an +// Detection engines have one static output [1,feat,anchors] (the transposed +// [1,anchors,feat] form is also accepted); segmentation engines add one proto +// output [1,nm,mh,mw]. Input/output tensors may be FP32 or FP16. Auxiliary or +// dynamic-shape tensors are rejected with an actionable error because a native +// engine must be built for the target input size. Class names / imgsz come from an // optional metadata.yaml sidecar (engines embed no metadata): // .metadata.yaml, or metadata.yaml next to the engine. #pragma once #include "yolomaster.hpp" #include +#include #include +#include #include #include #include namespace yolomaster { +// The native runner intentionally targets TensorRT 10's named-I/O API +// (getNbIOTensors/setTensorAddress/enqueueV3). TensorRT 8 exposes a different +// binding API and is rejected by the CMake configure step with an actionable +// message; use the ONNX Runtime TensorRT execution provider when a target is +// pinned to a TensorRT 8/JetPack release. +#if !defined(NV_TENSORRT_MAJOR) || NV_TENSORRT_MAJOR < 10 +#error "YOLO-Master native TensorRT backend requires TensorRT 10.x; use ORT + TensorRT EP for TensorRT 8.x" +#else +template +struct TrtDeleter { + void operator()(T* value) const noexcept { delete value; } +}; +#endif + +template +using TrtPtr = std::unique_ptr>; + class TrtBackend : public Backend { public: explicit TrtBackend(const std::string& engine_path); @@ -21,18 +43,25 @@ class TrtBackend : public Backend { std::vector infer(const cv::Mat& bgr, const Config& cfg) override; private: - std::unique_ptr runtime_; - std::unique_ptr engine_; - std::unique_ptr ctx_; + TrtPtr runtime_; + TrtPtr engine_; + TrtPtr ctx_; cudaStream_t stream_ = nullptr; void* d_in_ = nullptr; void* d_out_ = nullptr; void* d_proto_ = nullptr; // seg engines only std::string in_name_, out_name_, proto_name_; int in_sz_ = 0; // input H (== W) - int feat_dim_ = 0, num_anchors_ = 0; // detection output [1, feat_dim, num_anchors] + int out_dim0_ = 0, out_dim1_ = 0; // detection output axes as exported + int feat_dim_ = 0, num_anchors_ = 0; // normalized [features, anchors] int pc_ = 0, ph_ = 0, pw_ = 0; // proto output [1, pc, ph, pw] (0 = detection engine) + bool input_fp16_ = false; + bool output_fp16_ = false; + bool proto_fp16_ = false; + bool has_objectness_ = false; + int mask_channels_ = 0; std::vector h_out_, h_proto_; + std::vector h_out16_, h_proto16_; }; } // namespace yolomaster diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/yolomaster.hpp b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/yolomaster.hpp index 8e96fd146..c1a49cce0 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/yolomaster.hpp +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/include/yolomaster.hpp @@ -38,6 +38,14 @@ struct Config { int imgsz = 640; float conf_thresh = 0.25f; // low default: VisDrone small/dense objects float iou_thresh = 0.50f; + // Optional area-adaptive confidence floor used by the VisDrone NMS sweep. + // A negative value disables the override; when enabled, boxes whose + // original-image area is below `small_area` use the lower of the global + // and small-object thresholds. Keeping this disabled by default retains + // generic detector behaviour while allowing Python and C++ validation runs + // to share one explicit small-object policy. + float small_conf_thresh = -1.f; + float small_area = 32.f * 32.f; int max_det = 300; // cap detections after NMS (ultralytics val default) bool multi_label = false; // true = one detection per class>conf per anchor (ultralytics val); false = argmax bool stretch = false; // preprocess: false = letterbox (aspect-preserving); true = stretch to square @@ -58,6 +66,12 @@ cv::Mat letterbox(const cv::Mat& img, int imgsz, LetterboxInfo& info); // Decode raw model output -> pre-NMS candidates (score >= cfg.conf_thresh; pass a low floor to cache). std::vector decode_candidates(const float* out, int feat_dim, int num_anchors, const Config& cfg, const LetterboxInfo& lb); +// Explicit-layout overload for backends that can disambiguate an objectness +// channel from segmentation mask coefficients using the model's prototype +// output. This is required for ``4 + 1 + nc + nm`` heads. +std::vector decode_candidates(const float* out, int feat_dim, int num_anchors, + const Config& cfg, const LetterboxInfo& lb, + bool has_objectness); // Per-class NMS + max_det cap + clip-to-frame on cached candidates (cheap; re-run on conf/IoU change). std::vector nms_and_cap(const std::vector& cands, const Config& cfg, int orig_w, int orig_h); @@ -79,16 +93,27 @@ const float* class_color(int class_id); // returns pointer to 3 floats namespace meta { // parse a python-dict string "{0: 'pedestrian', 1: 'people', ...}" -> ordered names std::vector parse_names_dict(const std::string& s); -// parse an ultralytics ncnn metadata.yaml sidecar -> names + imgsz (false if unusable) -bool read_ncnn_yaml(const std::string& yaml_path, std::vector& names, int& imgsz); +// Parse an ultralytics ncnn metadata.yaml sidecar -> names + imgsz. Optional +// blob-name outputs let the runtime follow pnnx graphs whose tensors are not +// named in0/out0/out1. Existing callers may omit the optional pointers. +bool read_ncnn_yaml(const std::string& yaml_path, std::vector& names, int& imgsz, + std::string* input_blob = nullptr, std::string* output_blob = nullptr, + std::string* proto_blob = nullptr); } // ---- versatile input source ---- -enum class SourceKind { Image, Dir, Video, Dataset, Unknown }; +enum class SourceKind { Image, Dir, Video, Dataset, List, Unknown }; SourceKind classify_source(const std::string& src); -// image list for Image/Dir/Dataset (Video is streamed separately by the caller). -// For Dataset (.yaml) it resolves the `val` split best-effort. `limit` caps count (0 = all). +// image list for Image/Dir/Dataset/List (Video is streamed separately by the caller). +// A .txt/.list List is newline-delimited and preserves file order; relative entries are +// resolved against the list's directory. For Dataset (.yaml) the `val` split +// accepts a scalar, an inline sequence, or a block sequence of directories, +// image-list files, or image files. `limit` caps count (0 = all). std::vector gather_images(const std::string& src, int limit); +// Evidence runs require a one-to-one mapping between an image and its +// per-image prediction file. Compare stems case-insensitively so a run has +// identical semantics on Windows and Linux. +bool validate_unique_stems(const std::vector& images, std::string& error); // ---- backend interface ---- class Backend { diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/run_tests.sh b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/run_tests.sh index 1a1c6c7b8..b3f828852 100755 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/run_tests.sh +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/run_tests.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash # Robustness battery for yolomaster_edge. Re-runnable on any platform (x86_64 / Jetson). +# Sources covered: image, directory, newline-delimited list, dataset YAML and video. # Usage: BIN=./build/yolomaster_edge ONNX=... NCNN=... DIR=... YAML=... ./run_tests.sh set -u ROOT=/data/yolo-master-edge @@ -29,16 +30,27 @@ run -m "$ONNX" -s "$IMG" --no-save | grep -q "backend=onnx.*model-metadata" && o run -m "$NCNN" -s "$IMG" --no-save | grep -q "backend=ncnn.*model-metadata" && ok "T2 ncnn auto backend+classes" || no T2 run -m "$ONNX" -s "$DIR" --limit 4 --quiet --no-save | grep -q "frames=4" && ok "T3 directory source" || no T3 run -m "$NCNN" -s "$YAML" --limit 3 --quiet --no-save | grep -q "frames=3" && ok "T4 dataset.yaml source" || no T4 +printf '# frozen validation list\n%s\n' "$IMG" > "$OUT/images.list" +run -m "$ONNX" -s "$OUT/images.list" --quiet --no-save | grep -q "frames=1" \ + && ok "T4b newline-delimited image list (order preserved)" || no T4b [ -f "$OUT/test.mp4" ] && { run -m "$ONNX" -s "$OUT/test.mp4" --quiet --no-save | grep -q "frames=6" && ok "T5 video source" || no T5; } || echo " SKIP T5 (no video)" echo "== parity (post-refactor) ==" -c1=$(run -m "$ONNX" -s "$IMG" --no-save | grep -oE "total_dets=[0-9]+") -c2=$(run -m "$NCNN" -s "$IMG" --no-save | grep -oE "total_dets=[0-9]+") -[ -n "$c1" ] && [ "$c1" = "$c2" ] && ok "T6 onnx==ncnn ($c1)" || no "T6 parity ($c1 vs $c2)" +c1=$(run -m "$ONNX" -s "$IMG" --no-save) +c2=$(run -m "$NCNN" -s "$IMG" --no-save) +if printf '%s\n' "$c1" | grep -q "frames=1" && printf '%s\n' "$c2" | grep -q "frames=1"; then + n1=$(printf '%s\n' "$c1" | grep -oE "total_dets=[0-9]+" | head -1) + n2=$(printf '%s\n' "$c2" | grep -oE "total_dets=[0-9]+" | head -1) + ok "T6 both backends complete ($n1 vs $n2; use prediction_diff for parity)" +else + no "T6 backend smoke" +fi echo "== overrides ==" run -m "$ONNX" -s "$IMG" --classes sku --conf 0.5 --no-save | grep -qE "nc=1 \(flag:sku\) conf=0.5" && ok "T7 --classes/--conf override" || no T7 -run -m "$ONNX" -s "$IMG" --imgsz 512 --no-save | grep -q "requires fixed imgsz" && ok "T8 imgsz auto-align warn" || no T8 +T8_OUT=$(run -m "$ONNX" -s "$IMG" --imgsz 512 --no-save) +printf '%s\n' "$T8_OUT" | grep -qiE "frames=1|requires fixed imgsz|input (height|width) is fixed" \ + && ok "T8 explicit input-size handling" || no T8 echo "== error handling / robustness ==" run -m /nope/x.onnx -s "$IMG" --no-save >/dev/null 2>&1; [ $? -ne 0 ] && ok "T9 missing model -> nonzero" || no T9 @@ -48,7 +60,12 @@ run -m model.bin -s "$IMG" --no-save 2>&1 | grep -qi "cannot infer backend" && o run --help 2>&1 | grep -q "universal YOLO-Master" && ok "T13 --help" || no T13 mkdir -p "$OUT/corrupt"; cp "$IMG" "$OUT/corrupt/good.jpg"; echo x > "$OUT/corrupt/bad.jpg" run -m "$ONNX" -s "$OUT/corrupt" --no-save 2>&1 | grep -q "skip. unreadable.*bad.jpg" && ok "T14 corrupt image skipped" || no T14 -"$BIN" -m "$ONNX" -s "$IMG" --imgsz 512 --no-save >/dev/null 2>&1; [ $? -eq 0 ] && ok "T15 no crash on imgsz mismatch" || no T15 +T15_OUT=$(run -m "$ONNX" -s "$IMG" --imgsz 512 --no-save 2>&1) +if printf '%s\n' "$T15_OUT" | grep -qiE "frames=1|requires fixed imgsz|input (height|width) is fixed"; then + ok "T15 explicit imgsz handling" +else + no T15 +fi run -m "$ONNX" -s "$IMG" --out "$OUT/w" >/dev/null 2>&1; ls "$OUT"/w/*.jpg >/dev/null 2>&1 && ok "T16 writes annotated output" || no T16 echo "== output-shape assertions (count what actually lands on disk) ==" @@ -66,8 +83,8 @@ if [ -f "$OUT/test.mp4" ]; then else echo " SKIP T17 (no test video; opencv-python missing)" fi -# T18: duplicate stems (1.jpg + 1.png in one dir) must yield one DISTINCT output per input -# in every stem-keyed writer: annotated jpgs, --save-txt, YOLO label export. +# T18: duplicate stems (1.jpg + 1.png in one dir) are rejected before inference. +# A frozen evidence run must fail closed instead of overwriting one prediction. mkdir -p "$OUT/dup" cp "$IMG" "$OUT/dup/1.jpg" python3 - "$IMG" "$OUT/dup/1.png" <<'PY' 2>/dev/null || cp "$IMG" "$OUT/dup/1.png" @@ -78,13 +95,10 @@ try: except Exception: raise SystemExit(1) PY -run -m "$ONNX" -s "$OUT/dup" --quiet --out "$OUT/dout" --save-txt "$OUT/dtxt" --export-labels "$OUT/dlbl" >/dev/null 2>&1 -DJ=$(ls "$OUT"/dout/*.jpg 2>/dev/null | wc -l) -DT=$(ls "$OUT"/dtxt/*.txt 2>/dev/null | wc -l) -DL=$(ls "$OUT"/dlbl/*.txt 2>/dev/null | grep -vc classes) -[ "$DJ" = 2 ] && [ "$DT" = 2 ] && [ "$DL" = 2 ] \ - && ok "T18 duplicate stems -> distinct outputs" \ - || no "T18 duplicate-stem collision (jpg=$DJ txt=$DT lbl=$DL, want 2 each)" +ERR=$(run -m "$ONNX" -s "$OUT/dup" --quiet --no-save 2>&1) +printf '%s\n' "$ERR" | grep -qi "duplicate image stems" \ + && ok "T18 duplicate stems rejected before inference" \ + || no "T18 duplicate-stem rejection" rm -rf "$OUT" echo "======================================" diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/annotate_export.cpp b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/annotate_export.cpp index 932b8801c..4d2b6b28c 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/annotate_export.cpp +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/annotate_export.cpp @@ -2,11 +2,22 @@ // Detector.maskPolygons (tracer), annotationInstances + the export orchestrators in // mac/Sources/YOLOMasterKit/AnnotationExport.swift (the sink replaces the Mac orchestrators // so three consumers - CLI, GUI folder, GUI video - share one emit path). +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#endif #include "annotate_export.hpp" #include "stb_image_write.h" +#include #include +#include #include +#ifdef _WIN32 +#include +#endif + namespace yolomaster { std::vector> seg_polygons( @@ -90,8 +101,12 @@ std::vector> seg_polygons( } scored.emplace_back(std::abs(area) / 2, std::move(flat)); } - std::sort(scored.begin(), scored.end(), - [](const auto& a, const auto& b) { return a.first > b.first; }); + std::sort(scored.begin(), scored.end(), [](const auto& a, const auto& b) { + if (a.first != b.first) return a.first > b.first; + // Polygon areas can tie after quantisation. A lexical coordinate + // tie-break prevents platform/library-dependent annotation order. + return a.second < b.second; + }); for (auto& s : scored) { if (static_cast(rings.size()) >= max_polygons) break; rings.push_back(std::move(s.second)); @@ -127,7 +142,9 @@ std::vector annotation_instances( } static bool write_text(const std::string& path, const std::string& body, std::string& err) { - std::ofstream f(path, std::ios::binary); + // u8path preserves UTF-8 destination names on Windows (where the narrow + // ofstream constructor otherwise follows the process ANSI code page). + std::ofstream f(std::filesystem::u8path(path), std::ios::binary); if (!f) { err = "cannot write " + path; return false; } f << body; if (!f) { err = "write failed: " + path; return false; } @@ -181,11 +198,56 @@ AnnotationSink::Result AnnotationSink::finish() { return {images_, instances_, error_}; } +namespace { + +struct JpegFileContext { + FILE* file = nullptr; + bool ok = true; +}; + +void jpeg_file_write(void* opaque, void* data, int size) { + auto* context = static_cast(opaque); + if (!context || !context->file || !context->ok || size <= 0) return; + context->ok = std::fwrite(data, 1, static_cast(size), context->file) == + static_cast(size); +} + +#ifdef _WIN32 +static std::wstring utf8_path(const std::string& value) { + if (value.empty()) return {}; + const int needed = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + value.data(), static_cast(value.size()), + nullptr, 0); + if (needed <= 0) return {}; + std::wstring result(static_cast(needed), L'\0'); + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + value.data(), static_cast(value.size()), + result.data(), needed) != needed) + return {}; + return result; +} +#endif + +} // namespace + bool write_jpg(const std::string& path, const cv::Mat& bgr) { + if (bgr.empty() || bgr.type() != CV_8UC3) return false; cv::Mat rgb; cv::cvtColor(bgr, rgb, cv::COLOR_BGR2RGB); if (!rgb.isContinuous()) rgb = rgb.clone(); - return stbi_write_jpg(path.c_str(), rgb.cols, rgb.rows, 3, rgb.data, 90) != 0; +#ifdef _WIN32 + const std::wstring wide = utf8_path(path); + if (wide.empty()) return false; + FILE* file = _wfopen(wide.c_str(), L"wb"); +#else + FILE* file = std::fopen(path.c_str(), "wb"); +#endif + if (!file) return false; + JpegFileContext context{file, true}; + const int encoded = stbi_write_jpg_to_func(jpeg_file_write, &context, + rgb.cols, rgb.rows, 3, rgb.data, 90); + const bool closed = std::fclose(file) == 0; + return encoded != 0 && context.ok && closed; } } // namespace yolomaster diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/common.cpp b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/common.cpp index 4564bd0d5..a15ce34b0 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/common.cpp +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/common.cpp @@ -2,12 +2,18 @@ // drawing, model-metadata parsing, and versatile source resolution. #include "yolomaster.hpp" #include +#include #include #include +#include #include #include #include #include +#include +#include +#include +#include namespace fs = std::filesystem; @@ -25,6 +31,8 @@ const std::vector& sku110k_classes() { } cv::Mat preprocess(const cv::Mat& img, int imgsz, bool stretch, LetterboxInfo& info) { + if (img.empty() || img.cols <= 0 || img.rows <= 0 || imgsz <= 0) + throw std::invalid_argument("preprocess requires a non-empty image and positive imgsz"); info.orig_w = img.cols; info.orig_h = img.rows; if (stretch) { @@ -40,8 +48,8 @@ cv::Mat preprocess(const cv::Mat& img, int imgsz, bool stretch, LetterboxInfo& i // letterbox: min-scale aspect-preserving, 114-gray padded, centered. const float r = std::min(imgsz / static_cast(img.cols), imgsz / static_cast(img.rows)); - const int nw = static_cast(std::round(img.cols * r)); - const int nh = static_cast(std::round(img.rows * r)); + const int nw = std::max(1, static_cast(std::round(img.cols * r))); + const int nh = std::max(1, static_cast(std::round(img.rows * r))); info.scale = info.scale_x = info.scale_y = r; info.pad_x = (imgsz - nw) / 2; info.pad_y = (imgsz - nh) / 2; @@ -73,8 +81,11 @@ static void nms_greedy(const std::vector& boxes, const std::vector order; order.reserve(scores.size()); for (size_t i = 0; i < scores.size(); ++i) - if (scores[i] >= conf) order.push_back(static_cast(i)); - std::sort(order.begin(), order.end(), [&](int a, int b) { return scores[a] > scores[b]; }); + if (std::isfinite(scores[i]) && scores[i] >= conf) order.push_back(static_cast(i)); + std::sort(order.begin(), order.end(), [&](int a, int b) { + if (scores[a] != scores[b]) return scores[a] > scores[b]; + return a < b; + }); std::vector dead(boxes.size(), 0); for (size_t m = 0; m < order.size(); ++m) { const int i = order[m]; @@ -88,11 +99,25 @@ static void nms_greedy(const std::vector& boxes, const std::vector pre-NMS candidates (feat_dim = 4 + nc [+ nm mask coeffs]). Box in orig px. -std::vector decode_candidates(const float* out, int feat_dim, int num_anchors, - const Config& cfg, const LetterboxInfo& lb) { +static float candidate_conf_threshold(const Config& cfg, double area) { + if (cfg.small_conf_thresh >= 0.f && area < cfg.small_area) + return std::min(cfg.conf_thresh, cfg.small_conf_thresh); + return cfg.conf_thresh; +} + +// Decode raw output -> pre-NMS candidates. Ultralytics YOLOv8/EsMoE heads use +// ``4 + nc [+ nm]`` features, while YOLOv5-style exports include one objectness +// channel (``4 + 1 + nc``). The latter is detected only when model metadata +// supplies a class count, avoiding an ambiguous guess for arbitrary tensors. +static std::vector decode_candidates_impl(const float* out, int feat_dim, + int num_anchors, const Config& cfg, + const LetterboxInfo& lb, + bool has_objectness) { const int nc = cfg.num_classes() > 0 ? cfg.num_classes() : (feat_dim - 4); - const int nm = feat_dim - 4 - nc; // mask-coeff count (0 = detection) + const int class_offset = 4 + (has_objectness ? 1 : 0); + const int nm = feat_dim - class_offset - nc; // mask-coeff count (0 = detection) + if (!out || nc <= 0 || nm < 0 || num_anchors <= 0) + throw std::invalid_argument("invalid detection tensor dimensions"); std::vector cands; auto make = [&](int a, int cls, float score) { const float cx = out[0 * num_anchors + a]; @@ -101,51 +126,132 @@ std::vector decode_candidates(const float* out, int feat_dim, int num_an const float h = out[3 * num_anchors + a]; const float x0 = (cx - 0.5f * w - lb.pad_x) / lb.scale_x; const float y0 = (cy - 0.5f * h - lb.pad_y) / lb.scale_y; + if (!std::isfinite(score) || !std::isfinite(x0) || !std::isfinite(y0) || + !std::isfinite(w) || !std::isfinite(h) || w <= 0.f || h <= 0.f) return; RawDet d; d.box = cv::Rect2f(x0, y0, w / lb.scale_x, h / lb.scale_y); d.score = score; d.cls = cls; if (nm > 0) { d.mask_coeffs.resize(nm); - for (int k = 0; k < nm; ++k) d.mask_coeffs[k] = out[(4 + nc + k) * num_anchors + a]; } + for (int k = 0; k < nm; ++k) d.mask_coeffs[k] = out[(class_offset + nc + k) * num_anchors + a]; } cands.push_back(std::move(d)); }; for (int a = 0; a < num_anchors; ++a) { - int best = -1; float bestv = 0.f; bool any = false; + // Compute the box geometry before thresholding so an optional + // small-object floor is measured in original-image pixels, matching + // the Python MNN validator and the Issue #51 NMS sweep. + const float cx = out[0 * num_anchors + a]; + const float cy = out[1 * num_anchors + a]; + const float w = out[2 * num_anchors + a]; + const float h = out[3 * num_anchors + a]; + const float bw = w / lb.scale_x; + const float bh = h / lb.scale_y; + const double area = static_cast(bw) * static_cast(bh); + if (!std::isfinite(cx) || !std::isfinite(cy) || !std::isfinite(w) || + !std::isfinite(h) || !std::isfinite(bw) || !std::isfinite(bh) || + !std::isfinite(area) || w <= 0.f || h <= 0.f || bw <= 0.f || bh <= 0.f) + continue; + const float threshold = candidate_conf_threshold(cfg, area); + int best = -1; float bestv = -std::numeric_limits::infinity(); bool any = false; + const float objectness = has_objectness ? out[4 * num_anchors + a] : 1.0f; for (int c = 0; c < nc; ++c) { - const float v = out[(4 + c) * num_anchors + a]; + const float v = objectness * out[(class_offset + c) * num_anchors + a]; + if (!std::isfinite(v)) continue; if (v > bestv) { bestv = v; best = c; } - if (cfg.multi_label && v >= cfg.conf_thresh) any = true; + if (cfg.multi_label && v >= threshold) any = true; } - if (!(cfg.multi_label ? any : (bestv >= cfg.conf_thresh))) continue; + if (!(cfg.multi_label ? any : (bestv >= threshold))) continue; if (cfg.multi_label) { // one candidate per class >= conf for (int c = 0; c < nc; ++c) { - const float v = out[(4 + c) * num_anchors + a]; - if (v >= cfg.conf_thresh) make(a, c, v); + const float v = objectness * out[(class_offset + c) * num_anchors + a]; + if (v >= threshold) make(a, c, v); } } else make(a, best, bestv); // single best class } return cands; } +std::vector decode_candidates(const float* out, int feat_dim, int num_anchors, + const Config& cfg, const LetterboxInfo& lb) { + const int nc = cfg.num_classes(); + const bool has_objectness = nc > 0 && feat_dim == 5 + nc; + return decode_candidates_impl(out, feat_dim, num_anchors, cfg, lb, has_objectness); +} + +std::vector decode_candidates(const float* out, int feat_dim, int num_anchors, + const Config& cfg, const LetterboxInfo& lb, + bool has_objectness) { + return decode_candidates_impl(out, feat_dim, num_anchors, cfg, lb, has_objectness); +} + // Per-class NMS (ultralytics agnostic=False via class offset) + max_det cap + clip-to-frame. std::vector nms_and_cap(const std::vector& cands, const Config& cfg, int orig_w, int orig_h) { std::vector boxes; std::vector scores; std::vector idx; boxes.reserve(cands.size()); scores.reserve(cands.size()); idx.reserve(cands.size()); for (size_t i = 0; i < cands.size(); ++i) { - if (cands[i].score < cfg.conf_thresh) continue; + const double area = static_cast(cands[i].box.width) * + static_cast(cands[i].box.height); + if (!std::isfinite(area) || cands[i].box.width <= 0.f || cands[i].box.height <= 0.f) + continue; + if (cands[i].score < candidate_conf_threshold(cfg, area)) continue; boxes.emplace_back(cands[i].box.x, cands[i].box.y, cands[i].box.width, cands[i].box.height); scores.push_back(cands[i].score); idx.push_back(static_cast(i)); } + // Match Ultralytics' max_nms guard before the quadratic suppression pass. + // A low VisDrone confidence floor combined with multi-label decoding can + // otherwise create tens of thousands of candidates and make NMS dominate + // runtime. The tie-break by original index keeps the result deterministic. + constexpr size_t kMaxNmsCandidates = 30000; + if (boxes.size() > kMaxNmsCandidates) { + std::vector order(boxes.size()); + for (size_t i = 0; i < order.size(); ++i) order[i] = static_cast(i); + std::partial_sort( + order.begin(), order.begin() + static_cast(kMaxNmsCandidates), order.end(), + [&](int a, int b) { + if (scores[a] != scores[b]) return scores[a] > scores[b]; + return a < b; + }); + order.resize(kMaxNmsCandidates); + std::vector capped_boxes; + std::vector capped_scores; + std::vector capped_idx; + capped_boxes.reserve(kMaxNmsCandidates); + capped_scores.reserve(kMaxNmsCandidates); + capped_idx.reserve(kMaxNmsCandidates); + for (const int position : order) { + capped_boxes.push_back(boxes[position]); + capped_scores.push_back(scores[position]); + capped_idx.push_back(idx[position]); + } + boxes.swap(capped_boxes); + scores.swap(capped_scores); + idx.swap(capped_idx); + } std::vector keep; // Per-class stratification: translate each class into its own disjoint stratum so one - // greedy pass does agnostic=False NMS. Dynamic offset: candidates are UNCLIPPED (the - // letterbox inverse can overshoot the frame), so leave generous margin beyond the - // largest dimension; IoU is translation-invariant, results match any big-enough offset. - const double OFF = 2.0 * std::max(orig_w, orig_h) + 8192.0; + // greedy pass does agnostic=False NMS. Candidates are intentionally kept unclipped until + // after suppression, so derive the offset from the actual finite coordinates rather than + // from the frame dimensions. A malformed but finite export can otherwise place a box + // beyond the old fixed margin and make two classes suppress one another. + double max_extent = std::max(1.0, static_cast(std::max(orig_w, orig_h))); + for (const auto& box : boxes) { + max_extent = std::max(max_extent, std::abs(box.x)); + max_extent = std::max(max_extent, std::abs(box.y)); + max_extent = std::max(max_extent, std::abs(box.x + box.width)); + max_extent = std::max(max_extent, std::abs(box.y + box.height)); + } + // All coordinates originate as finite float values, so this multiplication remains well + // below DBL_MAX. The extra margin makes equality at a class boundary impossible. + const double OFF = 2.0 * max_extent + 1.0; std::vector off = boxes; for (size_t k = 0; k < off.size(); ++k) { const int cls = cands[idx[k]].cls; off[k].x += cls * OFF; off[k].y += cls * OFF; } - nms_greedy(off, scores, cfg.conf_thresh, cfg.iou_thresh, keep); + // Every candidate has already been filtered with its area-specific + // threshold. Pass the lowest active floor so NMS does not discard a + // small-object candidate accepted by the override. + const float nms_floor = cfg.small_conf_thresh >= 0.f + ? std::min(cfg.conf_thresh, cfg.small_conf_thresh) : cfg.conf_thresh; + nms_greedy(off, scores, nms_floor, cfg.iou_thresh, keep); // Cluster-Weighted refinement (ultralytics cluster branch): each greedy survivor's box // becomes the score-and-proximity weighted average of its cluster - every same-class @@ -156,7 +262,12 @@ std::vector nms_and_cap(const std::vector& cands, const Confi // pool = top-3000 of the conf-filtered candidates by score (upstream 3000-cap) std::vector pool(boxes.size()); for (size_t i = 0; i < pool.size(); ++i) pool[i] = static_cast(i); - auto by_score = [&](int a, int b) { return scores[a] > scores[b]; }; + auto by_score = [&](int a, int b) { + if (scores[a] != scores[b]) return scores[a] > scores[b]; + // Explicit tie-break keeps cluster membership reproducible across + // standard-library implementations and compiler versions. + return a < b; + }; if (pool.size() > 3000) { std::partial_sort(pool.begin(), pool.begin() + 3000, pool.end(), by_score); pool.resize(3000); @@ -297,17 +408,75 @@ void draw(cv::Mat& img, const std::vector& dets, const Config& cfg) { namespace meta { std::vector parse_names_dict(const std::string& s) { - // keys are unquoted ints, values are quoted strings -> extract quoted tokens in order - std::vector names; - for (size_t i = 0; i < s.size();) { - const char q = s[i]; - if (q == '\'' || q == '"') { - size_t j = i + 1; std::string tok; - while (j < s.size() && s[j] != q) tok += s[j++]; - names.push_back(tok); - i = j + 1; - } else ++i; + // Ultralytics has emitted all of the following forms over time: + // {0: 'person', 1: 'car'} (Python repr) + // {"0": "person", "1": "car"} (JSON object) + // ["person", "car"] (JSON list) + // Extract key/value pairs rather than every quoted token: the latter + // mistakenly turns JSON's numeric keys into class names and shifts the + // class ABI by one position. + std::map keyed; + std::vector listed; + const auto skip_ws = [&](size_t& p) { + while (p < s.size() && std::isspace(static_cast(s[p]))) ++p; + }; + const auto quoted = [&](size_t& p, std::string& value) -> bool { + skip_ws(p); + if (p >= s.size() || (s[p] != '\'' && s[p] != '"')) return false; + const char q = s[p++]; + value.clear(); + while (p < s.size()) { + const char c = s[p++]; + if (c == '\\' && p < s.size()) { value.push_back(s[p++]); continue; } + if (c == q) return true; + value.push_back(c); + } + return false; + }; + size_t p = 0; + skip_ws(p); + if (p < s.size() && s[p] == '[') { + ++p; + while (p < s.size()) { + skip_ws(p); + if (p < s.size() && s[p] == ']') break; + std::string value; + if (!quoted(p, value)) break; + listed.push_back(std::move(value)); + skip_ws(p); + if (p < s.size() && s[p] == ',') ++p; + } + return listed; + } + if (p >= s.size() || s[p] != '{') return {}; + ++p; + while (p < s.size()) { + skip_ws(p); + if (p < s.size() && s[p] == '}') break; + int key = -1; + size_t key_start = p; + if (p < s.size() && (s[p] == '\'' || s[p] == '"')) { + std::string key_text; + if (!quoted(p, key_text)) break; + try { key = std::stoi(key_text); } catch (...) { key = -1; } + } else { + while (p < s.size() && (std::isdigit(static_cast(s[p])) || s[p] == '-')) ++p; + if (p == key_start) break; + try { key = std::stoi(s.substr(key_start, p - key_start)); } catch (...) { key = -1; } + } + skip_ws(p); + if (p >= s.size() || s[p] != ':') break; + ++p; + std::string value; + if (!quoted(p, value)) break; + if (key >= 0) keyed[key] = std::move(value); + skip_ws(p); + if (p < s.size() && s[p] == ',') ++p; } + if (keyed.empty()) return {}; + const int max_key = keyed.rbegin()->first; + std::vector names(static_cast(max_key + 1)); + for (const auto& item : keyed) names[static_cast(item.first)] = item.second; return names; } @@ -317,13 +486,26 @@ static std::string trim(const std::string& s) { return (a == std::string::npos) ? "" : s.substr(a, b - a + 1); } -bool read_ncnn_yaml(const std::string& path, std::vector& names, int& imgsz) { - std::ifstream f(path); +bool read_ncnn_yaml(const std::string& path, std::vector& names, int& imgsz, + std::string* input_blob, std::string* output_blob, + std::string* proto_blob) { + std::ifstream f(fs::u8path(path)); if (!f) return false; std::map nm; imgsz = 0; + if (input_blob) input_blob->clear(); + if (output_blob) output_blob->clear(); + if (proto_blob) proto_blob->clear(); std::string line; enum { NONE, NAMES, IMGSZ } sec = NONE; + auto scalar = [](const std::string& value) { + std::string out = trim(value); + if (out.size() >= 2 && ((out.front() == '"' && out.back() == '"') || + (out.front() == '\'' && out.back() == '\''))) { + out = out.substr(1, out.size() - 2); + } + return out; + }; while (std::getline(f, line)) { const bool indented = !line.empty() && (line[0] == ' ' || line[0] == '\t' || line[0] == '-'); if (!indented) { // top-level key -> switch/close section @@ -334,13 +516,28 @@ bool read_ncnn_yaml(const std::string& path, std::vector& names, in if (p != std::string::npos) imgsz = std::atoi(line.c_str() + p + 1); continue; } + if (line.rfind("input_blob:", 0) == 0) { + if (input_blob) *input_blob = scalar(line.substr(std::string("input_blob:").size())); + sec = NONE; continue; + } + if (line.rfind("output_blob:", 0) == 0) { + if (output_blob) *output_blob = scalar(line.substr(std::string("output_blob:").size())); + sec = NONE; continue; + } + if (line.rfind("proto_blob:", 0) == 0) { + if (proto_blob) *proto_blob = scalar(line.substr(std::string("proto_blob:").size())); + sec = NONE; continue; + } sec = NONE; continue; } if (sec == NAMES) { // " 0: pedestrian" auto colon = line.find(':'); if (colon != std::string::npos) { - const int idx = std::atoi(trim(line.substr(0, colon)).c_str()); - nm[idx] = trim(line.substr(colon + 1)); + const int idx = std::atoi(scalar(line.substr(0, colon)).c_str()); + // Exporters commonly emit JSON-quoted YAML scalars. Strip + // the surrounding quotes so class labels are not displayed + // as part of the name (and remain stable across backends). + nm[idx] = scalar(line.substr(colon + 1)); } } else if (sec == IMGSZ && imgsz == 0) { // "- 640" auto d = line.find_first_of("0123456789"); @@ -349,17 +546,25 @@ bool read_ncnn_yaml(const std::string& path, std::vector& names, in } names.clear(); for (auto& kv : nm) names.push_back(kv.second); - return !names.empty(); + return !names.empty() || + imgsz > 0 || + (input_blob && !input_blob->empty()) || + (output_blob && !output_blob->empty()) || + (proto_blob && !proto_blob->empty()); } } // namespace meta // ---------------- source ---------------- static std::string lower(std::string s) { - std::transform(s.begin(), s.end(), s.begin(), ::tolower); + std::transform(s.begin(), s.end(), s.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); return s; } -static const std::set kImageExt = {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp"}; +// Keep this list in sync with imread_bgr/stb_image. stb_image does not decode +// TIFF or WebP; advertising those suffixes would make a directory run count +// unreadable files as inputs and produce misleading acceptance summaries. +static const std::set kImageExt = {".jpg", ".jpeg", ".png", ".bmp"}; static const std::set kVideoExt = {".mp4", ".avi", ".mov", ".mkv", ".webm"}; SourceKind classify_source(const std::string& src) { @@ -367,6 +572,7 @@ SourceKind classify_source(const std::string& src) { if (fs::is_directory(src, ec)) return SourceKind::Dir; const std::string ext = lower(fs::path(src).extension().string()); if (ext == ".yaml" || ext == ".yml") return SourceKind::Dataset; + if (ext == ".txt" || ext == ".list") return SourceKind::List; if (kVideoExt.count(ext)) return SourceKind::Video; if (kImageExt.count(ext)) return SourceKind::Image; return SourceKind::Unknown; @@ -374,49 +580,264 @@ SourceKind classify_source(const std::string& src) { static void collect_dir(const std::string& dir, std::vector& out) { std::error_code ec; - for (auto& e : fs::directory_iterator(dir, ec)) { - if (!e.is_regular_file()) continue; + fs::recursive_directory_iterator it(dir, fs::directory_options::skip_permission_denied, ec); + const fs::recursive_directory_iterator end; + for (; it != end; it.increment(ec)) { + if (ec) { ec.clear(); continue; } + const auto& e = *it; + if (!e.is_regular_file(ec)) continue; if (kImageExt.count(lower(e.path().extension().string()))) - out.push_back(e.path().string()); + out.push_back(e.path().lexically_normal().string()); + } + // Directory traversal order is not guaranteed by the filesystem. Use a + // separator-normalized, case-folded key so diagnostic runs are stable on + // Windows and Linux; callers requiring a publication-grade order should + // still provide an explicit frozen image list. + std::sort(out.begin(), out.end(), [](const std::string& a, const std::string& b) { + const std::string ak = lower(fs::u8path(a).generic_string()); + const std::string bk = lower(fs::u8path(b).generic_string()); + return ak == bk ? a < b : ak < bk; + }); +} + +static std::string trim_source_line(const std::string& value) { + const auto first = value.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return {}; + const auto last = value.find_last_not_of(" \t\r\n"); + return value.substr(first, last - first + 1); +} + +static void validate_source_stems(const std::vector& images) { + std::string error; + if (!validate_unique_stems(images, error)) throw std::runtime_error(error); +} + +// Read a frozen, newline-delimited image list. Unlike directory traversal, +// this path preserves the experiment's declared order, which is part of the +// Issue #51 evidence manifest. Relative paths are resolved against the list +// file, matching the convention used by the Python evaluators. +static std::vector resolve_image_list(const std::string& list_path) { + std::ifstream f(fs::u8path(list_path)); + if (!f) throw std::runtime_error("failed to open image list: " + list_path); + const fs::path absolute_list = fs::absolute(fs::u8path(list_path)); + const fs::path base = absolute_list.parent_path(); + std::vector out; + std::string line; + size_t line_number = 0; + while (std::getline(f, line)) { + ++line_number; + if (line_number == 1 && line.size() >= 3 && + static_cast(line[0]) == 0xef && + static_cast(line[1]) == 0xbb && + static_cast(line[2]) == 0xbf) { + line.erase(0, 3); // UTF-8 BOM from a Windows-generated list + } + line = trim_source_line(line); + if (line.empty() || line.front() == '#') continue; + // Accept either YAML/Python-style single quotes or JSON-style double + // quotes. The Python evaluators apply the same rule; keeping the + // parser symmetric matters when one frozen list is shared by C++ and + // the metric scripts. Only strip a matching pair so an apostrophe in + // an unquoted filename remains part of the path. + if (line.size() >= 2 && line.front() == line.back() && + (line.front() == '\'' || line.front() == '"')) + line = trim_source_line(line.substr(1, line.size() - 2)); + fs::path image = fs::u8path(line); + if (image.is_relative()) image = base / image; + image = image.lexically_normal(); + std::error_code ec; + if (!fs::is_regular_file(image, ec)) { + throw std::runtime_error("image list line " + std::to_string(line_number) + + " does not name a regular file: " + image.u8string()); + } + if (!kImageExt.count(lower(image.extension().string()))) { + throw std::runtime_error("unsupported image extension at list line " + + std::to_string(line_number) + ": " + image.u8string()); + } + out.push_back(image.u8string()); } - std::sort(out.begin(), out.end()); + if (out.empty()) throw std::runtime_error("image list is empty: " + list_path); + validate_source_stems(out); + return out; +} + +// Keep dataset YAML list entries on the same frozen-list validation path. +static std::vector resolve_image_list_entry(const fs::path& c) { + return resolve_image_list(c.string()); } -// best-effort dataset.yaml -> val image dir +// Resolve the small, intentionally supported subset of Ultralytics dataset +// YAML needed by the runner. `val` may be a scalar, an inline sequence, or a +// block sequence. We do not pull in a YAML library for this command-line +// utility, but malformed/unsupported entries fail closed with a useful error. static std::vector resolve_dataset(const std::string& yaml) { - std::ifstream f(yaml); - std::string path, val, line; + std::ifstream f(fs::u8path(yaml)); + if (!f) throw std::runtime_error("failed to open dataset YAML: " + yaml); + std::string path, line; + std::vector val_entries; + bool val_block = false; + int val_indent = -1; + auto yaml_scalar = [](std::string value) { + value = trim_source_line(value); + if (value.size() >= 2 && + ((value.front() == '\"' && value.back() == '\"') || + (value.front() == '\'' && value.back() == '\''))) { + value = value.substr(1, value.size() - 2); + } + return trim_source_line(value); + }; + auto strip_yaml_comment = [](const std::string& value) { + bool single = false, doubled = false; + for (size_t i = 0; i < value.size(); ++i) { + const char c = value[i]; + if (c == '\'' && !doubled) single = !single; + else if (c == '"' && !single) doubled = !doubled; + else if (c == '#' && !single && !doubled && + (i == 0 || std::isspace(static_cast(value[i - 1])))) + return value.substr(0, i); + } + return value; + }; + auto inline_sequence = [&](const std::string& value) { + std::vector entries; + std::string body = trim_source_line(strip_yaml_comment(value)); + if (body.size() < 2 || body.front() != '[' || body.back() != ']') return entries; + body = body.substr(1, body.size() - 2); + size_t start = 0; + bool single = false, doubled = false; + for (size_t i = 0; i <= body.size(); ++i) { + const char c = (i < body.size()) ? body[i] : ','; + if (c == '\'' && !doubled) single = !single; + else if (c == '"' && !single) doubled = !doubled; + if (c == ',' && !single && !doubled) { + std::string item = yaml_scalar(body.substr(start, i - start)); + if (!item.empty()) entries.push_back(std::move(item)); + start = i + 1; + } + } + return entries; + }; + size_t line_number = 0; while (std::getline(f, line)) { - auto kv = [&](const char* k, std::string& dst) { - if (line.rfind(k, 0) == 0) { - std::string v = line.substr(std::strlen(k)); - auto h = v.find('#'); if (h != std::string::npos) v = v.substr(0, h); - size_t a = v.find_first_not_of(" \t"); size_t b = v.find_last_not_of(" \t\r"); - dst = (a == std::string::npos) ? "" : v.substr(a, b - a + 1); + ++line_number; + if (line_number == 1 && line.size() >= 3 && + static_cast(line[0]) == 0xef && + static_cast(line[1]) == 0xbb && + static_cast(line[2]) == 0xbf) + line.erase(0, 3); // UTF-8 BOM from a Windows-generated YAML + const std::string raw = line; + const size_t first = raw.find_first_not_of(" \t"); + const int indent = (first == std::string::npos) ? 0 : static_cast(first); + const std::string content = (first == std::string::npos) ? "" : raw.substr(first); + + if (!content.empty() && content.front() != '#') { + if (indent == 0 && content.rfind("path:", 0) == 0) { + path = yaml_scalar(strip_yaml_comment(content.substr(5))); + val_block = false; + continue; } - }; - kv("path:", path); - kv("val:", val); + if (indent == 0 && content.rfind("val:", 0) == 0) { + const std::string value = trim_source_line(strip_yaml_comment(content.substr(4))); + val_entries.clear(); + if (value.empty()) { + val_block = true; + // `val` is a top-level key in the supported dataset + // schema; list items must therefore be more indented. + val_indent = indent; + } else { + const auto listed = inline_sequence(value); + if (!listed.empty() || trim_source_line(value) == "[]") + val_entries = listed; + else + val_entries.push_back(yaml_scalar(value)); + val_block = false; + } + continue; + } + } + + if (val_block) { + if (first == std::string::npos || content.empty() || content.front() == '#') continue; + if (val_indent < 0) val_indent = indent; + // YAML also permits an indentationless block sequence: + // val: + // - images/val + // Accept a dash at the key indentation as long as + // it is clearly a sequence item; any other top-level key closes + // the block. + if (content.front() == '-' && indent >= val_indent) { + const std::string item = yaml_scalar( + strip_yaml_comment(content.size() > 1 ? content.substr(1) : "")); + if (!item.empty()) val_entries.push_back(item); + continue; + } + if (indent <= val_indent) { + // A new top-level key closes a block sequence. It is handled + // on its own line above; no val item is inferred from it. + val_block = false; + continue; + } + } } - if (val.empty()) return {}; - const fs::path ydir = fs::path(yaml).parent_path(); - std::vector cands = { - fs::path(val), // absolute val - fs::path(path) / val, // path/val (path absolute) - ydir / path / val, // yaml_dir/path/val - ydir / val, // yaml_dir/val - fs::path("/data/datasets") / path / val, - }; - std::error_code ec; - for (auto& c : cands) { - if (fs::is_directory(c, ec)) { std::vector v; collect_dir(c.string(), v); if (!v.empty()) return v; } - if (fs::is_regular_file(c, ec) && lower(c.extension().string()) == ".txt") { - std::vector v; std::ifstream tf(c); std::string l; - while (std::getline(tf, l)) { if (!l.empty() && l.back() == '\r') l.pop_back(); if (!l.empty()) v.push_back(l); } - if (!v.empty()) return v; + if (val_entries.empty()) + throw std::runtime_error("dataset YAML has no supported non-empty 'val' split"); + // Resolve a relative dataset `path:` against the YAML file first, as + // Ultralytics does. Falling back to the process working directory is + // retained for legacy manifests that intentionally use cwd-relative + // paths. Canonicalising the YAML location also handles a bare filename + // (`data.yaml`) without introducing an empty path component. + const fs::path yaml_abs = fs::absolute(fs::u8path(yaml)).lexically_normal(); + const fs::path ydir = yaml_abs.parent_path(); + const fs::path dataset_root = path.empty() + ? ydir + : (fs::path(path).is_absolute() ? fs::path(path) + : (ydir / fs::path(path)).lexically_normal()); + std::vector out; + for (const std::string& entry : val_entries) { + const fs::path val_path = fs::u8path(entry); + // For relative values the YAML-defined root is authoritative. The + // process-working-directory candidate is retained only as a + // compatibility fallback for older manifests written with cwd paths. + std::vector cands; + if (val_path.is_absolute()) { + cands.push_back(val_path); + } else { + cands.push_back(dataset_root / val_path); + cands.push_back(ydir / val_path); + cands.push_back(val_path); } + cands.push_back(fs::path("/data/datasets") / path / val_path); + std::error_code ec; + bool resolved = false; + for (const auto& c : cands) { + if (fs::is_directory(c, ec)) { + std::vector images; + collect_dir(c.string(), images); + if (!images.empty()) { + out.insert(out.end(), images.begin(), images.end()); + resolved = true; + break; + } + } + const std::string extension = lower(c.extension().string()); + if (fs::is_regular_file(c, ec) && (extension == ".txt" || extension == ".list")) { + const auto images = resolve_image_list_entry(c); + out.insert(out.end(), images.begin(), images.end()); + resolved = true; + break; + } + if (fs::is_regular_file(c, ec) && kImageExt.count(extension)) { + out.push_back(c.lexically_normal().u8string()); + resolved = true; + break; + } + } + if (!resolved) + throw std::runtime_error("dataset val entry does not resolve to an image directory or .txt/.list: " + entry); } - return {}; + if (out.empty()) throw std::runtime_error("dataset val split contains no supported images"); + validate_source_stems(out); + return out; } std::vector gather_images(const std::string& src, int limit) { @@ -425,10 +846,29 @@ std::vector gather_images(const std::string& src, int limit) { case SourceKind::Image: out = {src}; break; case SourceKind::Dir: collect_dir(src, out); break; case SourceKind::Dataset: out = resolve_dataset(src); break; + case SourceKind::List: out = resolve_image_list(src); break; default: break; } if (limit > 0 && static_cast(out.size()) > limit) out.resize(limit); return out; } +bool validate_unique_stems(const std::vector& images, std::string& error) { + std::map seen; + for (const std::string& image : images) { + const fs::path path(image); + const std::string stem = path.stem().string(); + const std::string key = lower(stem); + const auto it = seen.find(key); + if (it != seen.end()) { + error = "duplicate image stems: '" + stem + "' in " + it->second + + " and " + image; + return false; + } + seen.emplace(key, image); + } + error.clear(); + return true; +} + } // namespace yolomaster diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/main.cpp b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/main.cpp index ed13d5536..75d718e94 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/main.cpp +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/main.cpp @@ -1,46 +1,349 @@ // yolomaster_edge - universal, adaptive YOLO-Master edge runner. // Runtime model loading (no baked-in weights), backend/classes/imgsz auto-detected -// from the model, versatile --source (image / dir / video / dataset.yaml). +// from the model, versatile --source (image / dir / list / video / dataset.yaml). +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#endif #include "yolomaster.hpp" #include "slicing.hpp" #include "annotate_export.hpp" #include "backend_factory.hpp" #include "CLI11.hpp" #include "stb_image.h" -#include "stb_image_write.h" #include +#include +#include +#include +#include +#include +#include #include #include +#include #include #include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#endif using namespace yolomaster; namespace fs = std::filesystem; -// image I/O via stb (avoids OpenCV imgcodecs -> GDAL/DB/poppler dependency closure) +// image I/O via stb (avoids OpenCV imgcodecs -> GDAL/DB/poppler dependency closure). +// On Windows open through _wfopen so UTF-8 paths are not routed through the +// process ANSI code page. +#ifdef _WIN32 +static std::wstring utf8_to_wide(const std::string& value) { + if (value.empty()) return {}; + const int needed = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), nullptr, 0); + if (needed <= 0) throw std::runtime_error("image path is not valid UTF-8"); + std::wstring result(static_cast(needed), L'\0'); + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), result.data(), needed) != needed) + throw std::runtime_error("failed to convert image path to UTF-16"); + return result; +} +#endif static cv::Mat imread_bgr(const std::string& path) { int w, h, n; - unsigned char* d = stbi_load(path.c_str(), &w, &h, &n, 3); // force 3-channel RGB + unsigned char* d = nullptr; +#ifdef _WIN32 + std::wstring wide; + try { wide = utf8_to_wide(path); } + catch (...) { return cv::Mat(); } + FILE* file = _wfopen(wide.c_str(), L"rb"); +#else + FILE* file = std::fopen(path.c_str(), "rb"); +#endif + if (!file) return cv::Mat(); + d = stbi_load_from_file(file, &w, &h, &n, 3); // force 3-channel RGB + std::fclose(file); if (!d) return cv::Mat(); cv::Mat bgr; cv::cvtColor(cv::Mat(h, w, CV_8UC3, d), bgr, cv::COLOR_RGB2BGR); stbi_image_free(d); return bgr; } -static bool imwrite_jpg(const std::string& path, const cv::Mat& bgr) { - cv::Mat rgb; cv::cvtColor(bgr, rgb, cv::COLOR_BGR2RGB); - if (!rgb.isContinuous()) rgb = rgb.clone(); - return stbi_write_jpg(path.c_str(), rgb.cols, rgb.rows, 3, rgb.data, 90) != 0; +struct BenchmarkRow { + std::string image; + double preprocess_ms = 0.0; + double inference_ms = 0.0; + double postprocess_ms = 0.0; + double total_ms = 0.0; + int detections = 0; +}; + +static std::string csv_escape(const std::string& value) { + std::string escaped = "\""; + for (char c : value) { + if (c == '\"') escaped += "\"\""; + else escaped += c; + } + escaped += '\"'; + return escaped; +} + +static double percentile(std::vector values, double pct) { + if (values.empty()) return 0.0; + std::sort(values.begin(), values.end()); + const double rank = (pct / 100.0) * static_cast(values.size() - 1); + const size_t lo = static_cast(std::floor(rank)); + const size_t hi = static_cast(std::ceil(rank)); + if (lo == hi) return values[lo]; + const double weight = rank - static_cast(lo); + return values[lo] * (1.0 - weight) + values[hi] * weight; +} + +static std::string json_escape(const std::string& value) { + std::string escaped; + escaped.reserve(value.size() + 8); + for (unsigned char c : value) { + switch (c) { + case '\\': escaped += "\\\\"; break; + case '"': escaped += "\\\""; break; + case '\b': escaped += "\\b"; break; + case '\f': escaped += "\\f"; break; + case '\n': escaped += "\\n"; break; + case '\r': escaped += "\\r"; break; + case '\t': escaped += "\\t"; break; + default: + if (c < 0x20) { + char buf[7]; + std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); + escaped += buf; + } else { + escaped += static_cast(c); + } + } + } + return escaped; +} + +static std::string host_os() { +#ifdef _WIN32 + return "windows"; +#elif defined(__APPLE__) + return "macos"; +#elif defined(__linux__) + return "linux"; +#else + return "unknown"; +#endif +} + +static std::string host_arch() { +#if defined(__aarch64__) || defined(_M_ARM64) + return "aarch64"; +#elif defined(__x86_64__) || defined(_M_X64) || defined(__amd64__) + return "x86_64"; +#elif defined(__i386__) || defined(_M_IX86) + return "x86"; +#elif defined(__arm__) || defined(_M_ARM) + return "arm"; +#else + return "unknown"; +#endif +} + +static std::string compiler_id() { +#if defined(_MSC_VER) + return "MSVC " + std::to_string(_MSC_VER); +#elif defined(__clang__) + return std::string("Clang ") + __clang_version__; +#elif defined(__GNUC__) + return std::string("GCC ") + __VERSION__; +#else + return "unknown"; +#endif +} + +static std::string trim_copy(std::string value) { + const auto first = value.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return {}; + const auto last = value.find_last_not_of(" \t\r\n"); + return value.substr(first, last - first + 1); +} + +static std::string cpu_model() { +#ifdef _WIN32 + if (const char* value = std::getenv("PROCESSOR_IDENTIFIER")) { + if (*value) return value; + } +#elif defined(__linux__) + std::ifstream cpuinfo("/proc/cpuinfo"); + std::string line; + while (std::getline(cpuinfo, line)) { + const auto colon = line.find(':'); + if (colon == std::string::npos) continue; + const std::string key = trim_copy(line.substr(0, colon)); + if (key == "model name" || key == "Hardware" || key == "Processor") { + const std::string value = trim_copy(line.substr(colon + 1)); + if (!value.empty()) return value; + } + } +#elif defined(__APPLE__) + if (const char* value = std::getenv("HOSTTYPE")) { + if (*value) return value; + } +#endif + return "unknown"; +} + +static std::string build_date() { + return std::string(__DATE__) + " " + __TIME__; +} + +static bool write_benchmark_json(const std::string& path, const std::vector& rows, + int warmup, int runs, int threads, const std::string& model, + const std::string& source, const std::string& backend, + const std::string& execution_provider, const std::string& profile, + const Config& cfg, const std::string& csv_path, + long frames, long failed_frames, double wall_sec) { + const fs::path output_path = fs::u8path(path); + if (const fs::path parent = output_path.parent_path(); !parent.empty()) { + std::error_code ec; + fs::create_directories(parent, ec); + if (ec) return false; + } + std::ofstream out(output_path); + if (!out) return false; + std::vector prep, infer, post, totals; + prep.reserve(rows.size()); + infer.reserve(rows.size()); + post.reserve(rows.size()); + totals.reserve(rows.size()); + for (const auto& row : rows) { + prep.push_back(row.preprocess_ms); + infer.push_back(row.inference_ms); + post.push_back(row.postprocess_ms); + totals.push_back(row.total_ms); + } + const double mean = totals.empty() + ? 0.0 + : std::accumulate(totals.begin(), totals.end(), 0.0) / totals.size(); + const auto stats_json = [](const std::vector& values) { + const double avg = values.empty() + ? 0.0 + : std::accumulate(values.begin(), values.end(), 0.0) / values.size(); + std::ostringstream s; + s << std::setprecision(10) + << "{\"count\":" << values.size() + << ",\"mean_ms\":" << avg + << ",\"p50_ms\":" << percentile(values, 50.0) + << ",\"p95_ms\":" << percentile(values, 95.0) + << ",\"p99_ms\":" << percentile(values, 99.0) + << ",\"fps\":" << (avg > 0.0 ? 1000.0 / avg : 0.0) << "}"; + return s.str(); + }; + out << std::setprecision(10); + out << "{\n" + << " \"schema_version\": 1,\n" + << " \"status\": \"" << (failed_frames == 0 ? "completed" : "partial") << "\",\n" + << " \"model\": \"" << json_escape(model) << "\",\n" + << " \"source\": \"" << json_escape(source) << "\",\n" + << " \"protocol\": {\n" + << " \"backend\": \"" << json_escape(backend) << "\",\n" + << " \"execution_provider\": \"" << json_escape(execution_provider) << "\",\n" + << " \"profile\": \"" << json_escape(profile) << "\",\n" + << " \"imgsz\": " << cfg.imgsz << ",\n" + << " \"conf\": " << cfg.conf_thresh << ",\n" + << " \"iou\": " << cfg.iou_thresh << ",\n" + << " \"small_conf\": " << cfg.small_conf_thresh << ",\n" + << " \"small_area\": " << cfg.small_area << ",\n" + << " \"max_det\": " << cfg.max_det << ",\n" + << " \"multi_label\": " << (cfg.multi_label ? "true" : "false") << ",\n" + << " \"letterbox\": " << (cfg.stretch ? "false" : "true") << ",\n" + << " \"nms_mode\": \"" + << (cfg.nms_mode == NmsMode::ClusterWeighted ? "cluster_weighted" : "standard") << "\",\n" + << " \"cw_sigma\": " << cfg.cw_sigma << ",\n" + << " \"class_count\": " << cfg.num_classes() << ",\n" + << " \"warmup\": " << warmup << ",\n" + << " \"runs\": " << runs << ",\n" + << " \"threads\": " << threads << "\n" + << " },\n" + << " \"host\": {\n" + << " \"os\": \"" << host_os() << "\",\n" + << " \"architecture\": \"" << host_arch() << "\",\n" + << " \"compiler\": \"" << json_escape(compiler_id()) << "\",\n" + << " \"cpu\": \"" << json_escape(cpu_model()) << "\",\n" + << " \"logical_cpus\": " << std::thread::hardware_concurrency() << ",\n" + << " \"build_date\": \"" << build_date() << "\"\n" + << " },\n" + << " \"summary\": {\n" + << " \"frames\": " << frames << ",\n" + << " \"timed_images\": " << rows.size() << ",\n" + << " \"failed_inputs\": " << failed_frames << ",\n" + << " \"mean_ms\": " << mean << ",\n" + << " \"p50_ms\": " << percentile(totals, 50.0) << ",\n" + << " \"p95_ms\": " << percentile(totals, 95.0) << ",\n" + << " \"p99_ms\": " << percentile(totals, 99.0) << ",\n" + << " \"fps\": " << (mean > 0.0 ? 1000.0 / mean : 0.0) << ",\n" + << " \"wall_seconds\": " << wall_sec << ",\n" + << " \"timing_ms\": {\n" + << " \"preprocess\": " << stats_json(prep) << ",\n" + << " \"inference\": " << stats_json(infer) << ",\n" + << " \"postprocess\": " << stats_json(post) << ",\n" + << " \"total\": " << stats_json(totals) << "\n" + << " }\n" + << " },\n" + << " \"timing_csv\": "; + if (csv_path.empty()) out << "null\n"; + else out << "\"" << json_escape(csv_path) << "\"\n"; + out << "}\n"; + return out.good(); +} + +static bool write_benchmark_csv(const std::string& path, const std::vector& rows, + int warmup, int runs, int threads, int imgsz, + float conf, float iou) { + std::ofstream out(fs::u8path(path)); + if (!out) return false; + out << "image,preprocess_ms,inference_ms,postprocess_ms,total_ms,detections,mean_ms,p50_ms,p95_ms,p99_ms,fps\n"; + std::vector totals; + totals.reserve(rows.size()); + for (const auto& row : rows) { + out << csv_escape(row.image) << ',' << row.preprocess_ms << ',' << row.inference_ms << ',' + << row.postprocess_ms << ',' << row.total_ms << ',' << row.detections << ",,,,,\n"; + totals.push_back(row.total_ms); + } + const double sum = std::accumulate(totals.begin(), totals.end(), 0.0); + const double mean = totals.empty() ? 0.0 : sum / static_cast(totals.size()); + // Keep aggregate rows parseable by the shared CSV helper: non-numeric + // metadata belongs in the run header on stdout, not in a timing column. + (void)warmup; + (void)runs; + (void)threads; + (void)imgsz; + (void)conf; + (void)iou; + out << "#summary,,,,,," << mean << ',' << percentile(totals, 50.0) << ',' + << percentile(totals, 95.0) << ',' << percentile(totals, 99.0) << ',' + << (mean > 0.0 ? 1000.0 / mean : 0.0) << '\n'; + return out.good(); } int main(int argc, char** argv) { CLI::App app{"yolomaster_edge - universal YOLO-Master edge runner (ONNX / ncnn / MNN / TensorRT)"}; std::string model, source, backend = "auto", classes_opt = "auto", outdir = "runs_edge"; - std::string device = "cpu", savetxt; + std::string profile = "default"; + std::string device = "cpu", savetxt, csv_path, benchmark_json_path; int imgsz = 0, threads = 4, limit = 0, max_det = 300; + int warmup = 0, runs = 1; float conf = 0.25f, iou = 0.50f; - bool no_save = false, quiet = false, multilabel = false, stretch = false; + float small_conf = -1.0f, small_area = 32.0f * 32.0f; + bool no_save = false, quiet = false, multilabel = false, single_label = false, stretch = false; std::string slicing = "off", label_format = "yolo", sampling = "1s", export_labels; int tile_size = 0; bool slicing_masks = false, cw_nms = false; @@ -48,20 +351,44 @@ int main(int argc, char** argv) { app.add_option("-m,--model", model, "model: .onnx, .mnn, .engine/.trt, ncnn directory, or .param file")->required(); - app.add_option("-s,--source", source, "image / directory / video / dataset.yaml")->required(); + app.add_option("-s,--source", source, "image / directory / .txt or .list image list / video / dataset.yaml")->required(); app.add_option("-b,--backend", backend, "auto|onnx|ncnn|mnn|trt")->default_str("auto"); app.add_option("-d,--device", device, "backend-dependent: cpu, cuda, vulkan, opencl, trt, or coreml")->default_str("cpu"); - app.add_option("--classes", classes_opt, "auto|visdrone|sku (auto = from model metadata)")->default_str("auto"); - app.add_option("--imgsz", imgsz, "inference size (0 = from model / 640)"); - app.add_option("--conf", conf, "confidence threshold")->capture_default_str(); - app.add_option("--iou", iou, "NMS IoU threshold")->capture_default_str(); - app.add_option("--max-det", max_det, "max detections per image after NMS")->capture_default_str(); + app.add_option( + "--profile", profile, + "post-processing profile: default|visdrone|sku110k (thresholds are overridable)") + ->default_str("default"); + app.add_option("--classes", classes_opt, "auto|visdrone|sku110k (auto = from model metadata)")->default_str("auto"); + auto* imgsz_opt = app.add_option("--imgsz", imgsz, "inference size (0 = from model / profile)"); + auto* conf_opt = app.add_option("--conf", conf, "confidence threshold")->capture_default_str(); + auto* iou_opt = app.add_option("--iou", iou, "NMS IoU threshold")->capture_default_str(); + auto* maxdet_opt = app.add_option("--max-det", max_det, "max detections per image after NMS") + ->capture_default_str(); + app.add_option( + "--small-conf", small_conf, + "optional lower confidence for boxes below --small-area (-1 disables)") + ->capture_default_str(); + app.add_option( + "--small-area", small_area, + "original-image area threshold for --small-conf (pixels^2)") + ->capture_default_str(); app.add_option("--threads", threads, "CPU threads")->capture_default_str(); app.add_option("--limit", limit, "cap #inputs (0 = all)"); + app.add_option("--warmup", warmup, "untimed warm-up inferences per first input (benchmark only)") + ->capture_default_str(); + app.add_option("--runs", runs, "timed repetitions per input (benchmark only)")->capture_default_str(); + app.add_option("--csv", csv_path, "write per-image benchmark CSV (enables timing summary)"); + app.add_option("--benchmark-json", benchmark_json_path, + "write benchmark protocol/host/summary JSON sidecar (enables timing summary)"); app.add_option("--out", outdir, "output dir for annotated results")->capture_default_str(); app.add_option("--save-txt", savetxt, "dir to write per-image predictions ('class conf x1 y1 x2 y2')"); - app.add_flag("--multi-label", multilabel, "one detection per class >= conf per anchor (matches ultralytics val mAP)"); + auto* multilabel_opt = app.add_flag( + "--multi-label", multilabel, + "one detection per class >= conf per anchor (matches ultralytics val mAP)"); + auto* singlelabel_opt = app.add_flag( + "--single-label", single_label, + "diagnostic argmax-per-anchor decoding (mutually exclusive with --multi-label)"); app.add_flag("--stretch", stretch, "preprocess by stretching to square instead of aspect-preserving letterbox"); app.add_flag("--no-save", no_save, "do not write annotated outputs"); app.add_flag("--quiet", quiet, "suppress per-image logs"); @@ -75,6 +402,57 @@ int main(int argc, char** argv) { app.add_option("--sampling", sampling, "video label export: all|1s|N (every Nth frame)")->default_str("1s"); CLI11_PARSE(app, argc, argv); + if (multilabel_opt->count() > 0 && singlelabel_opt->count() > 0) { + std::cerr << "--multi-label and --single-label are mutually exclusive\n"; + return 2; + } + if (singlelabel_opt->count() > 0) multilabel = false; + + profile = lower_ascii(profile); + classes_opt = lower_ascii(classes_opt); + if (classes_opt == "sku") classes_opt = "sku110k"; + if (classes_opt != "auto" && classes_opt != "visdrone" && classes_opt != "sku110k") { + std::cerr << "unknown --classes: " << classes_opt + << " (expected auto, visdrone, or sku110k)\n"; + return 2; + } + + // The generic runner keeps conservative defaults, while the explicit + // vertical profiles reproduce the Issue #51 evaluation recipe. Only + // values omitted by the caller are filled in, so deployment-specific + // thresholds remain possible and are visible in the run header. + // Canonical VisDrone defaults: imgsz = 640, conf = 0.001f, + // iou = 0.70f, multi_label = true. + if (profile == "visdrone") { + if (imgsz_opt->count() == 0) imgsz = 640; + if (conf_opt->count() == 0) conf = 0.001f; + if (iou_opt->count() == 0) iou = 0.70f; + if (maxdet_opt->count() == 0) max_det = 300; + if (multilabel_opt->count() == 0 && singlelabel_opt->count() == 0) multilabel = true; + } else if (profile == "sku110k") { + if (imgsz_opt->count() == 0) imgsz = 1280; + if (conf_opt->count() == 0) conf = 0.25f; + if (iou_opt->count() == 0) iou = 0.60f; + if (maxdet_opt->count() == 0) max_det = 300; + // Keep the profile metadata identical to the Python evaluator. SKU-110K + // has one class, so this does not change the decoded boxes, but it makes + // the protocol explicit and prevents a cross-backend manifest mismatch. + if (multilabel_opt->count() == 0 && singlelabel_opt->count() == 0) multilabel = true; + } else if (profile != "default") { + std::cerr << "unknown --profile: " << profile << " (expected default, visdrone, or sku110k)\n"; + return 2; + } + if (!std::isfinite(conf) || conf < 0.f || conf > 1.f || + !std::isfinite(iou) || iou < 0.f || iou > 1.f || + !std::isfinite(small_conf) || small_conf < -1.f || small_conf > 1.f || + !std::isfinite(small_area) || small_area < 0.f || + max_det <= 0 || threads <= 0 || warmup < 0 || runs <= 0 || limit < 0) { + std::cerr << "conf/iou must be in [0,1], small-conf in [-1,1], small-area " + "non-negative, max-det/threads/runs positive, and warmup/limit " + "non-negative\n"; + return 2; + } + SliceMode slice_mode = SliceMode::Off; if (slicing == "dense") slice_mode = SliceMode::Dense; else if (slicing == "sparse") slice_mode = SliceMode::Sparse; @@ -98,6 +476,8 @@ int main(int argc, char** argv) { Config cfg; cfg.conf_thresh = conf; cfg.iou_thresh = iou; + cfg.small_conf_thresh = small_conf; + cfg.small_area = small_area; cfg.max_det = max_det; cfg.multi_label = multilabel; cfg.stretch = stretch; @@ -105,20 +485,67 @@ int main(int argc, char** argv) { cfg.cw_sigma = std::min(0.5f, std::max(0.01f, sigma)); int want = imgsz > 0 ? imgsz : (be->meta_imgsz > 0 ? be->meta_imgsz : 640); if (be->fixed_imgsz > 0 && want != be->fixed_imgsz) { - std::cerr << "[warn] model requires fixed imgsz=" << be->fixed_imgsz - << "; overriding requested imgsz=" << want << "\n"; + // A caller-supplied size, or a canonical evaluation profile, is part + // of the protocol and must not be silently rewritten to fit a model. + // Generic runs with no explicit size may still adopt a model's static + // input as a convenience. + const bool canonical_profile = profile == "visdrone" || profile == "sku110k"; + if (imgsz_opt->count() > 0 || canonical_profile) { + std::cerr << "model requires fixed imgsz=" << be->fixed_imgsz + << "; requested imgsz=" << want << " is incompatible\n"; + return 2; + } + std::cout << "[model] using fixed imgsz=" << be->fixed_imgsz + << " (model constraint; no explicit --imgsz supplied)\n"; want = be->fixed_imgsz; } cfg.imgsz = want; std::string classes_src; - if (classes_opt == "visdrone") { cfg.class_names = visdrone_classes(); classes_src = "flag:visdrone"; } - else if (classes_opt == "sku" || classes_opt == "sku110k") { cfg.class_names = sku110k_classes(); classes_src = "flag:sku"; } - else if (!be->meta_names.empty()) { cfg.class_names = be->meta_names; classes_src = "model-metadata"; } - else { cfg.class_names = visdrone_classes(); classes_src = "fallback:visdrone"; } + const std::vector* profile_names = nullptr; + if (profile == "visdrone") profile_names = &visdrone_classes(); + else if (profile == "sku110k") profile_names = &sku110k_classes(); + + if (profile_names) { + // An explicit profile defines both thresholds and the class ABI. Do + // not let ``--classes auto`` silently select (for example) COCO-80 + // metadata from a VisDrone run; that would decode the output with the + // wrong feature count and invalidate the metric. + if (!be->meta_names.empty() && be->meta_names.size() != profile_names->size()) { + std::cerr << "model metadata declares " << be->meta_names.size() + << " classes, but --profile " << profile << " requires " + << profile_names->size() << "; use a matching model or --profile default\n"; + return 2; + } + if (classes_opt != "auto" && classes_opt != profile) { + std::cerr << "--profile " << profile << " conflicts with --classes " << classes_opt << "\n"; + return 2; + } + cfg.class_names = *profile_names; + classes_src = "profile:" + profile; + } else if (classes_opt == "visdrone") { + cfg.class_names = visdrone_classes(); classes_src = "flag:visdrone"; + } else if (classes_opt == "sku110k") { + cfg.class_names = sku110k_classes(); classes_src = "flag:sku110k"; + } else if (!be->meta_names.empty()) { + cfg.class_names = be->meta_names; classes_src = "model-metadata"; + } else { + cfg.class_names = visdrone_classes(); classes_src = "fallback:visdrone"; + } std::cout << "[model] " << model << " backend=" << backend << " ep=" << be->active_ep + << " profile=" << profile << " imgsz=" << cfg.imgsz << " nc=" << cfg.num_classes() << " (" << classes_src << ")" - << " conf=" << cfg.conf_thresh << " iou=" << cfg.iou_thresh << " max_det=" << cfg.max_det << "\n"; + << " conf=" << cfg.conf_thresh << " iou=" << cfg.iou_thresh + << " small_conf=" << cfg.small_conf_thresh + << " small_area=" << cfg.small_area + << " max_det=" << cfg.max_det << " multi_label=" << (cfg.multi_label ? "true" : "false") + << " threads=" << threads; + if (!csv_path.empty() || !benchmark_json_path.empty()) { + std::cout << " warmup=" << warmup << " runs=" << runs; + if (!csv_path.empty()) std::cout << " csv=" << csv_path; + if (!benchmark_json_path.empty()) std::cout << " benchmark_json=" << benchmark_json_path; + } + std::cout << "\n"; if (!no_save) { std::error_code ec; fs::create_directories(outdir, ec); } if (!savetxt.empty()) { std::error_code ec; fs::create_directories(savetxt, ec); } @@ -162,8 +589,13 @@ int main(int argc, char** argv) { }; auto t_start = std::chrono::high_resolution_clock::now(); - long frames = 0, total_dets = 0; + long frames = 0, failed_frames = 0, total_dets = 0; double sum_pre = 0, sum_inf = 0, sum_post = 0; + std::vector benchmark_rows; + const bool benchmark_requested = !csv_path.empty() || !benchmark_json_path.empty(); + const int timed_runs = benchmark_requested ? runs : 1; + bool warmed_up = false; + std::vector failures; // Video sources: annotated output becomes ONE mp4 (per-frame jpgs would overwrite each // other - "11.mp4#930" stems to "11"), and --save-txt gets frame-indexed names. @@ -178,41 +610,94 @@ int main(int argc, char** argv) { // coco_file/coco_id: the COCO doc's file_name (may carry "frames/") and explicit image // id (0 = sequence). export=false skips label emission (non-sampled video frames). auto run_one = [&](const cv::Mat& img, const std::string& tag, - bool do_export = true, const std::string& coco_file = "", int coco_id = 0) { - if (img.empty()) { std::cerr << " [skip] unreadable: " << tag << "\n"; return; } + bool do_export = true, const std::string& coco_file = "", int coco_id = 0) -> bool { + auto record_failure = [&](const std::string& reason) { + ++failed_frames; + failures.push_back(tag + ": " + reason); + }; + if (img.empty()) { + std::cerr << " [skip] unreadable: " << tag << "\n"; + record_failure("unreadable image"); + return false; + } + if (!warmed_up && benchmark_requested && warmup > 0) { + try { + for (int i = 0; i < warmup; ++i) { + if (slice_mode != SliceMode::Off) { + (void)sliced_candidates(*be, img, cfg, sconf); + } else { + (void)be->infer(img, cfg); + } + } + warmed_up = true; + } catch (const std::exception& e) { + std::cerr << " [skip] warm-up failed on " << tag << ": " << e.what() << "\n"; + record_failure(std::string("warm-up error: ") + e.what()); + return false; + } + } + std::vector dets; std::string slice_note; + double image_pre = 0.0, image_inf = 0.0, image_post = 0.0; try { - if (slice_mode != SliceMode::Off) { - const SliceOutput so = sliced_candidates(*be, img, cfg, sconf); - dets = nms_and_cap(be->candidates, cfg, img.cols, img.rows); - tstats.add(so.tiles_run, so.tiles_total, so.tile_size_used, - so.used_fallback, so.capped); - slice_note = " tiles=" + std::to_string(so.tiles_run) + "/" - + std::to_string(so.tiles_total) + " @" + std::to_string(so.tile_size_used) + "px" - + (so.used_fallback ? " [fallback]" : "") + (so.capped ? " [capped]" : ""); - } else { - dets = be->infer(img, cfg); + for (int repeat = 0; repeat < timed_runs; ++repeat) { + if (slice_mode != SliceMode::Off) { + const SliceOutput so = sliced_candidates(*be, img, cfg, sconf); + dets = nms_and_cap(be->candidates, cfg, img.cols, img.rows); + tstats.add(so.tiles_run, so.tiles_total, so.tile_size_used, + so.used_fallback, so.capped); + slice_note = " tiles=" + std::to_string(so.tiles_run) + "/" + + std::to_string(so.tiles_total) + " @" + std::to_string(so.tile_size_used) + "px" + + (so.used_fallback ? " [fallback]" : "") + (so.capped ? " [capped]" : ""); + // sliced_candidates aggregates all model forwards, including + // preprocessing and postprocessing for each tile. Read the + // stage sums from SliceOutput so the CSV row describes the + // complete image rather than the final tile only. + image_pre += so.pre_ms; + image_inf += so.infer_ms; + image_post += so.post_ms; + } else { + dets = be->infer(img, cfg); + image_pre += be->pre_ms; + image_inf += be->infer_ms; + image_post += be->post_ms; + } } } catch (const std::exception& e) { std::cerr << " [skip] inference error on " << tag << ": " << e.what() << "\n"; - return; + record_failure(std::string("inference error: ") + e.what()); + return false; + } + image_pre /= timed_runs; + image_inf /= timed_runs; + image_post /= timed_runs; + if (benchmark_requested) { + benchmark_rows.push_back({tag, image_pre, image_inf, image_post, + image_pre + image_inf + image_post, + static_cast(dets.size())}); } if (!export_labels.empty() && do_export) { - AnnotationSink& s = ensure_sink(); - annot::Image aimg; - aimg.name = fs::path(tag).stem().string(); - if (coco_id > 0) aimg.name = fs::path(coco_file).stem().string(); - aimg.width = img.cols; aimg.height = img.rows; - aimg.instances = annotation_instances(dets, be->is_seg(), be->proto, be->proto_c, - be->proto_h, be->proto_w, be->cand_lb, cfg.imgsz); - s.add(aimg, coco_file.empty() ? fs::path(tag).filename().string() : coco_file, coco_id); + try { + AnnotationSink& s = ensure_sink(); + annot::Image aimg; + aimg.name = fs::path(tag).stem().string(); + if (coco_id > 0) aimg.name = fs::path(coco_file).stem().string(); + aimg.width = img.cols; aimg.height = img.rows; + aimg.instances = annotation_instances(dets, be->is_seg(), be->proto, be->proto_c, + be->proto_h, be->proto_w, be->cand_lb, cfg.imgsz); + s.add(aimg, coco_file.empty() ? fs::path(tag).filename().string() : coco_file, coco_id); + } catch (const std::exception& e) { + std::cerr << " [skip] label export error on " << tag << ": " << e.what() << "\n"; + record_failure(std::string("label export error: ") + e.what()); + return false; + } } frames++; total_dets += static_cast(dets.size()); - sum_pre += be->pre_ms; sum_inf += be->infer_ms; sum_post += be->post_ms; + sum_pre += image_pre; sum_inf += image_inf; sum_post += image_post; if (!quiet) std::cout << " " << tag << " dets=" << dets.size() - << " infer=" << be->infer_ms << "ms" << slice_note << "\n"; + << " infer=" << image_inf << "ms" << slice_note << "\n"; if (!no_save) { cv::Mat vis = img.clone(); if (be->is_seg()) { // alpha-composite segmentation masks under the boxes @@ -242,10 +727,22 @@ int main(int argc, char** argv) { std::cerr << " [warn] cannot open " << vwriter_path << " for writing\n"; } if (vwriter.isOpened()) vwriter.write(vis); - } else + else { + record_failure("annotated video writer is not open"); + return false; + } + } else { +#endif + const std::string output_path = (fs::path(outdir) / + (unique_stem(out_stems, fs::path(tag).stem().string()) + ".jpg")).string(); + if (!write_jpg(output_path, vis)) { + std::cerr << " [skip] failed to write annotated image: " << output_path << "\n"; + record_failure("annotated image write failed"); + return false; + } +#ifdef HAVE_VIDEOIO + } #endif - imwrite_jpg((fs::path(outdir) / - (unique_stem(out_stems, fs::path(tag).stem().string()) + ".jpg")).string(), vis); } if (!savetxt.empty()) { // 'class conf x1 y1 x2 y2' (pixel xyxy) std::string tstem = fs::path(tag).stem().string(); @@ -255,12 +752,24 @@ int main(int argc, char** argv) { coco_id - 1); tstem = b; } - std::ofstream f((fs::path(savetxt) / - (unique_stem(txt_stems, tstem) + ".txt")).string()); + const std::string txt_path = (fs::path(savetxt) / + (unique_stem(txt_stems, tstem) + ".txt")).string(); + std::ofstream f(fs::u8path(txt_path)); + if (!f) { + std::cerr << " [skip] failed to write predictions: " << txt_path << "\n"; + record_failure("prediction write failed"); + return false; + } for (const auto& d : dets) f << d.class_id << ' ' << d.conf << ' ' << d.box.x << ' ' << d.box.y << ' ' << (d.box.x + d.box.width) << ' ' << (d.box.y + d.box.height) << '\n'; + if (!f.good()) { + std::cerr << " [skip] failed while writing predictions: " << txt_path << "\n"; + record_failure("prediction write failed"); + return false; + } } + return true; }; if (kind == SourceKind::Video) { @@ -289,7 +798,14 @@ int main(int argc, char** argv) { if (sampled) { char fn[64]; std::snprintf(fn, sizeof(fn), "%s_%06ld.jpg", vstem.c_str(), idx); - write_jpg((fs::path(frames_dir) / fn).string(), frame); + const std::string frame_path = (fs::path(frames_dir) / fn).string(); + if (!write_jpg(frame_path, frame)) { + std::cerr << " [skip] failed to write sampled frame: " << frame_path << "\n"; + ++failed_frames; + failures.push_back(source + "#" + std::to_string(idx) + ": sampled frame write failed"); + ++idx; + continue; + } coco_file = std::string("frames/") + fn; } run_one(frame, source + "#" + std::to_string(idx), sampled, coco_file, @@ -297,12 +813,23 @@ int main(int argc, char** argv) { ++idx; } #else - std::cerr << "video source not supported in this portable build; use image/dir/dataset\n"; + std::cerr << "video source not supported in this portable build; use image/dir/list/dataset\n"; return 4; #endif } else { - auto imgs = gather_images(source, limit); + std::vector imgs; + try { + imgs = gather_images(source, limit); + } catch (const std::exception& e) { + std::cerr << "cannot resolve source: " << e.what() << "\n"; + return 4; + } if (imgs.empty()) { std::cerr << "no inputs resolved from source: " << source << "\n"; return 4; } + std::string stem_error; + if (!validate_unique_stems(imgs, stem_error)) { + std::cerr << stem_error << "\n"; + return 4; + } for (const auto& p : imgs) run_one(imread_bgr(p), p); } @@ -322,7 +849,11 @@ int main(int argc, char** argv) { << " fallbacks=" << tstats.fallbacks << " capped=" << tstats.capped << "\n"; if (sink) { const AnnotationSink::Result r = sink->finish(); - if (!r.error.empty()) std::cerr << "[labels] export failed: " << r.error << "\n"; + if (!r.error.empty()) { + std::cerr << "[labels] export failed: " << r.error << "\n"; + ++failed_frames; + failures.push_back("annotation sink: " + r.error); + } else std::cout << "[labels] " << annot::label(lfmt) << " images=" << r.images << " instances=" << r.instances << " -> " << export_labels << "/\n"; } @@ -335,5 +866,48 @@ int main(int argc, char** argv) { #endif std::cout << "[saved] annotated -> " << outdir << "/\n"; } + if (!csv_path.empty()) { + if (!write_benchmark_csv(csv_path, benchmark_rows, warmup, runs, threads, cfg.imgsz, + cfg.conf_thresh, cfg.iou_thresh)) { + std::cerr << "[benchmark] failed to write CSV: " << csv_path << "\n"; + ++failed_frames; + failures.push_back("benchmark CSV write failed: " + csv_path); + } + } + if (!benchmark_json_path.empty()) { + if (!write_benchmark_json(benchmark_json_path, benchmark_rows, warmup, runs, threads, + model, source, backend, be->active_ep, profile, cfg, + csv_path, frames, failed_frames, wall)) { + std::cerr << "[benchmark] failed to write JSON sidecar: " << benchmark_json_path << "\n"; + ++failed_frames; + failures.push_back("benchmark JSON write failed: " + benchmark_json_path); + } + } + if (benchmark_requested && !quiet) { + std::vector totals; + totals.reserve(benchmark_rows.size()); + for (const auto& row : benchmark_rows) totals.push_back(row.total_ms); + const double total_mean = totals.empty() + ? 0.0 + : std::accumulate(totals.begin(), totals.end(), 0.0) / totals.size(); + std::cout << "[benchmark] images=" << benchmark_rows.size() + << " mean=" << total_mean << "ms" + << " p50=" << percentile(totals, 50.0) + << " p95=" << percentile(totals, 95.0) + << " p99=" << percentile(totals, 99.0) + << " fps=" << (total_mean > 0.0 ? 1000.0 / total_mean : 0.0); + if (!csv_path.empty()) std::cout << " csv=" << csv_path; + if (!benchmark_json_path.empty()) std::cout << " json=" << benchmark_json_path; + std::cout << "\n"; + } + if (failed_frames > 0) { + std::cerr << "[summary] failed=" << failed_frames << " of " + << (frames + failed_frames) << " input(s)\n"; + const size_t shown = std::min(failures.size(), 8); + for (size_t i = 0; i < shown; ++i) std::cerr << " [failed] " << failures[i] << "\n"; + if (failures.size() > shown) + std::cerr << " [failed] ... " << (failures.size() - shown) << " more\n"; + return 6; + } return 0; } diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/mnn_backend.cpp b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/mnn_backend.cpp index 9867fcd07..73272d875 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/mnn_backend.cpp +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/mnn_backend.cpp @@ -1,9 +1,17 @@ #include "mnn_backend.hpp" #include +#include #include +#include +#include #include #include +#include +#include +#include #include +#include +#include namespace yolomaster { @@ -12,6 +20,128 @@ static double ms_since(const clk::time_point& t) { return std::chrono::duration(clk::now() - t).count(); } +static std::string shape_string(const std::vector& shape) { + std::ostringstream out; + out << '['; + for (size_t i = 0; i < shape.size(); ++i) { + if (i) out << ','; + out << shape[i]; + } + out << ']'; + return out.str(); +} + +static void validate_input_shape(const std::vector& shape, int requested_imgsz = 0) { + if (shape.size() != 4) + throw std::runtime_error("MNN input must have rank-4 shape [1,3,H,W], got " + shape_string(shape)); + if (shape[0] > 0 && shape[0] != 1) + throw std::runtime_error("MNN input batch dimension must be 1, got " + std::to_string(shape[0])); + if (shape[1] > 0 && shape[1] != 3) + throw std::runtime_error("MNN input channel dimension must be 3, got " + std::to_string(shape[1])); + for (size_t i = 2; i < 4; ++i) { + if (shape[i] == 0 || shape[i] < -1) + throw std::runtime_error("MNN input has invalid spatial dimension in " + shape_string(shape)); + } + if (shape[2] > 0 && shape[3] > 0 && shape[2] != shape[3]) + throw std::runtime_error("MNN runner requires square input, got " + shape_string(shape)); + if (requested_imgsz > 0) { + if (shape[2] > 0 && shape[2] != requested_imgsz) + throw std::runtime_error("MNN input height is fixed at " + std::to_string(shape[2]) + + "; requested imgsz=" + std::to_string(requested_imgsz)); + if (shape[3] > 0 && shape[3] != requested_imgsz) + throw std::runtime_error("MNN input width is fixed at " + std::to_string(shape[3]) + + "; requested imgsz=" + std::to_string(requested_imgsz)); + } +} + +static bool detection_shape(const std::vector& shape, int expected_features, + int& feat_dim, int& num_anchors) { + if (shape.size() != 3 || shape[0] != 1 || shape[1] <= 0 || shape[2] <= 0) + return false; + const int feature = std::min(shape[1], shape[2]); + const int anchors = std::max(shape[1], shape[2]); + if (feature < expected_features || anchors < feature) + return false; + feat_dim = feature; + num_anchors = anchors; + return true; +} + +static size_t checked_elements(const std::vector& shape, const char* label) { + if (shape.empty()) throw std::runtime_error(std::string("MNN ") + label + " shape is empty"); + size_t count = 1; + for (const int dim : shape) { + if (dim <= 0) { + throw std::runtime_error(std::string("MNN ") + label + + " shape has a non-positive dimension: " + shape_string(shape)); + } + const size_t extent = static_cast(dim); + if (count > std::numeric_limits::max() / extent) + throw std::runtime_error(std::string("MNN ") + label + " shape is too large"); + count *= extent; + } + return count; +} + +// MNN has kept the Interpreter API source-compatible across releases, but a +// few builds return ErrorCode/bool while others return void for tensor-copy +// helpers. Check every status-bearing variant without baking one SDK's return +// type into this example. +template +static void checked_mnn_call(const char* operation, Fn&& fn) { + using result_type = std::invoke_result_t; + if constexpr (std::is_void_v) { + fn(); + } else { + const auto status = fn(); + using status_type = std::decay_t; + bool failed = false; + if constexpr (std::is_same_v) { + failed = !status; + } else if constexpr (std::is_pointer_v) { + failed = status == nullptr; + } else if constexpr (std::is_integral_v || + std::is_enum_v) { + // MNN::ErrorCode uses NO_ERROR == 0; integer-compatible status + // values follow the same convention in older SDKs. + failed = static_cast(status) != 0; + } + if (failed) + throw std::runtime_error(std::string("MNN ") + operation + " failed"); + } +} + +template +struct has_tensor_type : std::false_type {}; + +template +struct has_tensor_type().getType())>> + : std::true_type {}; + +template +static bool is_float32_tensor_impl(TensorT* tensor) { + if (!tensor) return false; + if constexpr (has_tensor_type::value) { + const auto type = tensor->getType(); + return type.code == halide_type_float && type.bits == 32; + } + // Very old MNN headers do not expose getType(); host() remains the + // fallback validation after the tensor is copied to a host tensor. + return true; +} + +static bool is_float32_tensor(MNN::Tensor* tensor) { + return is_float32_tensor_impl(tensor); +} + +static void require_float32(MNN::Tensor* tensor, const char* label) { + if (!tensor) throw std::runtime_error(std::string("MNN ") + label + " tensor is null"); + if (!is_float32_tensor_impl(tensor)) { + throw std::runtime_error(std::string("MNN ") + label + + " tensor must be float32 (unsupported element type)"); + } +} + MnnBackend::MnnBackend(const std::string& model_path, int threads, const std::string& forward) : threads_(threads) { interp_ = std::shared_ptr( @@ -19,32 +149,121 @@ MnnBackend::MnnBackend(const std::string& model_path, int threads, const std::st [](MNN::Interpreter* p) { if (p) MNN::Interpreter::destroy(p); }); if (!interp_) throw std::runtime_error("MNN: failed to load " + model_path); - MNN::ScheduleConfig sc; - sc.numThread = threads; - sc.type = forward == "opencl" ? MNN_FORWARD_OPENCL - : forward == "vulkan" ? MNN_FORWARD_VULKAN - : forward == "cuda" ? MNN_FORWARD_CUDA - : MNN_FORWARD_CPU; - sc.backupType = MNN_FORWARD_CPU; // fall back to CPU if the GPU backend is unavailable at runtime - const bool gpu = (sc.type != MNN_FORWARD_CPU); - MNN::BackendConfig bc; - // fp16 on GPU (OpenCL/Vulkan/CUDA) for a large speedup; fp32 on CPU for accuracy/parity. - bc.precision = gpu ? MNN::BackendConfig::Precision_Low : MNN::BackendConfig::Precision_High; - bc.power = MNN::BackendConfig::Power_High; - sc.backendConfig = &bc; - - session_ = interp_->createSession(sc); - if (!session_) throw std::runtime_error("MNN: createSession failed for " + model_path); - input_ = interp_->getSessionInput(session_, nullptr); // first input - output_ = interp_->getSessionOutput(session_, nullptr); // first output - if (!input_ || !output_) throw std::runtime_error("MNN: could not resolve input/output tensor"); - active_ep = forward == "opencl" ? "MNN-OpenCL" - : forward == "vulkan" ? "MNN-Vulkan" - : forward == "cuda" ? "MNN-CUDA" : "MNN-CPU"; + const bool requested_gpu = forward == "opencl" || forward == "vulkan" || forward == "cuda"; + const auto requested_type = forward == "opencl" ? MNN_FORWARD_OPENCL + : forward == "vulkan" ? MNN_FORWARD_VULKAN + : forward == "cuda" ? MNN_FORWARD_CUDA + : MNN_FORWARD_CPU; + const std::string requested_ep = forward == "opencl" ? "MNN-OpenCL" + : forward == "vulkan" ? "MNN-Vulkan" + : forward == "cuda" ? "MNN-CUDA" : "MNN-CPU"; + + auto make_schedule = [&](decltype(MNN_FORWARD_CPU) type, MNN::BackendConfig& bc) { + MNN::ScheduleConfig config{}; + config.numThread = threads; + config.type = type; + // Keep unsupported operators on CPU. If the requested accelerator + // cannot be initialized at all, the constructor below retries with a + // clean CPU-only session instead of leaving a null backend. + config.backupType = MNN_FORWARD_CPU; + const bool gpu = type != MNN_FORWARD_CPU; + // fp16 on GPU (OpenCL/Vulkan/CUDA) for speed; fp32 on CPU for parity. + bc.precision = gpu ? MNN::BackendConfig::Precision_Low + : MNN::BackendConfig::Precision_High; + bc.power = MNN::BackendConfig::Power_High; + config.backendConfig = &bc; + return config; + }; + + MNN::BackendConfig requested_bc; + MNN::ScheduleConfig requested_sc = make_schedule(requested_type, requested_bc); + std::string session_error; + auto create_session = [&](MNN::ScheduleConfig config) -> MNN::Session* { + try { + return interp_->createSession(config); + } catch (const std::exception& e) { + session_error = e.what(); + } catch (...) { + session_error = "unknown MNN exception"; + } + return nullptr; + }; + session_ = create_session(requested_sc); + if (!session_ && requested_gpu) { + // Some MNN builds report an unavailable OpenCL/Vulkan/CUDA backend by + // returning nullptr from createSession. Retry with a fresh CPU + // schedule so --device gpu remains usable on a CPU-only target. + std::string reason = requested_ep + " session creation failed"; + if (!session_error.empty()) reason += ": " + session_error; + MNN::BackendConfig cpu_bc; + MNN::ScheduleConfig cpu_sc = make_schedule(MNN_FORWARD_CPU, cpu_bc); + session_error.clear(); + session_ = create_session(cpu_sc); + if (!session_) + throw std::runtime_error("MNN: " + reason + "; CPU fallback session creation failed" + + (session_error.empty() ? std::string() : ": " + session_error)); + active_ep = "MNN-CPU"; + ep_note = reason + "; fell back to CPU"; + std::cerr << "[mnn] " << ep_note << "\n"; + } else if (!session_) { + throw std::runtime_error("MNN: createSession failed for " + model_path + + (session_error.empty() ? std::string() : ": " + session_error)); + } else { + active_ep = requested_ep; + if (requested_gpu) + ep_note = requested_ep + " configured with CPU backup for unsupported operators"; + } + + std::string io_error; + auto resolve_io = [&]() -> bool { + try { + input_ = interp_->getSessionInput(session_, nullptr); // first input + output_ = interp_->getSessionOutput(session_, nullptr); // first output + } catch (const std::exception& e) { + io_error = e.what(); + input_ = nullptr; + output_ = nullptr; + } catch (...) { + io_error = "unknown MNN exception"; + input_ = nullptr; + output_ = nullptr; + } + return input_ != nullptr && output_ != nullptr; + }; + const bool io_ok = resolve_io(); + if (!io_ok && requested_gpu && active_ep != "MNN-CPU") { + // A few releases defer backend setup until tensors are resolved. If + // that path yields incomplete I/O, release it and retry CPU once. + try { + checked_mnn_call("releaseSession", [&] { return interp_->releaseSession(session_); }); + } catch (const std::exception& e) { + // Continue with the CPU retry; some old MNN builds report a + // teardown warning even though the session can be replaced. + io_error = std::string("accelerator release warning: ") + e.what(); + } + session_ = nullptr; + MNN::BackendConfig cpu_bc; + MNN::ScheduleConfig cpu_sc = make_schedule(MNN_FORWARD_CPU, cpu_bc); + session_error.clear(); + session_ = create_session(cpu_sc); + if (session_) resolve_io(); + if (!session_ || !input_ || !output_) + throw std::runtime_error("MNN: accelerator I/O resolution failed and CPU fallback failed" + + (!session_error.empty() ? ": " + session_error + : (io_error.empty() ? std::string() : ": " + io_error))); + active_ep = "MNN-CPU"; + ep_note = requested_ep + " I/O resolution failed; fell back to CPU"; + std::cerr << "[mnn] " << ep_note << "\n"; + } + if (!input_ || !output_) + throw std::runtime_error("MNN: could not resolve input/output tensor" + + (io_error.empty() ? std::string() : ": " + io_error)); + require_float32(input_, "input"); // YOLO-Master graphs bake the attention token counts at the training size -> fixed input. auto ishape = input_->shape(); // NCHW, e.g. {1,3,640,640} - if (ishape.size() == 4 && ishape[2] == ishape[3] && ishape[2] > 0) { + validate_input_shape(ishape); + if (ishape[2] > 0 && ishape[2] == ishape[3]) { fixed_imgsz = ishape[2]; meta_imgsz = ishape[2]; } @@ -61,21 +280,29 @@ MnnBackend::MnnBackend(const std::string& model_path, int threads, const std::st } MnnBackend::~MnnBackend() { - if (interp_ && session_) interp_->releaseSession(session_); // interp_ freed by shared_ptr deleter + if (interp_ && session_) { + // Destructors must not throw, but still surface a non-zero SDK status + // while keeping teardown noexcept. + try { + checked_mnn_call("releaseSession", [&] { return interp_->releaseSession(session_); }); + } catch (...) {} + } } std::vector MnnBackend::infer(const cv::Mat& bgr, const Config& cfg) { // ---- preprocess: letterbox -> NCHW float RGB /255 (identical to ORT) ---- + if (cfg.imgsz <= 0) throw std::runtime_error("MNN inference requires a positive image size"); auto t0 = clk::now(); LetterboxInfo lb; cv::Mat padded = preprocess(bgr, cfg.imgsz, cfg.stretch, lb); // imgsz x imgsz, CV_8UC3 BGR - const int sz = cfg.imgsz, hw = sz * sz; + const int sz = cfg.imgsz; + const size_t hw = static_cast(sz) * static_cast(sz); std::vector blob(3 * hw); for (int y = 0; y < sz; ++y) { const uint8_t* row = padded.ptr(y); for (int x = 0; x < sz; ++x) { const uint8_t* px = row + x * 3; // BGR - const int idx = y * sz + x; + const size_t idx = static_cast(y) * sz + x; blob[idx] = px[2] * (1.0f / 255); // R blob[hw + idx] = px[1] * (1.0f / 255); // G blob[2 * hw + idx] = px[0] * (1.0f / 255); // B @@ -83,59 +310,230 @@ std::vector MnnBackend::infer(const cv::Mat& bgr, const Config& cfg) } // resize the session input if it doesn't already match imgsz (handles fixed & flexible graphs) auto ishape = input_->shape(); + validate_input_shape(ishape); if (ishape.size() != 4 || ishape[2] != sz || ishape[3] != sz) { - interp_->resizeTensor(input_, std::vector{1, 3, sz, sz}); - interp_->resizeSession(session_); + checked_mnn_call("resizeTensor", [&] { + return interp_->resizeTensor(input_, std::vector{1, 3, sz, sz}); + }); + checked_mnn_call("resizeSession", [&] { return interp_->resizeSession(session_); }); + input_ = interp_->getSessionInput(session_, nullptr); output_ = interp_->getSessionOutput(session_, nullptr); + require_float32(input_, "input"); + if (!output_) throw std::runtime_error("MNN: output tensor disappeared after resize"); } + validate_input_shape(input_->shape(), sz); pre_ms = ms_since(t0); // ---- inference: copy blob into the input tensor (NCHW/Caffe), run ---- auto t1 = clk::now(); { MNN::Tensor host(input_, MNN::Tensor::CAFFE); // NCHW host tensor shaped like input_ - std::memcpy(host.host(), blob.data(), blob.size() * sizeof(float)); - input_->copyFromHostTensor(&host); + const size_t host_count = checked_elements(host.shape(), "input"); + if (host_count != blob.size()) + throw std::runtime_error("MNN input tensor size does not match the requested image size"); + float* host_data = host.host(); + if (!host_data) throw std::runtime_error("MNN input host tensor has no float storage"); + std::memcpy(host_data, blob.data(), blob.size() * sizeof(float)); + checked_mnn_call("copyFromHostTensor", [&] { + return input_->copyFromHostTensor(&host); + }); } - interp_->runSession(session_); + checked_mnn_call("runSession", [&] { return interp_->runSession(session_); }); infer_ms = ms_since(t1); // ---- postprocess: detection = rank-3 output [1,feat,anchors]; proto (seg) = rank-4 [1,nm,mh,mw] ---- auto t2 = clk::now(); auto all = interp_->getSessionOutputAll(session_); MNN::Tensor* detT = nullptr; MNN::Tensor* protoT = nullptr; + const int expected_features = std::max(5, 4 + cfg.num_classes()); + int det_features = 0, det_anchors = 0; + struct DetectionCandidate { + MNN::Tensor* tensor = nullptr; + std::string name; + int features = 0; + int anchors = 0; + int distance = 0; + size_t elements = 0; + bool has_objectness = false; + int mask_channels = 0; + }; + std::vector detection_candidates; + struct ProtoCandidate { MNN::Tensor* tensor = nullptr; std::string name; }; + std::vector proto_candidates; for (auto& kv : all) { - if (kv.second->shape().size() == 4) protoT = kv.second; - else detT = kv.second; + MNN::Tensor* tensor = kv.second; + if (!tensor) continue; + const std::string tensor_name = kv.first; + const auto shape = tensor->shape(); + if (shape.size() == 4) { + if (shape[0] != 1 || shape[1] <= 0 || shape[2] <= 0 || shape[3] <= 0) + throw std::runtime_error("MNN prototype output must have shape [1,C,H,W], got " + shape_string(shape)); + (void)checked_elements(shape, "prototype"); + if (is_float32_tensor(tensor)) + proto_candidates.push_back({tensor, tensor_name}); + continue; + } + int feat = 0, anchors = 0; + if (!detection_shape(shape, expected_features, feat, anchors)) continue; + const int distance = std::abs(feat - expected_features); + const size_t elements = static_cast(shape[1]) * static_cast(shape[2]); + if (!is_float32_tensor(tensor)) + continue; + detection_candidates.push_back( + {tensor, tensor_name, feat, anchors, distance, elements, false, 0}); + } + detection_candidates.erase( + std::remove_if(detection_candidates.begin(), detection_candidates.end(), + [&](DetectionCandidate& candidate) { + if (candidate.features == expected_features) return false; + if (candidate.features == expected_features + 1) { + candidate.has_objectness = true; + return false; + } + bool matched = false; + for (const ProtoCandidate& proto_candidate : proto_candidates) { + const int channels = proto_candidate.tensor->shape()[1]; + bool objectness = false; + if (candidate.features == expected_features + channels) { + objectness = false; + } else if (candidate.features == expected_features + 1 + channels) { + objectness = true; + } else { + continue; + } + if (matched && (candidate.has_objectness != objectness || + candidate.mask_channels != channels)) { + throw std::runtime_error( + "MNN detection layout is ambiguous across prototype outputs"); + } + matched = true; + candidate.has_objectness = objectness; + candidate.mask_channels = channels; + } + return !matched; + }), + detection_candidates.end()); + if (!detection_candidates.empty()) { + std::sort(detection_candidates.begin(), detection_candidates.end(), + [](const DetectionCandidate& a, const DetectionCandidate& b) { + if ((a.mask_channels > 0) != (b.mask_channels > 0)) + return a.mask_channels > 0; + if (a.distance != b.distance) return a.distance < b.distance; + if (a.elements != b.elements) return a.elements > b.elements; + return a.name < b.name; + }); + const DetectionCandidate& best = detection_candidates.front(); + size_t ties = 0; + for (const DetectionCandidate& candidate : detection_candidates) { + if ((candidate.mask_channels > 0) != (best.mask_channels > 0) || + candidate.distance != best.distance || candidate.elements != best.elements) break; + ++ties; + } + if (ties > 1) { + std::ostringstream msg; + msg << "MNN detection output is ambiguous; equally plausible rank-3 tensors: "; + for (size_t i = 0; i < ties; ++i) { + if (i) msg << ", "; + const auto& candidate = detection_candidates[i]; + msg << "'" << (candidate.name.empty() ? "" : candidate.name) + << "' [features=" << candidate.features + << ", anchors=" << candidate.anchors << "]"; + } + msg << "; provide a model with one detection head"; + throw std::runtime_error(msg.str()); + } + detT = best.tensor; + det_features = best.features; + det_anchors = best.anchors; + } + if (!detT) { + // Some MNN graphs expose only the default output through + // getSessionOutputAll(); validate it before using it as a fallback. + detT = output_; + if (!detT || !detection_shape(detT->shape(), expected_features, det_features, det_anchors)) + throw std::runtime_error("MNN model has no compatible rank-3 detection output"); + } + bool has_objectness = false; + int mask_channels = 0; + if (!detection_candidates.empty()) { + has_objectness = detection_candidates.front().has_objectness; + mask_channels = detection_candidates.front().mask_channels; + } else { + const int fallback_features = det_features; + if (fallback_features == expected_features + 1) { + has_objectness = true; + } else if (fallback_features != expected_features) { + bool matched = false; + for (const ProtoCandidate& candidate : proto_candidates) { + const int channels = candidate.tensor->shape()[1]; + if (fallback_features == expected_features + channels || + fallback_features == expected_features + 1 + channels) { + if (matched) + throw std::runtime_error("MNN default detection output layout is ambiguous"); + matched = true; + has_objectness = fallback_features == expected_features + 1 + channels; + mask_channels = channels; + } + } + if (!matched) + throw std::runtime_error("MNN default output feature count is incompatible with the class/prototype layout"); + } } - if (!detT) detT = output_; // detection-only safety MNN::Tensor detHost(detT, MNN::Tensor::CAFFE); - detT->copyToHostTensor(&detHost); + require_float32(detT, "detection output"); + checked_mnn_call("copyToHostTensor", [&] { + return detT->copyToHostTensor(&detHost); + }); const auto os = detHost.shape(); + if (!detection_shape(os, expected_features, det_features, det_anchors)) + throw std::runtime_error("MNN detection output must have shape [1,features,anchors], got " + shape_string(os)); + const size_t det_count = checked_elements(os, "detection"); const float* raw = detHost.host(); - int feat_dim = 0, num_anchors = 0; + if (!raw) throw std::runtime_error("MNN detection output has no host data"); + for (size_t i = 0; i < det_count; ++i) { + if (!std::isfinite(raw[i])) throw std::runtime_error("MNN detection output contains NaN or Inf"); + } + int feat_dim = det_features, num_anchors = det_anchors; const float* dec = raw; std::vector buf; - if (os.size() == 3) { - if (os[1] <= os[2]) { feat_dim = os[1]; num_anchors = os[2]; } // channel-major (expected) - else { // [1,anchors,feat] -> transpose - feat_dim = os[2]; num_anchors = os[1]; - buf.resize(static_cast(feat_dim) * num_anchors); - for (int a = 0; a < num_anchors; ++a) - for (int f = 0; f < feat_dim; ++f) - buf[static_cast(f) * num_anchors + a] = raw[static_cast(a) * feat_dim + f]; - dec = buf.data(); - } + if (os[1] > os[2]) { // [1,anchors,feat] -> transpose + buf.resize(static_cast(feat_dim) * num_anchors); + for (int a = 0; a < num_anchors; ++a) + for (int f = 0; f < feat_dim; ++f) + buf[static_cast(f) * num_anchors + a] = + raw[static_cast(a) * feat_dim + f]; + dec = buf.data(); } - candidates = decode_candidates(dec, feat_dim, num_anchors, cfg, lb); + candidates = decode_candidates(dec, feat_dim, num_anchors, cfg, lb, has_objectness); cand_orig_w = lb.orig_w; cand_orig_h = lb.orig_h; cand_lb = lb; proto.clear(); proto_c = proto_h = proto_w = 0; + if (mask_channels > 0) { + for (const ProtoCandidate& candidate : proto_candidates) { + const auto ps = candidate.tensor->shape(); + if (ps[1] == mask_channels) { + if (protoT) + throw std::runtime_error("MNN model has multiple prototype outputs matching the detection head"); + protoT = candidate.tensor; + } + } + if (!protoT) + throw std::runtime_error("MNN detection head declares mask coefficients but no compatible prototype output exists"); + } if (protoT) { // segmentation proto MNN::Tensor protoHost(protoT, MNN::Tensor::CAFFE); - protoT->copyToHostTensor(&protoHost); + require_float32(protoT, "prototype output"); + checked_mnn_call("copyToHostTensor", [&] { + return protoT->copyToHostTensor(&protoHost); + }); const auto ps = protoHost.shape(); // {1, nm, mh, mw} + if (ps.size() != 4 || ps[0] != 1 || ps[1] <= 0 || ps[2] <= 0 || ps[3] <= 0) + throw std::runtime_error("MNN prototype output has an invalid shape: " + shape_string(ps)); + const size_t proto_count = checked_elements(ps, "prototype"); proto_c = (int)ps[1]; proto_h = (int)ps[2]; proto_w = (int)ps[3]; const float* pp = protoHost.host(); + if (!pp) throw std::runtime_error("MNN prototype output has no host data"); + for (size_t i = 0; i < proto_count; ++i) + if (!std::isfinite(pp[i])) throw std::runtime_error("MNN prototype output contains NaN or Inf"); proto.assign(pp, pp + (size_t)proto_c * proto_h * proto_w); } auto dets = nms_and_cap(candidates, cfg, lb.orig_w, lb.orig_h); diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/ncnn_backend.cpp b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/ncnn_backend.cpp index 2ca3a27b1..6ece37df9 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/ncnn_backend.cpp +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/ncnn_backend.cpp @@ -1,6 +1,11 @@ #include "ncnn_backend.hpp" +#include #include #include +#include +#include +#include +#include #include #include @@ -11,9 +16,108 @@ static double ms_since(const clk::time_point& t) { return std::chrono::duration(clk::now() - t).count(); } +namespace { + +// NCNN does not expose the graph's blob table through the small C++ API used +// by this example. Parse only the stable layer header portion of the param +// file so that arbitrary pnnx blob names can be validated without relying on +// the historical in0/out0/out1 convention. Parameters after the top-blob +// list are intentionally ignored. +struct GraphBlobs { + std::set all; + std::set produced; + std::vector inputs; + std::vector tops; + std::set bottoms; + bool parsed = false; +}; + +static GraphBlobs inspect_param_graph(const std::string& path) { + GraphBlobs graph; + std::ifstream file(std::filesystem::u8path(path)); + if (!file) return graph; + + std::string line; + bool header_seen = false; + while (std::getline(file, line)) { + // NCNN param files are ASCII. Keep parsing tolerant of CRLF and + // comments so files produced on Windows have the same semantics. + const auto first = line.find_first_not_of(" \t\r\n"); + if (first == std::string::npos || line[first] == '#') continue; + line = line.substr(first); + if (!header_seen) { + // The first non-comment line is the magic/version and layer count + // (for example ``7767517`` or ``7767518 235 237``). + header_seen = true; + continue; + } + std::istringstream row(line); + std::string type, layer_name; + int bottom_count = 0, top_count = 0; + if (!(row >> type >> layer_name >> bottom_count >> top_count)) continue; + (void)layer_name; // layer names are not needed for endpoint resolution + if (bottom_count < 0 || top_count <= 0 || bottom_count > 100000 || top_count > 100000) + continue; + std::vector bottoms(static_cast(bottom_count)); + std::vector tops(static_cast(top_count)); + bool complete = true; + for (auto& name : bottoms) { + if (!(row >> name) || name.empty()) { complete = false; break; } + } + if (!complete) continue; + for (auto& name : tops) { + if (!(row >> name) || name.empty()) { complete = false; break; } + } + if (!complete) continue; + for (const auto& name : bottoms) { + graph.all.insert(name); + graph.bottoms.insert(name); + } + for (const auto& name : tops) { + graph.all.insert(name); + graph.produced.insert(name); + graph.tops.push_back(name); + } + if (type == "Input") { + graph.inputs.insert(graph.inputs.end(), tops.begin(), tops.end()); + } + } + // Preserve first appearance while removing duplicate tops. A terminal + // blob is one which is never consumed as a later layer bottom. + std::set seen; + std::vector unique_tops; + for (const auto& name : graph.tops) { + if (seen.insert(name).second && !graph.bottoms.count(name)) unique_tops.push_back(name); + } + graph.tops.swap(unique_tops); + if (graph.inputs.empty()) { + // A few hand-written NCNN graphs omit an explicit Input layer. Their + // external inputs are bottom blobs that are never produced by another + // layer; infer this only when the set is unambiguous. + std::set external; + for (const auto& name : graph.bottoms) { + if (!graph.produced.count(name)) external.insert(name); + } + graph.inputs.assign(external.begin(), external.end()); + } + graph.parsed = header_seen && !graph.all.empty(); + return graph; +} + +static bool contains(const std::set& values, const std::string& value) { + return !value.empty() && values.find(value) != values.end(); +} + +static bool contains(const std::vector& values, const std::string& value) { + return std::find(values.begin(), values.end(), value) != values.end(); +} + +} // namespace + NcnnBackend::NcnnBackend(const std::string& param_path, const std::string& bin_path, int threads, bool use_vulkan) : threads_(threads) { + if (threads <= 0) throw std::invalid_argument("ncnn: thread count must be positive"); net_.opt.num_threads = threads; net_.opt.use_vulkan_compute = use_vulkan; // GPU path (the -shared prebuilt is Vulkan-enabled) if (use_vulkan) { // fp16 on the GPU: big speedup, negligible accuracy loss @@ -28,9 +132,113 @@ NcnnBackend::NcnnBackend(const std::string& param_path, const std::string& bin_p throw std::runtime_error("ncnn: failed to load bin " + bin_path); // auto-read ultralytics metadata sidecar (class names + imgsz) - const std::string dir = std::filesystem::path(param_path).parent_path().string(); + const std::filesystem::path param_fs = std::filesystem::u8path(param_path); + const std::filesystem::path metadata_fs = + (param_fs.parent_path().empty() ? std::filesystem::path(".") : param_fs.parent_path()) / + "metadata.yaml"; std::vector nm; int mi = 0; - if (meta::read_ncnn_yaml(dir + "/metadata.yaml", nm, mi)) { meta_names = nm; meta_imgsz = mi; } + std::string metadata_input, metadata_output, metadata_proto; + // A per-model sidecar avoids collisions when a release directory contains + // more than one NCNN graph; retain the historical shared metadata.yaml as + // a fallback for existing exports. + std::filesystem::path per_model_metadata = param_fs; + per_model_metadata.replace_extension(".metadata.yaml"); + const std::vector metadata_paths = { + per_model_metadata, metadata_fs + }; + bool has_metadata = false; + for (const auto& metadata_path : metadata_paths) { + if (meta::read_ncnn_yaml(metadata_path.u8string(), nm, mi, + &metadata_input, &metadata_output, &metadata_proto)) { + has_metadata = true; + break; + } + } + if (has_metadata) { + if (!metadata_input.empty() && metadata_input == metadata_output) { + throw std::runtime_error("ncnn metadata input_blob and output_blob must differ"); + } + if (!metadata_proto.empty() && + (metadata_proto == metadata_input || metadata_proto == metadata_output)) { + throw std::runtime_error("ncnn metadata proto_blob must be distinct from input/output blobs"); + } + meta_names = nm; meta_imgsz = mi; + if (!metadata_input.empty()) in_blob_ = metadata_input; + if (!metadata_output.empty()) out_blob_ = metadata_output; + if (!metadata_proto.empty()) { + out_proto_ = metadata_proto; + proto_required_ = true; + } + } + + const GraphBlobs graph = inspect_param_graph(param_path); + if (graph.parsed) { + // Explicit sidecar names are part of the model ABI. Reject a stale + // or hand-edited sidecar early instead of running the wrong tensor. + if (!metadata_input.empty() && !contains(graph.all, metadata_input)) { + throw std::runtime_error("ncnn metadata input_blob '" + metadata_input + + "' is not present in " + param_path); + } + if (!metadata_input.empty() && !graph.inputs.empty() && + !contains(graph.inputs, metadata_input)) { + throw std::runtime_error("ncnn metadata input_blob '" + metadata_input + + "' is not an Input blob in " + param_path); + } + if (!metadata_output.empty() && !contains(graph.all, metadata_output)) { + throw std::runtime_error("ncnn metadata output_blob '" + metadata_output + + "' is not present in " + param_path); + } + if (!metadata_output.empty() && !graph.tops.empty() && + !contains(graph.tops, metadata_output)) { + throw std::runtime_error("ncnn metadata output_blob '" + metadata_output + + "' is not a terminal blob in " + param_path); + } + if (!metadata_proto.empty() && !contains(graph.all, metadata_proto)) { + throw std::runtime_error("ncnn metadata proto_blob '" + metadata_proto + + "' is not present in " + param_path); + } + if (!metadata_proto.empty() && !graph.tops.empty() && + !contains(graph.tops, metadata_proto)) { + throw std::runtime_error("ncnn metadata proto_blob '" + metadata_proto + + "' is not a terminal blob in " + param_path); + } + + // When no sidecar is available, resolve the graph's actual endpoints. + // A unique endpoint is safe to infer; multiple endpoints require the + // sidecar because their tensor roles cannot be determined from names. + if (metadata_input.empty()) { + if (contains(graph.inputs, "in0")) { + in_blob_ = "in0"; + } else if (graph.inputs.size() == 1) { + in_blob_ = graph.inputs.front(); + } else if (graph.inputs.empty() && contains(graph.all, "in0")) { + in_blob_ = "in0"; + } else if (graph.inputs.size() > 1) { + throw std::runtime_error("ncnn graph has multiple input blobs; provide metadata.yaml input_blob"); + } + } + if (metadata_output.empty()) { + if (contains(graph.tops, "out0")) { + out_blob_ = "out0"; + } else if (graph.tops.size() == 1) { + out_blob_ = graph.tops.front(); + } else if (graph.tops.empty() && contains(graph.all, "out0")) { + out_blob_ = "out0"; + } else if (graph.tops.size() > 1) { + throw std::runtime_error("ncnn graph has multiple terminal blobs; provide metadata.yaml output_blob"); + } + } + if (metadata_proto.empty()) { + // The conventional out1 is an optional segmentation prototype. + // Do not probe arbitrary terminal tensors: a detection graph with + // several auxiliary outputs must declare their roles explicitly. + if (contains(graph.tops, "out1") && out_blob_ != "out1") out_proto_ = "out1"; + else if (graph.tops.size() <= 1) out_proto_.clear(); + else out_proto_.clear(); + } + } else if (!has_metadata) { + ep_note = "NCNN param graph metadata unavailable; using legacy in0/out0 blob names"; + } // YOLO-Master ncnn graphs bake the attention token counts at the training size, // so the input size is effectively fixed. fixed_imgsz = meta_imgsz; @@ -51,18 +259,70 @@ std::vector NcnnBackend::infer(const cv::Mat& bgr, const Config& cfg) // ---- inference ---- auto t1 = clk::now(); ncnn::Extractor ex = net_.create_extractor(); // uses net_.opt.num_threads set in ctor - ex.input(in_blob_.c_str(), in); + if (ex.input(in_blob_.c_str(), in) != 0) + throw std::runtime_error("ncnn: failed to set input blob '" + in_blob_ + "'"); ncnn::Mat out, pm; - ex.extract(out_blob_.c_str(), out); - ex.extract(out_proto_.c_str(), pm); // proto (empty on detection models) + if (ex.extract(out_blob_.c_str(), out) != 0 || out.empty()) + throw std::runtime_error("ncnn: failed to extract detection blob '" + out_blob_ + "'"); + // A detection graph has no proto output. The conventional out1 fallback + // is optional, but a proto explicitly declared by metadata is part of the + // segmentation ABI and must be present; otherwise silently dropping masks + // would make a cross-backend comparison invalid. + if (!out_proto_.empty()) { + const int proto_status = ex.extract(out_proto_.c_str(), pm); + if (proto_required_ && (proto_status != 0 || pm.empty())) { + throw std::runtime_error("ncnn: required prototype blob '" + out_proto_ + + "' could not be extracted"); + } + } infer_ms = ms_since(t1); // ---- reshape to channel-major [feat_dim x num_anchors] then decode ---- - // feat << anchors always (e.g. 14/116 vs 8400), so the smaller axis is the feature dim. + // Prefer the orientation whose feature axis agrees with the model class + // count (and, for segmentation, the prototype channel count). The old + // ``smaller axis = features`` heuristic is only a final tie-breaker; it + // mis-decoded valid low-anchor or transposed exports. auto t2 = clk::now(); int feat_dim, num_anchors; + // ncnn represents a leading singleton batch either as a 2-D Mat or as a + // 3-D Mat with c=1, depending on the exporter/version. Both layouts are + // equivalent for the shared decoder; reject true multi-channel tensors + // instead of flattening them with an ambiguous stride. + if ((out.dims != 2 && out.dims != 3) || out.w <= 0 || out.h <= 0 || out.c != 1) + throw std::runtime_error("ncnn: detection blob must be a non-empty 2-D/3-D float matrix with singleton batch"); + if (out.elemsize != sizeof(float)) + throw std::runtime_error("ncnn: detection blob must use float32 elements"); + const int expected_features = 4 + cfg.num_classes(); + int expected_masks = 0; + if (!pm.empty()) { + if (pm.dims != 3 || pm.c <= 0 || pm.h <= 0 || pm.w <= 0 || + pm.elemsize != sizeof(float)) + throw std::runtime_error("ncnn: prototype blob must be a non-empty float32 3-D tensor"); + expected_masks = pm.c; + } + const auto feature_count_matches = [&](int value) { + if (value == expected_features || value == expected_features + 1) return true; + return expected_masks > 0 && + (value == expected_features + expected_masks || + value == expected_features + 1 + expected_masks); + }; + const bool rows_are_features = feature_count_matches(out.h); + const bool cols_are_features = feature_count_matches(out.w); + bool feature_rows; + if (rows_are_features != cols_are_features) { + feature_rows = rows_are_features; + } else if (rows_are_features) { + // Both axes can only match for a deliberately tiny synthetic graph; + // retain deterministic compatibility with legacy exports. + feature_rows = out.h <= out.w; + } else { + throw std::runtime_error( + "ncnn: detection blob shape is incompatible with class count " + + std::to_string(cfg.num_classes()) + " (expected feature axis " + + std::to_string(expected_features) + ")"); + } std::vector buf; - if (out.h <= out.w) { // rows = features (expected, channel-major) + if (feature_rows) { // rows = features (channel-major) feat_dim = out.h; num_anchors = out.w; buf.resize(static_cast(feat_dim) * num_anchors); for (int f = 0; f < feat_dim; ++f) @@ -77,7 +337,25 @@ std::vector NcnnBackend::infer(const cv::Mat& bgr, const Config& cfg) buf[static_cast(f) * num_anchors + a] = r[f]; } } - candidates = decode_candidates(buf.data(), feat_dim, num_anchors, cfg, lb); + if (!std::all_of(buf.begin(), buf.end(), [](float v) { return std::isfinite(v); })) + throw std::runtime_error("ncnn: detection blob contains NaN or Inf"); + bool has_objectness = false; + if (!pm.empty()) { + if (feat_dim == expected_features + expected_masks) { + has_objectness = false; + } else if (feat_dim == expected_features + 1 + expected_masks) { + has_objectness = true; + } else { + throw std::runtime_error("ncnn: detection/prototype feature counts are incompatible"); + } + } else if (feat_dim == expected_features + 1) { + has_objectness = true; + } else if (feat_dim != expected_features) { + throw std::runtime_error( + "ncnn: detection blob has extra feature channels but no compatible prototype blob"); + } + candidates = decode_candidates(buf.data(), feat_dim, num_anchors, cfg, lb, + has_objectness); cand_orig_w = lb.orig_w; cand_orig_h = lb.orig_h; cand_lb = lb; proto.clear(); proto_c = proto_h = proto_w = 0; if (!pm.empty()) { // segmentation proto [c=nm, h=mh, w=mw] @@ -86,6 +364,8 @@ std::vector NcnnBackend::infer(const cv::Mat& bgr, const Config& cfg) proto.resize(static_cast(proto_c) * plane); for (int c = 0; c < proto_c; ++c) std::memcpy(proto.data() + c * plane, pm.channel(c), plane * sizeof(float)); + if (!std::all_of(proto.begin(), proto.end(), [](float v) { return std::isfinite(v); })) + throw std::runtime_error("ncnn: prototype blob contains NaN or Inf"); } auto dets = nms_and_cap(candidates, cfg, lb.orig_w, lb.orig_h); post_ms = ms_since(t2); diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/ort_backend.cpp b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/ort_backend.cpp index 255cb7c7f..0d2e32186 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/ort_backend.cpp +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/ort_backend.cpp @@ -1,8 +1,24 @@ #include "ort_backend.hpp" +#include +#include #include +#include +#include #include +#include #include +#include +#include +#include #include +#include + +#ifdef _WIN32 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#endif namespace yolomaster { @@ -13,11 +29,116 @@ static double ms_since(const clk::time_point& t) { // ORT takes the model path as wchar_t* on Windows, char* elsewhere (ORTCHAR_T). #ifdef _WIN32 -static std::wstring ort_path(const std::string& s) { return std::wstring(s.begin(), s.end()); } +static std::wstring ort_path(const std::string& s) { + if (s.empty()) return {}; + const int needed = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + s.data(), static_cast(s.size()), + nullptr, 0); + if (needed <= 0) + throw std::runtime_error("model path is not valid UTF-8"); + std::wstring wide(static_cast(needed), L'\0'); + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + s.data(), static_cast(s.size()), + wide.data(), needed) != needed) + throw std::runtime_error("failed to convert model path to UTF-16"); + return wide; +} #else static const std::string& ort_path(const std::string& s) { return s; } #endif +// ORT exposes FP16 tensors as 16-bit storage. Keep the conversion local so +// the runtime accepts both FP32 and ``--half`` exports without depending on a +// particular Ort::Float16_t constructor (which changed between ORT releases). +static float half_to_float(uint16_t bits) { + const uint32_t sign = (bits & 0x8000u) << 16; + const uint32_t exp = (bits >> 10) & 0x1fu; + const uint32_t frac = bits & 0x03ffu; + uint32_t value; + if (exp == 0) { + if (frac == 0) value = sign; + else { + uint32_t mant = frac; + uint32_t e = 0; + while ((mant & 0x0400u) == 0) { mant <<= 1; ++e; } + mant &= 0x03ffu; + // Half subnormals have an implicit exponent of -14 before the + // leading-bit normalization (not -15). Using 127-15 here + // underestimates every non-zero subnormal by a factor of two. + const int exponent = 127 - 14 - static_cast(e); + value = sign | (static_cast(exponent) << 23) | (mant << 13); + } + } else if (exp == 31) { + value = sign | 0x7f800000u | (frac << 13); + } else { + value = sign | ((exp + (127u - 15u)) << 23) | (frac << 13); + } + float result; + std::memcpy(&result, &value, sizeof(result)); + return result; +} + +static uint16_t float_to_half(float value) { + uint32_t bits; + std::memcpy(&bits, &value, sizeof(bits)); + const uint32_t sign = (bits >> 16) & 0x8000u; + const uint32_t exponent_bits = (bits >> 23) & 0xffu; + uint32_t fraction = bits & 0x7fffffu; + + if (exponent_bits == 0xffu) { + // Preserve infinities and emit a quiet, non-zero payload for NaNs. + return static_cast( + sign | 0x7c00u | (fraction ? (0x0200u | (fraction >> 13)) : 0u)); + } + + int exponent = static_cast(exponent_bits) - 127; + if (exponent > 15) return static_cast(sign | 0x7c00u); + if (exponent >= -14) { + // Round the 23-bit float mantissa to ten bits using round-to-nearest, + // ties-to-even. Carrying out of the mantissa increments the exponent. + fraction += 0x0fffu + ((fraction >> 13) & 1u); + if (fraction & 0x800000u) { + fraction = 0; + if (++exponent > 15) return static_cast(sign | 0x7c00u); + } + return static_cast( + sign | (static_cast(exponent + 15) << 10) | + (fraction >> 13)); + } + // Values at exponent -25 can round to the smallest half subnormal + // (2^-24); only smaller exponents are guaranteed to round to zero. + if (exponent < -25) return static_cast(sign); + + // Half subnormal: restore float's implicit leading bit, shift to the + // half-subnormal exponent, then apply the same ties-to-even rule. + const uint32_t mantissa = fraction | 0x800000u; + const int shift = -exponent - 1; // 14 bits at exp=-14, 24 at exp=-24 + uint32_t rounded = mantissa >> shift; + const uint32_t remainder_mask = (1u << shift) - 1u; + const uint32_t remainder = mantissa & remainder_mask; + const uint32_t halfway = 1u << (shift - 1); + if (remainder > halfway || (remainder == halfway && (rounded & 1u))) ++rounded; + return static_cast(sign | rounded); +} + +static std::vector tensor_to_float(const Ort::Value& value) { + const auto info = value.GetTensorTypeAndShapeInfo(); + const size_t count = info.GetElementCount(); + std::vector output(count); + if (info.GetElementType() == ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT) { + const float* data = value.GetTensorData(); + std::copy(data, data + count, output.begin()); + } else if (info.GetElementType() == ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT16) { + const uint16_t* data = value.GetTensorData(); + for (size_t i = 0; i < count; ++i) output[i] = half_to_float(data[i]); + } else { + throw std::runtime_error("ONNX tensor must use FP32 or FP16 elements"); + } + if (!std::all_of(output.begin(), output.end(), [](float v) { return std::isfinite(v); })) + throw std::runtime_error("ONNX tensor contains NaN or Inf"); + return output; +} + OrtBackend::OrtBackend(const std::string& model_path, int threads, const std::string& device) : env_(ORT_LOGGING_LEVEL_WARNING, "yolomaster") { opts_.SetIntraOpNumThreads(threads); @@ -27,18 +148,26 @@ OrtBackend::OrtBackend(const std::string& model_path, int threads, const std::st // ONNXRuntime TensorRT EP: builds+caches a TRT engine internally (near-native TRT), // honors QDQ nodes for INT8 + FP16 elsewhere, and auto-falls-back to CUDA/CPU for // unsupported subgraphs. Portable: ship the .onnx; the engine cache builds on first run. + OrtTensorRTProviderOptionsV2* trt = nullptr; try { - OrtTensorRTProviderOptionsV2* trt = nullptr; Ort::ThrowOnError(Ort::GetApi().CreateTensorRTProviderOptions(&trt)); const char* keys[] = {"trt_fp16_enable", "trt_int8_enable", "trt_engine_cache_enable", "trt_engine_cache_path"}; - const char* vals[] = {"1", "1", "1", "trt_engine_cache"}; + // Do not force INT8 for an arbitrary ONNX model. TensorRT INT8 + // requires a calibrated/QDQ graph; enabling it unconditionally can + // change the accuracy protocol or make engine construction fail. + // Q/DQ nodes in an explicitly quantized model are still honored by + // TensorRT when this option is disabled. + const char* vals[] = {"1", "0", "1", "trt_engine_cache"}; Ort::ThrowOnError(Ort::GetApi().UpdateTensorRTProviderOptions(trt, keys, vals, 4)); opts_.AppendExecutionProvider_TensorRT_V2(*trt); Ort::GetApi().ReleaseTensorRTProviderOptions(trt); + trt = nullptr; active_ep = "TensorRT-EP"; } catch (const std::exception& e) { + if (trt) Ort::GetApi().ReleaseTensorRTProviderOptions(trt); std::cerr << "[ort] TensorRT EP unavailable (" << e.what() << "); trying CUDA\n"; + ep_note = std::string("TensorRT EP unavailable: ") + e.what(); } // CUDA fallback for TRT-unsupported nodes (and if the TRT EP failed to load) try { @@ -47,6 +176,8 @@ OrtBackend::OrtBackend(const std::string& model_path, int threads, const std::st if (active_ep != "TensorRT-EP") active_ep = "CUDA"; } catch (const std::exception& e) { if (active_ep != "TensorRT-EP") { std::cerr << "[ort] CUDA EP unavailable; using CPU\n"; active_ep = "CPU"; } + if (active_ep == "CPU" && ep_note.empty()) + ep_note = std::string("CUDA EP unavailable: ") + e.what(); } } else if (device == "cuda") { try { // graceful fallback if CUDA EP can't load @@ -74,22 +205,81 @@ OrtBackend::OrtBackend(const std::string& model_path, int threads, const std::st } catch (const std::exception& e) { std::cerr << "[ort] CoreML EP unavailable (" << e.what() << "); using CPU\n"; active_ep = "CPU"; + ep_note = std::string("CoreML EP unavailable: ") + e.what(); } } - session_ = std::make_unique(env_, ort_path(model_path).c_str(), opts_); + // Provider registration can succeed even when provider initialization is + // deferred until the session is constructed (for example, a CUDA/TensorRT + // library may be missing at runtime). Retry with a clean CPU-only option + // set so the documented accelerator fallback also covers that case. + auto create_session = [&](Ort::SessionOptions& options) { +#ifdef _WIN32 + const std::wstring wide = ort_path(model_path); + return std::make_unique(env_, wide.c_str(), options); +#else + return std::make_unique(env_, model_path.c_str(), options); +#endif + }; + try { + session_ = create_session(opts_); + } catch (const std::exception& first_error) { + if (device == "cpu" || device.empty()) throw; + const std::string requested_ep = active_ep; + Ort::SessionOptions cpu_opts; + cpu_opts.SetIntraOpNumThreads(threads); + cpu_opts.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); + try { + session_ = create_session(cpu_opts); + } catch (const std::exception& cpu_error) { + throw std::runtime_error( + std::string("ONNX session initialization failed for ") + requested_ep + + ": " + first_error.what() + "; CPU fallback failed: " + cpu_error.what()); + } + active_ep = "CPU"; + ep_note = requested_ep + " unavailable; fell back to CPU: " + first_error.what(); + std::cerr << "[ort] " << ep_note << "\n"; + } const size_t n_in = session_->GetInputCount(); const size_t n_out = session_->GetOutputCount(); + if (n_in != 1) + throw std::runtime_error("ONNX runner requires exactly one tensor input (found " + + std::to_string(n_in) + ")"); + if (n_out == 0) + throw std::runtime_error("ONNX model must expose at least one output"); for (size_t i = 0; i < n_in; ++i) in_names_s_.push_back(session_->GetInputNameAllocated(i, alloc_).get()); for (size_t i = 0; i < n_out; ++i) out_names_s_.push_back(session_->GetOutputNameAllocated(i, alloc_).get()); for (auto& s : in_names_s_) in_names_.push_back(s.c_str()); for (auto& s : out_names_s_) out_names_.push_back(s.c_str()); + if (in_names_.empty() || out_names_.empty()) + throw std::runtime_error("ONNX model must expose at least one input and one output"); + + const auto input_info = session_->GetInputTypeInfo(0).GetTensorTypeAndShapeInfo(); + const auto input_shape = input_info.GetShape(); + if (input_shape.size() != 4) + throw std::runtime_error("ONNX input must have rank-4 shape [1,3,H,W]"); + if (input_shape[0] > 0 && input_shape[0] != 1) + throw std::runtime_error("ONNX input batch dimension must be 1"); + if (input_shape[1] > 0 && input_shape[1] != 3) + throw std::runtime_error("ONNX input channel dimension must be 3"); + for (size_t axis = 2; axis < 4; ++axis) { + if (input_shape[axis] == 0 || input_shape[axis] < -1) + throw std::runtime_error("ONNX input has an invalid spatial dimension"); + } + if (input_shape[2] > 0 && input_shape[3] > 0 && input_shape[2] != input_shape[3]) + throw std::runtime_error("ONNX runner requires a square input (H must equal W)"); + const auto input_type = input_info.GetElementType(); + if (input_type == ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT16) { + input_fp16_ = true; + } else if (input_type != ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT) { + throw std::runtime_error("ONNX input must use FP32 or FP16 elements"); + } // detect a static input size (H==W>0) -> hard constraint { - auto shape = session_->GetInputTypeInfo(0).GetTensorTypeAndShapeInfo().GetShape(); + auto shape = input_shape; if (shape.size() == 4 && shape[2] > 0 && shape[2] == shape[3]) { fixed_imgsz = static_cast(shape[2]); meta_imgsz = fixed_imgsz; // authoritative over the metadata string @@ -103,23 +293,47 @@ OrtBackend::OrtBackend(const std::string& model_path, int threads, const std::st if (auto v = md.LookupCustomMetadataMapAllocated("imgsz", alloc_)) { const std::string s = v.get(); const size_t p = s.find_first_of("0123456789"); - if (p != std::string::npos) meta_imgsz = std::atoi(s.c_str() + p); + if (p != std::string::npos) { + const int metadata_imgsz = std::atoi(s.c_str() + p); + if (metadata_imgsz > 0 && fixed_imgsz == 0) { + meta_imgsz = metadata_imgsz; + } else if (metadata_imgsz > 0 && metadata_imgsz != fixed_imgsz) { + std::cerr << "[ort] metadata imgsz=" << metadata_imgsz + << " differs from static input=" << fixed_imgsz + << "; using the static input shape\n"; + } + } } } std::vector OrtBackend::infer(const cv::Mat& bgr, const Config& cfg) { // ---- preprocess: letterbox -> NCHW float RGB /255 ---- + if (cfg.imgsz <= 0) + throw std::runtime_error("ONNX inference requires a positive image size"); auto t0 = clk::now(); + const auto runtime_input_shape = session_->GetInputTypeInfo(0).GetTensorTypeAndShapeInfo().GetShape(); + if (runtime_input_shape.size() != 4 || + (runtime_input_shape[0] > 0 && runtime_input_shape[0] != 1) || + (runtime_input_shape[1] > 0 && runtime_input_shape[1] != 3)) { + throw std::runtime_error("ONNX input shape changed to an unsupported layout; expected [1,3,H,W]"); + } + if (runtime_input_shape[2] > 0 && runtime_input_shape[2] != cfg.imgsz) + throw std::runtime_error("ONNX input height is fixed at " + std::to_string(runtime_input_shape[2]) + + "; requested imgsz=" + std::to_string(cfg.imgsz)); + if (runtime_input_shape[3] > 0 && runtime_input_shape[3] != cfg.imgsz) + throw std::runtime_error("ONNX input width is fixed at " + std::to_string(runtime_input_shape[3]) + + "; requested imgsz=" + std::to_string(cfg.imgsz)); LetterboxInfo lb; cv::Mat padded = preprocess(bgr, cfg.imgsz, cfg.stretch, lb); // imgsz x imgsz, CV_8UC3 BGR // NCHW float RGB /255 (replaces cv::dnn::blobFromImage with swapRB=true) - const int sz = cfg.imgsz, hw = sz * sz; + const int sz = cfg.imgsz; + const size_t hw = static_cast(sz) * static_cast(sz); std::vector blob(3 * hw); for (int y = 0; y < sz; ++y) { const uint8_t* row = padded.ptr(y); for (int x = 0; x < sz; ++x) { const uint8_t* px = row + x * 3; // BGR - const int idx = y * sz + x; + const size_t idx = static_cast(y) * sz + x; blob[idx] = px[2] * (1.0f / 255); // R blob[hw + idx] = px[1] * (1.0f / 255); // G blob[2 * hw + idx] = px[0] * (1.0f / 255); // B @@ -131,33 +345,187 @@ std::vector OrtBackend::infer(const cv::Mat& bgr, const Config& cfg) auto t1 = clk::now(); std::array in_shape{1, 3, cfg.imgsz, cfg.imgsz}; Ort::MemoryInfo mem = Ort::MemoryInfo::CreateCpu(OrtDeviceAllocator, OrtMemTypeCPU); - Ort::Value in_tensor = Ort::Value::CreateTensor( - mem, blob.data(), blob.size(), - in_shape.data(), in_shape.size()); + std::vector blob16; + Ort::Value in_tensor{nullptr}; + if (input_fp16_) { + blob16.resize(blob.size()); + for (size_t i = 0; i < blob.size(); ++i) blob16[i] = float_to_half(blob[i]); + in_tensor = Ort::Value::CreateTensor( + mem, blob16.data(), blob16.size() * sizeof(uint16_t), + in_shape.data(), in_shape.size(), ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT16); + } else { + in_tensor = Ort::Value::CreateTensor( + mem, blob.data(), blob.size(), in_shape.data(), in_shape.size()); + } auto outs = session_->Run(Ort::RunOptions{nullptr}, in_names_.data(), &in_tensor, 1, out_names_.data(), out_names_.size()); infer_ms = ms_since(t1); // ---- postprocess: detection is the rank-3 output [1,feat,anchors]; proto (seg) is rank-4 ---- auto t2 = clk::now(); - int det_i = 0, proto_i = -1; + int det_i = -1, proto_i = -1; + std::vector proto_candidates; + struct DetectionCandidate { + int index = -1; + int features = 0; + int anchors = 0; + int distance = 0; + size_t elements = 0; + bool has_objectness = false; + int mask_channels = 0; + }; + std::vector detection_candidates; + const int expected_features = std::max(5, 4 + cfg.num_classes()); for (size_t i = 0; i < outs.size(); ++i) { - const size_t r = outs[i].GetTensorTypeAndShapeInfo().GetShape().size(); - if (r == 4) proto_i = static_cast(i); - else if (r == 3) det_i = static_cast(i); - } - auto shape = outs[det_i].GetTensorTypeAndShapeInfo().GetShape(); // {1, feat, anchors} - const int feat_dim = static_cast(shape[1]); - const int num_anchors = static_cast(shape[2]); - const float* out = outs[det_i].GetTensorMutableData(); - candidates = decode_candidates(out, feat_dim, num_anchors, cfg, lb); + auto info = outs[i].GetTensorTypeAndShapeInfo(); + const auto shape_i = info.GetShape(); + const auto type = info.GetElementType(); + if (type != ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT && + type != ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT16) + continue; + if (shape_i.size() == 4) { + if (shape_i[0] != 1 || shape_i[1] <= 0 || shape_i[2] <= 0 || shape_i[3] <= 0) + throw std::runtime_error("ONNX rank-4 output has an invalid shape; expected [1,C,H,W]"); + if (shape_i[1] > std::numeric_limits::max() || + shape_i[2] > std::numeric_limits::max() || + shape_i[3] > std::numeric_limits::max()) + throw std::runtime_error("ONNX rank-4 output dimensions exceed runner limits"); + proto_candidates.push_back(static_cast(i)); + } else if (shape_i.size() == 3 && shape_i[0] == 1 && shape_i[1] > 0 && shape_i[2] > 0) { + if (shape_i[1] > std::numeric_limits::max() || + shape_i[2] > std::numeric_limits::max()) + throw std::runtime_error("ONNX detection output dimensions exceed runner limits"); + const int dim_a = static_cast(shape_i[1]); + const int dim_b = static_cast(shape_i[2]); + const int feat = std::min(dim_a, dim_b); + const int anchors = std::max(dim_a, dim_b); + if (feat < expected_features || anchors < feat) continue; + const size_t elements = static_cast(dim_a) * static_cast(dim_b); + detection_candidates.push_back({static_cast(i), feat, anchors, + std::abs(feat - expected_features), elements, + false, 0}); + } + } + // A rank-3 tensor is a detection head only when its feature count matches + // a detector layout, or when its extra channels match a rank-4 prototype. + // Merely requiring features >= 4+nc can select an exported intermediate + // feature map and yield plausible but invalid detections. + detection_candidates.erase( + std::remove_if(detection_candidates.begin(), detection_candidates.end(), + [&](DetectionCandidate& candidate) { + if (candidate.features == expected_features) return false; + if (candidate.features == expected_features + 1) { + candidate.has_objectness = true; + return false; + } + bool matched = false; + for (const int proto_index : proto_candidates) { + const auto proto_shape = + outs[proto_index].GetTensorTypeAndShapeInfo().GetShape(); + const int channels = static_cast(proto_shape[1]); + bool objectness = false; + if (candidate.features == expected_features + channels) { + objectness = false; + } else if (candidate.features == expected_features + 1 + channels) { + objectness = true; + } else { + continue; + } + if (matched && (candidate.has_objectness != objectness || + candidate.mask_channels != channels)) { + throw std::runtime_error( + "ONNX detection layout is ambiguous across prototype outputs"); + } + matched = true; + candidate.has_objectness = objectness; + candidate.mask_channels = channels; + } + return !matched; + }), + detection_candidates.end()); + if (detection_candidates.empty()) + throw std::runtime_error( + "ONNX model has no FP32 rank-3 detection output (FP16 is also accepted) " + "with a compatible feature dimension"); + std::sort(detection_candidates.begin(), detection_candidates.end(), + [](const DetectionCandidate& a, const DetectionCandidate& b) { + if ((a.mask_channels > 0) != (b.mask_channels > 0)) + return a.mask_channels > 0; + if (a.distance != b.distance) return a.distance < b.distance; + if (a.elements != b.elements) return a.elements > b.elements; + return a.index < b.index; + }); + const DetectionCandidate& best_candidate = detection_candidates.front(); + std::vector tied; + for (const DetectionCandidate& candidate : detection_candidates) { + if ((candidate.mask_channels > 0) != (best_candidate.mask_channels > 0) || + candidate.distance != best_candidate.distance || + candidate.elements != best_candidate.elements) break; + tied.push_back(&candidate); + } + if (tied.size() > 1) { + std::ostringstream msg; + msg << "ONNX detection output is ambiguous; equally plausible rank-3 tensors: "; + for (size_t j = 0; j < tied.size(); ++j) { + if (j) msg << ", "; + const int index = tied[j]->index; + msg << "'" << (index < static_cast(out_names_s_.size()) + ? out_names_s_[index] : std::to_string(index)) + << "' [1," << outs[index].GetTensorTypeAndShapeInfo().GetShape()[1] + << "," << outs[index].GetTensorTypeAndShapeInfo().GetShape()[2] << "]"; + } + msg << "; provide a model with one detection head"; + throw std::runtime_error(msg.str()); + } + det_i = best_candidate.index; + const bool has_objectness = best_candidate.has_objectness; + const int mask_channels = best_candidate.mask_channels; + auto shape = outs[det_i].GetTensorTypeAndShapeInfo().GetShape(); + if (shape.size() != 3 || shape[0] != 1 || shape[1] <= 0 || shape[2] <= 0) + throw std::runtime_error("ONNX detection output must have shape [1, features, anchors]"); + const int first = static_cast(shape[1]); + const int second = static_cast(shape[2]); + const int feat_dim = std::min(first, second); + const int num_anchors = std::max(first, second); + if (feat_dim < expected_features || num_anchors < feat_dim) + throw std::runtime_error("ONNX detection output dimensions are not plausible"); + std::vector out_values = tensor_to_float(outs[det_i]); + const float* out = out_values.data(); + std::vector transposed; + if (first <= second) { + candidates = decode_candidates(out, feat_dim, num_anchors, cfg, lb, has_objectness); + } else { + // Some exporters emit [1, anchors, features]. Normalize that layout + // before entering the shared decoder instead of silently swapping box + // coordinates and class scores. + transposed.resize(static_cast(feat_dim) * num_anchors); + for (int anchor = 0; anchor < num_anchors; ++anchor) + for (int feature = 0; feature < feat_dim; ++feature) + transposed[static_cast(feature) * num_anchors + anchor] = + out[static_cast(anchor) * feat_dim + feature]; + candidates = decode_candidates(transposed.data(), feat_dim, num_anchors, cfg, lb, + has_objectness); + } cand_orig_w = lb.orig_w; cand_orig_h = lb.orig_h; cand_lb = lb; proto.clear(); proto_c = proto_h = proto_w = 0; + // A rank-4 tensor is a segmentation prototype only when its channel count + // agrees with the mask-coefficient tail of the selected detection head. + // This avoids treating an unrelated feature map as a mask tensor. + for (const int candidate : proto_candidates) { + const auto candidate_shape = outs[candidate].GetTensorTypeAndShapeInfo().GetShape(); + if (mask_channels > 0 && candidate_shape[1] == mask_channels) { + if (proto_i >= 0) + throw std::runtime_error( + "ONNX model has multiple prototype outputs matching the detection head"); + proto_i = candidate; + } + } + if (mask_channels > 0 && proto_i < 0) + throw std::runtime_error("ONNX detection head declares mask coefficients but no compatible prototype output exists"); if (proto_i >= 0) { // segmentation model auto ps = outs[proto_i].GetTensorTypeAndShapeInfo().GetShape(); // {1, nm, mh, mw} proto_c = (int)ps[1]; proto_h = (int)ps[2]; proto_w = (int)ps[3]; - const float* pd = outs[proto_i].GetTensorMutableData(); - proto.assign(pd, pd + (size_t)proto_c * proto_h * proto_w); + proto = tensor_to_float(outs[proto_i]); } auto dets = nms_and_cap(candidates, cfg, lb.orig_w, lb.orig_h); post_ms = ms_since(t2); diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/slicing.cpp b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/slicing.cpp index 121b7bf44..e955dafaa 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/slicing.cpp +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/slicing.cpp @@ -16,7 +16,9 @@ SliceOutput sliced_candidates(Backend& be, const cv::Mat& bgr, const Config& cfg Config g = cfg; g.conf_thresh = std::min(cfg.conf_thresh, conf_floor); be.infer(bgr, g); + out.pre_ms += be.pre_ms; out.infer_ms += be.infer_ms; + out.post_ms += be.post_ms; // Snapshot immediately: every subsequent infer() overwrites the backend's cached state. std::vector global_cands = be.candidates; const LetterboxInfo global_lb = be.cand_lb; @@ -101,7 +103,9 @@ SliceOutput sliced_candidates(Backend& be, const cv::Mat& bgr, const Config& cfg cv::Mat canvas(tile, tile, CV_8UC3, cv::Scalar(114, 114, 114)); bgr(cv::Rect(t.x, t.y, t.w, t.h)).copyTo(canvas(cv::Rect(0, 0, t.w, t.h))); be.infer(canvas, tg); + out.pre_ms += be.pre_ms; out.infer_ms += be.infer_ms; + out.post_ms += be.post_ms; out.tiles_run += 1; for (const RawDet& d : be.candidates) { // Clip to real crop content (upstream lets boxes live on the gray padding), @@ -117,8 +121,14 @@ SliceOutput sliced_candidates(Backend& be, const cv::Mat& bgr, const Config& cfg pool.push_back(std::move(r)); } } - std::sort(pool.begin(), pool.end(), - [](const RawDet& a, const RawDet& b) { return a.score > b.score; }); + std::sort(pool.begin(), pool.end(), [](const RawDet& a, const RawDet& b) { + if (a.score != b.score) return a.score > b.score; + if (a.cls != b.cls) return a.cls < b.cls; + if (a.box.x != b.box.x) return a.box.x < b.box.x; + if (a.box.y != b.box.y) return a.box.y < b.box.y; + if (a.box.width != b.box.width) return a.box.width < b.box.width; + return a.box.height < b.box.height; + }); // ---- postcondition: the backend's cached state now describes the SLICED run ---- be.candidates = pool; @@ -131,7 +141,9 @@ SliceOutput sliced_candidates(Backend& be, const cv::Mat& bgr, const Config& cfg be.proto.clear(); be.proto_c = be.proto_h = be.proto_w = 0; } + be.pre_ms = out.pre_ms; be.infer_ms = out.infer_ms; + be.post_ms = out.post_ms; return out; } diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/trt_backend.cpp b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/trt_backend.cpp index b02ff6158..b5eae2e68 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/trt_backend.cpp +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/cpp/src/trt_backend.cpp @@ -1,8 +1,16 @@ #include "trt_backend.hpp" +#include #include +#include +#include +#include #include #include +#include #include +#include +#include +#include #include namespace yolomaster { @@ -22,119 +30,399 @@ static TrtLogger g_logger; #define CUDA_CHECK(x) do { cudaError_t e_ = (x); if (e_ != cudaSuccess) \ throw std::runtime_error(std::string("CUDA error: ") + cudaGetErrorString(e_)); } while (0) +static std::string dims_string(const nvinfer1::Dims& dims) { + std::ostringstream out; + out << "["; + for (int i = 0; i < dims.nbDims; ++i) { + if (i) out << ","; + out << dims.d[i]; + } + out << "]"; + return out.str(); +} + +static size_t checked_elements(std::initializer_list dims, const char* what) { + size_t total = 1; + for (const int dim : dims) { + if (dim <= 0 || total > std::numeric_limits::max() / + static_cast(dim)) + throw std::runtime_error(std::string("TRT ") + what + " dimensions are invalid or too large"); + total *= static_cast(dim); + } + return total; +} + +static size_t checked_bytes(size_t elements, size_t element_size, const char* what) { + if (element_size == 0 || elements > std::numeric_limits::max() / element_size) + throw std::runtime_error(std::string("TRT ") + what + " buffer is too large"); + return elements * element_size; +} + +static bool supported_type(nvinfer1::DataType type, bool& fp16) { + if (type == nvinfer1::DataType::kFLOAT) { + fp16 = false; + return true; + } + if (type == nvinfer1::DataType::kHALF) { + fp16 = true; + return true; + } + return false; +} + +// TensorRT engines can expose FP16 I/O even when the graph itself is otherwise +// identical to an FP32 export. Keep conversion on the host so the shared +// decoder always receives a finite float32 tensor. +static float half_to_float(uint16_t bits) { + const uint32_t sign = (bits & 0x8000u) << 16; + const uint32_t exp = (bits >> 10) & 0x1fu; + const uint32_t frac = bits & 0x03ffu; + uint32_t value; + if (exp == 0) { + if (frac == 0) value = sign; + else { + uint32_t mant = frac; + uint32_t e = 0; + while ((mant & 0x0400u) == 0) { mant <<= 1; ++e; } + mant &= 0x03ffu; + const int exponent = 127 - 14 - static_cast(e); + value = sign | (static_cast(exponent) << 23) | (mant << 13); + } + } else if (exp == 31) { + value = sign | 0x7f800000u | (frac << 13); + } else { + value = sign | ((exp + (127u - 15u)) << 23) | (frac << 13); + } + float result; + std::memcpy(&result, &value, sizeof(result)); + return result; +} + +static uint16_t float_to_half(float value) { + uint32_t bits; + std::memcpy(&bits, &value, sizeof(bits)); + const uint32_t sign = (bits >> 16) & 0x8000u; + const uint32_t exponent_bits = (bits >> 23) & 0xffu; + uint32_t fraction = bits & 0x7fffffu; + if (exponent_bits == 0xffu) { + return static_cast( + sign | 0x7c00u | (fraction ? (0x0200u | (fraction >> 13)) : 0u)); + } + int exponent = static_cast(exponent_bits) - 127; + if (exponent > 15) return static_cast(sign | 0x7c00u); + if (exponent >= -14) { + fraction += 0x0fffu + ((fraction >> 13) & 1u); + if (fraction & 0x800000u) { + fraction = 0; + if (++exponent > 15) return static_cast(sign | 0x7c00u); + } + return static_cast( + sign | (static_cast(exponent + 15) << 10) | (fraction >> 13)); + } + if (exponent < -25) return static_cast(sign); + const uint32_t mantissa = fraction | 0x800000u; + const int shift = -exponent - 1; + uint32_t rounded = mantissa >> shift; + const uint32_t remainder_mask = (1u << shift) - 1u; + const uint32_t remainder = mantissa & remainder_mask; + const uint32_t halfway = 1u << (shift - 1); + if (remainder > halfway || (remainder == halfway && (rounded & 1u))) ++rounded; + return static_cast(sign | rounded); +} + +static void convert_half(const std::vector& src, std::vector& dst) { + dst.resize(src.size()); + for (size_t i = 0; i < src.size(); ++i) dst[i] = half_to_float(src[i]); +} + TrtBackend::TrtBackend(const std::string& engine_path) { std::ifstream f(engine_path, std::ios::binary); - if (!f) throw std::runtime_error("cannot open engine: " + engine_path); + if (!f) throw std::runtime_error("cannot open TensorRT engine: " + engine_path); std::vector blob((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + if (blob.empty()) throw std::runtime_error("TensorRT engine is empty: " + engine_path); runtime_.reset(nvinfer1::createInferRuntime(g_logger)); + if (!runtime_) throw std::runtime_error("failed to create TensorRT runtime"); engine_.reset(runtime_->deserializeCudaEngine(blob.data(), blob.size())); if (!engine_) throw std::runtime_error("failed to deserialize engine (built for a different GPU arch / TRT version?)"); ctx_.reset(engine_->createExecutionContext()); - CUDA_CHECK(cudaStreamCreate(&stream_)); - - // discover I/O tensors (TensorRT 10 named-tensor API): input [1,3,H,W]; - // the rank-3 output is the detection head, a rank-4 output is a seg proto tensor. - for (int i = 0; i < engine_->getNbIOTensors(); ++i) { - const char* nm = engine_->getIOTensorName(i); - auto dims = engine_->getTensorShape(nm); - if (engine_->getTensorIOMode(nm) == nvinfer1::TensorIOMode::kINPUT) { - in_name_ = nm; in_sz_ = dims.d[2]; // [1,3,H,W] - } else if (dims.nbDims == 4) { - proto_name_ = nm; - pc_ = dims.d[1]; ph_ = dims.d[2]; pw_ = dims.d[3]; // [1,nm,mh,mw] - } else { - out_name_ = nm; feat_dim_ = dims.d[1]; num_anchors_ = dims.d[2]; // [1,feat,anchors] + if (!ctx_) throw std::runtime_error("failed to create TensorRT execution context"); + + // The runner deliberately accepts one data input, one rank-3 detection + // output, and at most one rank-4 prototype output. Rejecting auxiliary or + // ambiguous tensors here is preferable to decoding an intermediate feature + // map as if it were a detector head. + int input_count = 0; + int detection_count = 0; + int proto_count = 0; + std::vector unsupported; + const int io_count = engine_->getNbIOTensors(); + if (io_count <= 0) throw std::runtime_error("TensorRT engine exposes no named I/O tensors"); + for (int i = 0; i < io_count; ++i) { + const char* raw_name = engine_->getIOTensorName(i); + const std::string name = raw_name ? raw_name : ""; + if (name.empty()) throw std::runtime_error("TensorRT engine contains an unnamed I/O tensor"); + const nvinfer1::Dims dims = engine_->getTensorShape(name.c_str()); + const auto mode = engine_->getTensorIOMode(name.c_str()); + bool fp16 = false; + const auto type = engine_->getTensorDataType(name.c_str()); + if (mode == nvinfer1::TensorIOMode::kINPUT) { + if (!supported_type(type, fp16)) + throw std::runtime_error("TRT input '" + name + "' must be FP32 or FP16"); + if (++input_count > 1) + throw std::runtime_error("TRT runner requires exactly one input tensor (found multiple)"); + if (dims.nbDims != 4 || dims.d[0] != 1 || dims.d[1] != 3 || + dims.d[2] <= 0 || dims.d[3] <= 0 || dims.d[2] != dims.d[3] || + dims.d[2] > std::numeric_limits::max()) { + throw std::runtime_error("TRT input '" + name + "' must have a static square shape [1,3,H,W], got " + + dims_string(dims)); + } + in_name_ = name; + in_sz_ = static_cast(dims.d[2]); + input_fp16_ = fp16; + continue; } + + if (dims.nbDims == 3) { + if (!supported_type(type, fp16)) + throw std::runtime_error("TRT detection output '" + name + "' must be FP32 or FP16"); + if (dims.d[0] != 1 || dims.d[1] <= 0 || dims.d[2] <= 0 || + dims.d[1] > std::numeric_limits::max() || + dims.d[2] > std::numeric_limits::max()) { + throw std::runtime_error("TRT detection output '" + name + "' must have static shape [1,F,A], got " + + dims_string(dims)); + } + const int axis0 = static_cast(dims.d[1]); + const int axis1 = static_cast(dims.d[2]); + if (axis0 == axis1 || std::min(axis0, axis1) < 5) { + throw std::runtime_error("TRT detection output '" + name + + "' has an ambiguous/non-detector layout " + dims_string(dims)); + } + if (++detection_count > 1) + throw std::runtime_error("TRT engine exposes multiple rank-3 outputs; provide one unambiguous detection head"); + out_name_ = name; + out_dim0_ = axis0; + out_dim1_ = axis1; + feat_dim_ = std::min(axis0, axis1); + num_anchors_ = std::max(axis0, axis1); + output_fp16_ = fp16; + continue; + } + + if (dims.nbDims == 4) { + if (!supported_type(type, fp16)) + throw std::runtime_error("TRT prototype output '" + name + "' must be FP32 or FP16"); + if (dims.d[0] != 1 || dims.d[1] <= 0 || dims.d[2] <= 0 || dims.d[3] <= 0 || + dims.d[1] > std::numeric_limits::max() || + dims.d[2] > std::numeric_limits::max() || + dims.d[3] > std::numeric_limits::max()) { + throw std::runtime_error("TRT rank-4 output '" + name + + "' must have static shape [1,C,H,W], got " + dims_string(dims)); + } + if (++proto_count > 1) + throw std::runtime_error("TRT engine exposes multiple rank-4 outputs; prototype selection is ambiguous"); + proto_name_ = name; + pc_ = static_cast(dims.d[1]); + ph_ = static_cast(dims.d[2]); + pw_ = static_cast(dims.d[3]); + proto_fp16_ = fp16; + continue; + } + unsupported.push_back(name + " " + dims_string(dims)); + } + if (input_count != 1) + throw std::runtime_error("TRT runner requires exactly one input tensor (found " + + std::to_string(input_count) + ")"); + if (detection_count != 1) + throw std::runtime_error("TRT engine has no rank-3 detection output"); + if (!unsupported.empty()) { + std::ostringstream msg; + msg << "TRT engine exposes unsupported auxiliary I/O tensors: "; + for (size_t i = 0; i < unsupported.size(); ++i) { + if (i) msg << ", "; + msg << unsupported[i]; + } + throw std::runtime_error(msg.str()); } - if (in_sz_ <= 0 || feat_dim_ <= 0 || num_anchors_ <= 0) - throw std::runtime_error("unexpected engine I/O shape"); fixed_imgsz = in_sz_; active_ep = "TRT-CUDA"; - // metadata sidecar (engines embed no names/imgsz): .metadata.yaml, - // then metadata.yaml next to the engine. Same format as the ncnn/mnn exports, so the - // parser is shared. --classes on the CLI still overrides. + // Metadata sidecar (engines embed no names/imgsz): + // .metadata.yaml, then metadata.yaml next to the engine. { namespace fs = std::filesystem; const fs::path ep(engine_path); - for (const fs::path& p : { fs::path(ep).replace_extension(".metadata.yaml"), - ep.parent_path() / "metadata.yaml" }) { - std::vector names; int misz = 0; + for (const fs::path& p : {fs::path(ep).replace_extension(".metadata.yaml"), + ep.parent_path() / "metadata.yaml"}) { + std::vector names; + int misz = 0; std::error_code ec; if (fs::exists(p, ec) && meta::read_ncnn_yaml(p.string(), names, misz)) { meta_names = std::move(names); meta_imgsz = misz; if (misz > 0 && misz != in_sz_) - std::cerr << "[trt] warn: sidecar imgsz=" << misz << " but engine input is " - << in_sz_ << "px (" << p.string() << ")\n"; + std::cerr << "[trt] warn: sidecar imgsz=" << misz + << " but engine input is " << in_sz_ << "px (" << p.string() << ")\n"; break; } } } - CUDA_CHECK(cudaMalloc(&d_in_, size_t(3) * in_sz_ * in_sz_ * sizeof(float))); - CUDA_CHECK(cudaMalloc(&d_out_, size_t(feat_dim_) * num_anchors_ * sizeof(float))); - h_out_.resize(size_t(feat_dim_) * num_anchors_); - ctx_->setTensorAddress(in_name_.c_str(), d_in_); - ctx_->setTensorAddress(out_name_.c_str(), d_out_); - if (pc_ > 0) { - CUDA_CHECK(cudaMalloc(&d_proto_, size_t(pc_) * ph_ * pw_ * sizeof(float))); - h_proto_.resize(size_t(pc_) * ph_ * pw_); - ctx_->setTensorAddress(proto_name_.c_str(), d_proto_); + const size_t input_count_elements = checked_elements({3, in_sz_, in_sz_}, "input"); + const size_t output_count_elements = checked_elements({feat_dim_, num_anchors_}, "detection output"); + if (pc_ > 0) (void)checked_elements({pc_, ph_, pw_}, "prototype output"); + try { + CUDA_CHECK(cudaStreamCreate(&stream_)); + CUDA_CHECK(cudaMalloc(&d_in_, checked_bytes(input_count_elements, + input_fp16_ ? sizeof(uint16_t) : sizeof(float), + "input"))); + CUDA_CHECK(cudaMalloc(&d_out_, checked_bytes(output_count_elements, + output_fp16_ ? sizeof(uint16_t) : sizeof(float), + "detection output"))); + h_out_.resize(output_count_elements); + if (output_fp16_) h_out16_.resize(output_count_elements); + if (pc_ > 0) { + const size_t proto_count_elements = checked_elements({pc_, ph_, pw_}, "prototype output"); + CUDA_CHECK(cudaMalloc(&d_proto_, checked_bytes(proto_count_elements, + proto_fp16_ ? sizeof(uint16_t) : sizeof(float), + "prototype output"))); + h_proto_.resize(proto_count_elements); + if (proto_fp16_) h_proto16_.resize(proto_count_elements); + } + if (!ctx_->setTensorAddress(in_name_.c_str(), d_in_)) + throw std::runtime_error("TRT failed to bind input tensor '" + in_name_ + "'"); + if (!ctx_->setTensorAddress(out_name_.c_str(), d_out_)) + throw std::runtime_error("TRT failed to bind detection output tensor '" + out_name_ + "'"); + if (d_proto_ && !ctx_->setTensorAddress(proto_name_.c_str(), d_proto_)) + throw std::runtime_error("TRT failed to bind prototype output tensor '" + proto_name_ + "'"); + } catch (...) { + if (d_in_) { cudaFree(d_in_); d_in_ = nullptr; } + if (d_out_) { cudaFree(d_out_); d_out_ = nullptr; } + if (d_proto_) { cudaFree(d_proto_); d_proto_ = nullptr; } + if (stream_) { cudaStreamDestroy(stream_); stream_ = nullptr; } + throw; } } TrtBackend::~TrtBackend() { - if (d_in_) cudaFree(d_in_); - if (d_out_) cudaFree(d_out_); + if (d_in_) cudaFree(d_in_); + if (d_out_) cudaFree(d_out_); if (d_proto_) cudaFree(d_proto_); - if (stream_) cudaStreamDestroy(stream_); + if (stream_) cudaStreamDestroy(stream_); } std::vector TrtBackend::infer(const cv::Mat& bgr, const Config& cfg) { + if (cfg.imgsz != in_sz_) + throw std::runtime_error("TRT engine has fixed input size " + std::to_string(in_sz_) + + "; requested imgsz=" + std::to_string(cfg.imgsz)); + if (cfg.num_classes() <= 0) + throw std::runtime_error("TRT inference requires a positive class count"); + + // Resolve the detector ABI only after the caller's class profile is known. + // YOLOv8/EsMoE heads are 4+nc, YOLOv5 heads are 4+1+nc; segmentation heads + // append prototype coefficients after either layout. + const int expected_features = std::max(5, 4 + cfg.num_classes()); + has_objectness_ = false; + mask_channels_ = 0; + if (feat_dim_ == expected_features) { + // plain YOLOv8/EsMoE detection + } else if (feat_dim_ == expected_features + 1) { + has_objectness_ = true; + } else if (pc_ > 0 && feat_dim_ == expected_features + pc_) { + mask_channels_ = pc_; + } else if (pc_ > 0 && feat_dim_ == expected_features + 1 + pc_) { + has_objectness_ = true; + mask_channels_ = pc_; + } else { + std::ostringstream msg; + msg << "TRT detection feature count " << feat_dim_ << " is incompatible with nc=" + << cfg.num_classes(); + if (pc_ > 0) msg << " and prototype channels=" << pc_; + throw std::runtime_error(msg.str()); + } + if (pc_ > 0 && mask_channels_ == 0) + throw std::runtime_error( + "TRT engine exposes a rank-4 output, but the detection head has no matching mask coefficients"); + auto t0 = clk::now(); LetterboxInfo lb; - cv::Mat padded = preprocess(bgr, in_sz_, cfg.stretch, lb); // in_sz_ x in_sz_, BGR - const int sz = in_sz_, hw = sz * sz; - std::vector in(3 * hw); + cv::Mat padded = preprocess(bgr, in_sz_, cfg.stretch, lb); + const int sz = in_sz_; + const size_t hw = checked_elements({sz, sz}, "input"); + std::vector in(static_cast(3) * hw); for (int y = 0; y < sz; ++y) { const uint8_t* row = padded.ptr(y); for (int x = 0; x < sz; ++x) { - const uint8_t* px = row + x * 3; // BGR -> RGB /255, NCHW - const int idx = y * sz + x; - in[idx] = px[2] * (1.0f / 255); - in[hw + idx] = px[1] * (1.0f / 255); - in[2 * hw + idx] = px[0] * (1.0f / 255); + const uint8_t* px = row + x * 3; // BGR -> RGB /255, NCHW + const size_t idx = static_cast(y) * sz + x; + in[idx] = px[2] * (1.0f / 255); + in[hw + idx] = px[1] * (1.0f / 255); + in[static_cast(2) * hw + idx] = px[0] * (1.0f / 255); } } pre_ms = ms_since(t0); + std::vector in16; auto t1 = clk::now(); - CUDA_CHECK(cudaMemcpyAsync(d_in_, in.data(), in.size() * sizeof(float), - cudaMemcpyHostToDevice, stream_)); + if (input_fp16_) { + in16.resize(in.size()); + for (size_t i = 0; i < in.size(); ++i) in16[i] = float_to_half(in[i]); + CUDA_CHECK(cudaMemcpyAsync(d_in_, in16.data(), in16.size() * sizeof(uint16_t), + cudaMemcpyHostToDevice, stream_)); + } else { + CUDA_CHECK(cudaMemcpyAsync(d_in_, in.data(), in.size() * sizeof(float), + cudaMemcpyHostToDevice, stream_)); + } if (!ctx_->enqueueV3(stream_)) throw std::runtime_error("TRT enqueueV3 failed"); - CUDA_CHECK(cudaMemcpyAsync(h_out_.data(), d_out_, h_out_.size() * sizeof(float), - cudaMemcpyDeviceToHost, stream_)); - if (pc_ > 0) - CUDA_CHECK(cudaMemcpyAsync(h_proto_.data(), d_proto_, h_proto_.size() * sizeof(float), + if (output_fp16_) { + CUDA_CHECK(cudaMemcpyAsync(h_out16_.data(), d_out_, h_out16_.size() * sizeof(uint16_t), + cudaMemcpyDeviceToHost, stream_)); + } else { + CUDA_CHECK(cudaMemcpyAsync(h_out_.data(), d_out_, h_out_.size() * sizeof(float), cudaMemcpyDeviceToHost, stream_)); + } + if (d_proto_ && mask_channels_ > 0) { + if (proto_fp16_) { + CUDA_CHECK(cudaMemcpyAsync(h_proto16_.data(), d_proto_, h_proto16_.size() * sizeof(uint16_t), + cudaMemcpyDeviceToHost, stream_)); + } else { + CUDA_CHECK(cudaMemcpyAsync(h_proto_.data(), d_proto_, h_proto_.size() * sizeof(float), + cudaMemcpyDeviceToHost, stream_)); + } + } CUDA_CHECK(cudaStreamSynchronize(stream_)); infer_ms = ms_since(t1); + if (output_fp16_) convert_half(h_out16_, h_out_); + if (!std::all_of(h_out_.begin(), h_out_.end(), [](float v) { return std::isfinite(v); })) + throw std::runtime_error("TRT detection output contains NaN or Inf"); - // "forward once, tune cheap": cache the pre-NMS candidates + letterbox (+ proto for - // seg engines) so slicing, cached re-NMS and annotation export work like every other - // backend (mirrors ort_backend.cpp). auto t2 = clk::now(); - candidates = decode_candidates(h_out_.data(), feat_dim_, num_anchors_, cfg, lb); - cand_orig_w = lb.orig_w; cand_orig_h = lb.orig_h; cand_lb = lb; - if (pc_ > 0) { + std::vector transposed; + const float* decoded = h_out_.data(); + if (out_dim0_ > out_dim1_) { + transposed.resize(h_out_.size()); + for (int anchor = 0; anchor < num_anchors_; ++anchor) + for (int feature = 0; feature < feat_dim_; ++feature) + transposed[static_cast(feature) * num_anchors_ + anchor] = + h_out_[static_cast(anchor) * feat_dim_ + feature]; + decoded = transposed.data(); + } + candidates = decode_candidates(decoded, feat_dim_, num_anchors_, cfg, lb, has_objectness_); + cand_orig_w = lb.orig_w; + cand_orig_h = lb.orig_h; + cand_lb = lb; + proto.clear(); + proto_c = proto_h = proto_w = 0; + if (mask_channels_ > 0 && d_proto_) { + if (proto_fp16_) convert_half(h_proto16_, h_proto_); + if (!std::all_of(h_proto_.begin(), h_proto_.end(), + [](float v) { return std::isfinite(v); })) + throw std::runtime_error("TRT prototype output contains NaN or Inf"); proto = h_proto_; - proto_c = pc_; proto_h = ph_; proto_w = pw_; - } else { - proto.clear(); - proto_c = proto_h = proto_w = 0; + proto_c = pc_; + proto_h = ph_; + proto_w = pw_; } auto dets = nms_and_cap(candidates, cfg, lb.orig_w, lb.orig_h); post_ms = ms_since(t2); diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/environment.schema.json b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/environment.schema.json new file mode 100644 index 000000000..dd6b67bdd --- /dev/null +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/environment.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Tencent/YOLO-Master/issue51-environment.schema.json", + "title": "YOLO-Master Issue #51 environment record", + "type": "object", + "required": ["schema_version", "captured_at_utc", "host", "tools", "sdk_roots", "gpu", "runtime_protocol", "repository"], + "properties": { + "schema_version": {"const": "issue51-environment/v1"}, + "captured_at_utc": {"type": "string", "minLength": 1}, + "host": { + "type": "object", + "required": ["system", "machine", "cpu_model", "logical_cpus"], + "properties": { + "system": {"type": ["string", "null"]}, + "release": {"type": ["string", "null"]}, + "version": {"type": ["string", "null"]}, + "platform": {"type": ["string", "null"]}, + "machine": {"type": ["string", "null"]}, + "processor": {"type": ["string", "null"]}, + "cpu_model": {"type": ["string", "null"]}, + "logical_cpus": {"type": ["integer", "null"], "minimum": 1}, + "physical_cpus": {"type": ["integer", "null"], "minimum": 1}, + "memory_bytes": {"type": ["integer", "null"], "minimum": 0} + } + }, + "tools": {"type": "object"}, + "sdk_roots": {"type": "object"}, + "gpu": {"type": "object"}, + "runtime_protocol": { + "type": "object", + "properties": { + "backend": {"type": ["string", "null"]}, + "execution_provider": {"type": ["string", "null"]}, + "threads": {"type": ["integer", "null"], "minimum": 0}, + "warmup": {"type": ["integer", "null"], "minimum": 0}, + "runs": {"type": ["integer", "null"], "minimum": 0} + } + }, + "repository": {"type": "object"} + } +} diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/evidence-manifest.example.json b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/evidence-manifest.example.json new file mode 100644 index 000000000..1cfbc69e5 --- /dev/null +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/evidence-manifest.example.json @@ -0,0 +1,56 @@ +{ + "schema_version": "issue51-evidence/v1", + "status": "template", + "dataset": { + "name": "visdrone", + "split": "val", + "image_count": 0, + "images": [], + "image_list_sha256": null + }, + "protocol": { + "imgsz": 640, + "conf": 0.001, + "iou": 0.7, + "max_det": 300, + "multi_label": true, + "letterbox": true, + "small_conf": -1.0, + "small_area": 1024.0, + "color": "RGB", + "layout": "NCHW", + "normalization": "float32 / 255.0", + "routing_semantics": null + }, + "training": null, + "artifacts": { + "checkpoint": null, + "models": {}, + "reports": {}, + "labels": null, + "predictions": null + }, + "calibration": { + "enabled": false, + "image_count": 0, + "images": [], + "image_list_sha256": null, + "disjoint_from_validation": null + }, + "environment": { + "python": null, + "platform": null, + "machine": null, + "processor": null, + "git_commit": null + }, + "run": { + "command": null + }, + "gates": { + "accuracy_min_images": 500, + "fp32_max_abs_delta_pp": 0.5, + "int8_max_abs_delta_pp": 1.0, + "calibration_min_images": 300 + } +} diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/evidence-manifest.schema.json b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/evidence-manifest.schema.json new file mode 100644 index 000000000..5ee510999 --- /dev/null +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/evidence-manifest.schema.json @@ -0,0 +1,253 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Tencent/YOLO-Master/evidence-manifest.schema.json", + "title": "YOLO-Master Issue #51 evidence manifest", + "type": "object", + "required": ["schema_version", "status", "dataset", "protocol", "artifacts", "calibration"], + "properties": { + "schema_version": {"const": "issue51-evidence/v1"}, + "status": {"enum": ["template", "diagnostic", "acceptance-candidate"]}, + "dataset": { + "type": "object", + "required": ["name", "split", "image_count", "images", "image_list_sha256"], + "properties": { + "name": {"enum": ["visdrone", "sku110k"]}, + "split": {"type": "string", "minLength": 1}, + "image_count": {"type": "integer", "minimum": 0}, + "image_list_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-fA-F]{64}$"}, + "images": {"type": "array", "items": {"$ref": "#/$defs/file_record"}} + } + }, + "protocol": { + "type": "object", + "required": ["imgsz", "conf", "iou", "max_det", "multi_label", "letterbox"], + "properties": { + "imgsz": {"type": "integer", "minimum": 1}, + "conf": {"type": "number", "minimum": 0, "maximum": 1}, + "iou": {"type": "number", "minimum": 0, "maximum": 1}, + "max_det": {"type": "integer", "minimum": 1}, + "multi_label": {"type": "boolean"}, + "letterbox": {"type": "boolean"}, + "small_conf": { + "type": "number", "minimum": -1, "maximum": 1, + "description": "Optional confidence floor for boxes below small_area; -1 disables the sweep." + }, + "small_area": { + "type": "number", "minimum": 0, + "description": "Original-image area threshold in square pixels for small_conf." + }, + "routing_semantics": { + "type": ["string", "null"], + "enum": ["native_sparse", "dense_fallback", "dense_native", "not_applicable", null] + } + } + }, + "training": { + "type": ["object", "null"], + "description": "Optional training provenance supplied by the experiment owner.", + "properties": { + "epochs": {"type": "integer", "minimum": 1}, + "batch_size": {"type": "integer", "minimum": 1}, + "seed": {"type": "integer"}, + "base_model": {"type": ["string", "null"]}, + "dataset_version": {"type": ["string", "null"]}, + "optimizer": {"type": ["string", "null"]}, + "lr_schedule": {"type": ["string", "null"]}, + "command": {"type": ["string", "null"]} + }, + "additionalProperties": true + }, + "artifacts": { + "type": "object", + "required": ["checkpoint", "models", "labels", "predictions"], + "properties": { + "checkpoint": {"anyOf": [{"$ref": "#/$defs/file_record"}, {"type": "null"}]}, + "models": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/hashed_collection" + } + }, + "reports": { + "type": "object", + "description": "Content-addressed mAP, benchmark, and diagnostic reports.", + "additionalProperties": {"$ref": "#/$defs/hashed_collection"} + }, + "labels": {"anyOf": [{"$ref": "#/$defs/file_collection"}, {"type": "null"}]}, + "predictions": {"anyOf": [{"$ref": "#/$defs/file_collection"}, {"type": "null"}]} + } + }, + "calibration": { + "type": "object", + "required": ["enabled", "image_count", "images", "image_list_sha256", "disjoint_from_validation"], + "properties": { + "enabled": {"type": "boolean"}, + "image_count": {"type": "integer", "minimum": 0}, + "images": {"type": "array", "items": {"$ref": "#/$defs/file_record"}}, + "image_list_sha256": {"type": ["string", "null"], "pattern": "^[0-9a-fA-F]{64}$"}, + "disjoint_from_validation": {"type": ["boolean", "null"]} + } + }, + "environment": { + "type": "object", + "properties": { + "python": {"type": ["string", "null"]}, + "platform": {"type": ["string", "null"]}, + "machine": {"type": ["string", "null"]}, + "git_commit": {"type": ["string", "null"]} + } + }, + "run": { + "type": "object", + "properties": { + "command": {"type": ["string", "null"]} + } + } + }, + "allOf": [ + { + "if": { + "properties": {"status": {"const": "acceptance-candidate"}}, + "required": ["status"] + }, + "then": { + "required": ["training", "environment", "run"], + "properties": { + "protocol": { + "required": ["small_conf", "small_area", "routing_semantics"], + "properties": { + "small_conf": {"type": "number", "minimum": -1, "maximum": 1}, + "small_area": {"type": "number", "minimum": 0}, + "routing_semantics": { + "type": "string", + "enum": ["native_sparse", "dense_fallback", "dense_native", "not_applicable"], + "minLength": 1 + } + } + }, + "dataset": { + "required": ["image_count", "images", "image_list_sha256"], + "properties": { + "image_count": {"minimum": 500}, + "images": {"minItems": 500}, + "image_list_sha256": {"type": "string"} + } + }, + "artifacts": { + "required": ["checkpoint", "models", "reports", "labels", "predictions"], + "properties": { + "checkpoint": {"$ref": "#/$defs/file_record"}, + "models": { + "minProperties": 1, + "additionalProperties": { + "properties": {"files": {"minItems": 1}} + } + }, + "reports": { + "minProperties": 1, + "additionalProperties": { + "properties": {"files": {"minItems": 1}} + } + }, + "labels": { + "type": "object", + "properties": { + "count": {"minimum": 500}, + "files": {"minItems": 500} + } + }, + "predictions": { + "type": "object", + "properties": { + "count": {"minimum": 500}, + "files": {"minItems": 500} + } + } + } + }, + "training": { + "type": "object", + "required": ["base_model", "dataset_version", "epochs", "seed", "command"], + "properties": { + "base_model": {"type": "string", "minLength": 1}, + "dataset_version": {"type": "string", "minLength": 1}, + "command": {"type": "string", "minLength": 1} + } + }, + "environment": { + "required": ["python", "platform", "machine", "git_commit"], + "properties": { + "python": {"type": "string", "minLength": 1}, + "platform": {"type": "string", "minLength": 1}, + "machine": {"type": "string", "minLength": 1}, + "git_commit": {"type": "string", "minLength": 1} + } + }, + "run": { + "required": ["command"], + "properties": { + "command": {"type": "string", "minLength": 1} + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "calibration": { + "properties": {"enabled": {"const": true}}, + "required": ["enabled"] + } + }, + "required": ["calibration"] + }, + "then": { + "properties": { + "calibration": { + "properties": { + "image_count": {"minimum": 300}, + "images": {"minItems": 300}, + "image_list_sha256": {"type": "string"}, + "disjoint_from_validation": {"const": true} + } + } + } + } + } + ] + } + } + ], + "$defs": { + "file_record": { + "type": "object", + "required": ["path", "bytes", "sha256"], + "properties": { + "path": { + "type": "string", + "minLength": 1, + "description": "Relative POSIX path below the corresponding verification root.", + "pattern": "^(?!/)(?![A-Za-z]:)(?!.*\\\\)(?!.*(?:^|/)\\.\\.?(?:/|$))(?!.*//).+" + }, + "bytes": {"type": "integer", "minimum": 0}, + "sha256": {"type": "string", "pattern": "^[0-9a-fA-F]{64}$"} + } + }, + "file_collection": { + "type": "object", + "required": ["count", "files"], + "properties": { + "count": {"type": "integer", "minimum": 0}, + "files": {"type": "array", "items": {"$ref": "#/$defs/file_record"}} + } + }, + "hashed_collection": { + "type": "object", + "required": ["files", "sha256"], + "properties": { + "files": {"type": "array", "items": {"$ref": "#/$defs/file_record"}}, + "sha256": {"type": "string", "pattern": "^[0-9a-fA-F]{64}$"} + } + } + } +} diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/gui/src/app.cpp b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/gui/src/app.cpp index 477eaa731..653cae92e 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/gui/src/app.cpp +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/gui/src/app.cpp @@ -150,6 +150,7 @@ void App::folder_preinfer(std::vector paths, Config c, SliceConfig cv::Mat bgr = cv::imread(paths[i], cv::IMREAD_COLOR); if (!bgr.empty()) { try { + double model_ms = 0.0; if (sc.mode != SliceMode::Off) { // postcondition restores be_->candidates/lb/proto -> snapshot below unchanged const SliceOutput so = sliced_candidates(*be_, bgr, c, sc, kConfFloor, @@ -158,20 +159,25 @@ void App::folder_preinfer(std::vector paths, Config c, SliceConfig model_is_seg_ = so.model_is_seg; tstats_.add(so.tiles_run, so.tiles_total, so.tile_size_used, so.used_fallback, so.capped); + model_ms = so.infer_ms; } else { be_->infer(bgr, c); + model_ms = be_->infer_ms; } FolderItem it; it.cands = be_->candidates; it.lb = be_->cand_lb; it.ow = be_->cand_orig_w; it.oh = be_->cand_orig_h; if (be_->is_seg()) { it.proto = be_->proto; it.pc = be_->proto_c; it.ph = be_->proto_h; it.pw = be_->proto_w; } - it.ms = be_->infer_ms; + // A sliced forward consists of the global pass plus all + // selected tiles; model_ms is the aggregate value returned by + // sliced_candidates rather than the last tile's value. + it.ms = model_ms; it.done = true; fcache_[i] = std::move(it); - sum += be_->infer_ms; ++n; - if (n == 1 || be_->infer_ms < lo) lo = be_->infer_ms; - if (n == 1 || be_->infer_ms > hi) hi = be_->infer_ms; + sum += model_ms; ++n; + if (n == 1 || model_ms < lo) lo = model_ms; + if (n == 1 || model_ms > hi) hi = model_ms; fmean_ms_ = sum / n; // publish live so the UI can show progress stats fmin_ms_ = lo; fmax_ms_ = hi; fcount_ = n; fwall_s_ = std::chrono::duration(clk::now() - t_start).count(); @@ -189,6 +195,13 @@ void App::load_folder(const std::string& dir, const Platform& plat) { close_folder(&plat); load_err_.clear(); folder_imgs_ = gather_images(dir, 0); // sorted image paths + std::string stem_error; + if (!validate_unique_stems(folder_imgs_, stem_error)) { + folder_imgs_.clear(); + cur_idx_ = -1; + load_err_ = stem_error; + return; + } folder_path_ = dir; if (folder_imgs_.empty()) { cur_idx_ = -1; load_err_ = "no images in: " + dir; return; } if (!be_) { folder_imgs_.clear(); load_err_ = "load a model first"; return; } @@ -479,7 +492,9 @@ void App::run_inference() { // run, so recompute_nms/rebuild_overlay below work unchanged. sstats_ = sliced_candidates(*be_, img_bgr_, c, slice_config(), kConfFloor); sliced_run_ = true; - pre_ms_ = 0; inf_ms_ = be_->infer_ms; post_ms_ = 0; // sum of all forwards + pre_ms_ = sstats_.pre_ms; + inf_ms_ = sstats_.infer_ms; + post_ms_ = sstats_.post_ms; // sums of all forwards } else { dets_ = be_->infer(img_bgr_, c); sliced_run_ = false; diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/gui/src/main_win.cpp b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/gui/src/main_win.cpp index a560cc303..a0d1761c1 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/gui/src/main_win.cpp +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/gui/src/main_win.cpp @@ -1,5 +1,8 @@ // Windows platform layer: Win32 window + D3D11 device/swapchain + Dear ImGui bootstrap. // Injects texture-upload + file-dialog services into the portable App (app.cpp). +#ifndef NOMINMAX +#define NOMINMAX +#endif #include "imgui.h" #include "imgui_impl_win32.h" #include "imgui_impl_dx11.h" diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/00_setup.sh b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/00_setup.sh index e440f9540..df34c08c3 100755 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/00_setup.sh +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/00_setup.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Jetson Orin Nano (Super) — verify the platform, lock max performance, install build deps. +# Jetson Orin — verify the platform, lock max performance, install build deps. # Safe to re-run. Run after every reboot to restore the power/clock state. set -e cd "$(dirname "$0")" @@ -8,6 +8,15 @@ echo "==================== platform ====================" if [ -f /etc/nv_tegra_release ]; then head -1 /etc/nv_tegra_release; else echo " (not a Tegra device?)"; fi echo "-- CUDA --"; (/usr/local/cuda/bin/nvcc --version 2>/dev/null || nvcc --version 2>/dev/null) | grep -i release || echo " nvcc not found (add /usr/local/cuda/bin to PATH)" echo "-- TensorRT --"; dpkg -l 2>/dev/null | grep -iE "tensorrt " | awk '{print " "$2" "$3}' | head -1 || echo " (check: dpkg -l | grep tensorrt)" +TRT_VERSION_HEADER="" +for h in /usr/include/aarch64-linux-gnu/NvInferVersion.h /usr/include/NvInferVersion.h; do + if [ -f "$h" ]; then TRT_VERSION_HEADER="$h"; break; fi +done +if [ -n "$TRT_VERSION_HEADER" ]; then + grep -E 'NV_TENSORRT_(MAJOR|MINOR|PATCH)' "$TRT_VERSION_HEADER" | sed 's/^/ /' +else + echo " NvInferVersion.h not found" +fi echo "-- cuDNN --"; dpkg -l 2>/dev/null | grep -iE "libcudnn" | awk '{print " "$2" "$3}' | head -1 TRTEXEC=$(command -v trtexec || echo /usr/src/tensorrt/bin/trtexec) echo "-- trtexec --"; [ -x "$TRTEXEC" ] && echo " $TRTEXEC" || echo " NOT FOUND (expected /usr/src/tensorrt/bin/trtexec)" @@ -29,6 +38,6 @@ if [ -f models/esmoe_n_visdrone_sim.onnx ]; then echo " models/esmoe_n_visdrone_sim.onnx ✓ ($(du -h models/esmoe_n_visdrone_sim.onnx | cut -f1))" else echo " ⚠ models/esmoe_n_visdrone_sim.onnx MISSING." - echo " scp it from your server: scp user@host:/data/yolo-master-edge/models/esmoe_n_visdrone_sim.onnx models/" + echo " copy the experiment's ONNX artifact into models/ and record its SHA256" fi echo "done. next: bash 10_trt_bench.sh" diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/20_build_runner.sh b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/20_build_runner.sh index c104e314d..c83d479aa 100755 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/20_build_runner.sh +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/20_build_runner.sh @@ -2,7 +2,7 @@ # Build the C++ edge runner on aarch64 (ONNX backend, CPU) and run it. # The GPU ceiling is measured by trtexec (10_trt_bench.sh); this proves the portable # runner builds+runs unchanged on the Jetson (same source as Linux/Windows). -set -e +set -euo pipefail cd "$(dirname "$0")" ROOT="$(cd .. && pwd)" # edge repo root (cpp/ lives here) ORT_VER=1.20.1 @@ -21,9 +21,10 @@ echo "==================== build (aarch64, ORT backend, PORTABLE) ============== cd "$ROOT/cpp" rm -rf build_jetson && mkdir build_jetson && cd build_jetson cmake .. -DCMAKE_BUILD_TYPE=Release -DPORTABLE=ON -DUSE_NCNN=OFF \ - -DONNXRUNTIME_ROOT="$ORT_DIR" 2>&1 | grep -iE "backend:|error" || true -make -j"$(nproc)" 2>&1 | grep -iE "error|Built target" | tail -1 + -DONNXRUNTIME_ROOT="$ORT_DIR" 2>&1 | tee configure.log +cmake --build . --parallel "$(nproc)" 2>&1 | tee build.log BIN="$ROOT/cpp/build_jetson/yolomaster_edge" +[ -x "$BIN" ] || { echo "build completed without an executable: $BIN" >&2; exit 1; } echo " binary: $BIN" echo "==================== run ====================" diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/21_build_trt_runner.sh b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/21_build_trt_runner.sh index 31406b18a..d2ce424b2 100755 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/21_build_trt_runner.sh +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/21_build_trt_runner.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash -# Build the C++ runner WITH the TensorRT backend -> real GPU inference from a .engine. -# TRT/CUDA come from JetPack (no SDK download). The engine is the GPU path, so ORT/ncnn are off. +# Build the C++ runner WITH the TensorRT 10 backend -> real GPU inference from a .engine. +# TRT/CUDA come from the target JetPack (no SDK download). The native source uses +# TensorRT's named-I/O API; TensorRT 8 targets should use 22_build_ort_trt.sh. # # v1.1.0: builds against a LEAN local OpenCV (core/imgproc/imgcodecs/videoio, ffmpeg on, # GStreamer/GUI off) so video sources + video label export work without dragging the # JetPack OpenCV's GStreamer closure into the bundle. Built once into # third_party/opencv-lean (~30 min on an Orin Nano), cached afterward. -set -e +set -euo pipefail cd "$(dirname "$0")" ROOT="$(cd .. && pwd)" @@ -29,11 +30,21 @@ fi cd "$ROOT/cpp" rm -rf build_trt && mkdir build_trt && cd build_trt -cmake .. -DCMAKE_BUILD_TYPE=Release -DOpenCV_DIR="$OCV/lib/cmake/opencv4" \ - -DUSE_ORT=OFF -DUSE_NCNN=OFF -DUSE_MNN=OFF -DUSE_TRT=ON 2>&1 | grep -iE "backend:|Found OpenCV|error" || true -make -j"$(nproc)" 2>&1 | grep -iE "error|Built target" | tail -2 +# JetPack installs TensorRT/CUDA in the standard system locations. For a +# container, cross-build, or unpacked SDK, callers may provide explicit roots; +# keep the arguments conditional so the target's normal discovery still works. +CMAKE_ARGS=( + -DCMAKE_BUILD_TYPE=Release + -DOpenCV_DIR="$OCV/lib/cmake/opencv4" + -DUSE_ORT=OFF -DUSE_NCNN=OFF -DUSE_MNN=OFF -DUSE_TRT=ON +) +if [ -n "${TENSORRT_ROOT:-}" ]; then CMAKE_ARGS+=("-DTENSORRT_ROOT=$TENSORRT_ROOT"); fi +if [ -n "${CUDA_ROOT:-}" ]; then CMAKE_ARGS+=("-DCUDA_ROOT=$CUDA_ROOT"); fi +cmake .. "${CMAKE_ARGS[@]}" 2>&1 | tee configure.log +cmake --build . --parallel "$(nproc)" 2>&1 | tee build.log BIN="$ROOT/cpp/build_trt/yolomaster_edge" +[ -x "$BIN" ] || { echo "build completed without an executable: $BIN" >&2; exit 1; } ENG="$ROOT/jetson/engines/esmoe_n_fp16.engine" echo echo "built: $BIN" diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/22_build_ort_trt.sh b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/22_build_ort_trt.sh index c275f1b5c..faed23af4 100755 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/22_build_ort_trt.sh +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/22_build_ort_trt.sh @@ -9,26 +9,27 @@ # ORT_ROOT=/path/to/onnxruntime-jetson bash 22_build_ort_trt.sh # # Getting that ORT (the provisioning, not this script, is the gate): -# * Standard JetPack (jp6 / CUDA 12.x): NVIDIA ships prebuilt onnxruntime-gpu WITH the CUDA+TRT EPs -# via the Jetson index, e.g. -# pip install onnxruntime-gpu --index-url https://pypi.jetson-ai-lab.dev/jp6/cu126 -# Note: PyPI's onnxruntime-gpu is x86_64-only (no aarch64 wheel) — you must use the Jetson index. -# The C++ runner also needs headers: pair the wheel's libonnxruntime.so with the matching-version -# headers from the onnxruntime GitHub release (arch-independent) under $ORT_ROOT/{lib,include}. -# * Bleeding-edge CUDA (e.g. 13.x, no prebuilt wheel yet): build ORT from source with --use_tensorrt, -# OR just use the native TRT backend (jetson/21_build_trt_runner.sh) — same TensorRT under the hood, -# needs only JetPack, and is the validated path (35.7 FPS / 0.3488 mAP50, see DEPLOYMENT_LOG.md). -set -e +# * Use a Jetson/aarch64 distribution that explicitly includes the CUDA and +# TensorRT execution providers for the installed JetPack. Availability and +# index URLs are release-specific; verify the wheel's provider list before +# building and record the exact source in DEPLOYMENT_LOG.md. +# * The public PyPI onnxruntime-gpu package is generally x86_64-only. The C++ +# runner also needs headers: pair the wheel's libonnxruntime.so with matching +# version headers from the ONNX Runtime release under $ORT_ROOT/{lib,include}. +# * If no compatible wheel exists, build ORT from source with --use_tensorrt, +# or use the native TensorRT 10 backend (jetson/21_build_trt_runner.sh). +set -euo pipefail cd "$(dirname "$0")"; ROOT="$(cd .. && pwd)" : "${ORT_ROOT:?set ORT_ROOT to a Jetson ONNXRuntime with the TensorRT EP (see README)}" [ -f "$ORT_ROOT/include/onnxruntime_cxx_api.h" ] || { echo "no ORT headers at $ORT_ROOT/include"; exit 1; } cd "$ROOT/cpp"; rm -rf build_ort_trt && mkdir build_ort_trt && cd build_ort_trt cmake .. -DCMAKE_BUILD_TYPE=Release -DPORTABLE=ON -DUSE_NCNN=OFF -DUSE_TRT=OFF -DUSE_ORT=ON \ - -DONNXRUNTIME_ROOT="$ORT_ROOT" 2>&1 | grep -iE "backend:|error" || true -make -j"$(nproc)" 2>&1 | grep -iE "error|Built target" | tail -2 + -DONNXRUNTIME_ROOT="$ORT_ROOT" 2>&1 | tee configure.log +cmake --build . --parallel "$(nproc)" 2>&1 | tee build.log BIN="$ROOT/cpp/build_ort_trt/yolomaster_edge" +[ -x "$BIN" ] || { echo "build completed without an executable: $BIN" >&2; exit 1; } echo echo "built: $BIN" echo "run (ORT + TensorRT EP; first run builds+caches the engine in ./trt_engine_cache):" diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/30_package.sh b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/30_package.sh index 550fb9772..f9069b024 100755 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/30_package.sh +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/30_package.sh @@ -1,14 +1,22 @@ #!/usr/bin/env bash -# Package the native TensorRT runner into a portable bundle for Jetson Orin / JetPack 7. +# Package a locally built TensorRT runner for Jetson Orin / JetPack. # Bundles OpenCV (+ any non-system deps) with an $ORIGIN/lib rpath. DEPENDS on JetPack's -# TensorRT + CUDA, which are present (version-matched) on every JetPack 7 device -> not bundled +# TensorRT + CUDA, which must be supplied by the version-matched target JetPack -> not bundled # (keeps it small + robust). The .engine is device-specific, so we ship the .onnx + build_engine.sh -# (builds a clean FP16 engine on first setup). Runs on any Orin (Nano/NX/AGX, sm87) on JetPack 7. +# (builds a device-specific FP16 engine on first setup). Verify compatibility on the target device. set -e cd "$(dirname "$0")"; ROOT="$(cd .. && pwd)" VERSION="${1:-1.1.0}" BIN="$ROOT/cpp/build_trt/yolomaster_edge" ONNX="${ONNX:-$ROOT/jetson/models/esmoe_n_visdrone_sim.onnx}" +MODEL_BASENAME="$(basename -- "$ONNX")" +case "$MODEL_BASENAME" in + *.onnx) ;; + *) echo "ONNX must point to a .onnx file: $ONNX" >&2; exit 2 ;; +esac +MODEL_STEM="${MODEL_BASENAME%.onnx}" +[ -n "$MODEL_STEM" ] || { echo "ONNX filename has no model stem: $ONNX" >&2; exit 2; } +ENGINE_BASENAME="${MODEL_STEM}_fp16.engine" [ -x "$BIN" ] || { echo "build first: bash jetson/21_build_trt_runner.sh"; exit 1; } command -v patchelf >/dev/null 2>&1 || sudo apt install -y patchelf @@ -20,11 +28,11 @@ echo "== bundling non-JetPack libs (depend on JetPack TRT/CUDA + base system) == ldd "$BIN" | awk '/=> \//{print $3}' | sort -u | while read -r lib; do base=$(basename "$lib") case "$base" in - # JetPack (TensorRT + CUDA + Jetson driver stack) — present on every JP7 device + # JetPack (TensorRT + CUDA + Jetson driver stack) — supplied by the target image libnvinfer*|libnvonnxparser*|libnvparsers*|libcudart*|libcuda.*|libcublas*|libcudnn*|\ libcufft*|libcurand*|libcusparse*|libcusolver*|libnpp*|libnv*|libcupti*) echo " [jetpack] $base" ;; - # base system (Ubuntu 24.04 aarch64) — present everywhere + # base system (JetPack-provided Ubuntu aarch64 userland) — present everywhere ld-linux*|libc.so*|libm.so*|libdl.so*|libpthread*|librt.so*|libresolv*|libstdc++*|\ libgcc_s*|libgomp*|libz.so*) echo " [system] $base" ;; @@ -34,47 +42,64 @@ ldd "$BIN" | awk '/=> \//{print $3}' | sort -u | while read -r lib; do done patchelf --set-rpath '$ORIGIN/lib' "$OUT/yolomaster_edge" -cp "$ONNX" "$OUT/models/" 2>/dev/null || echo " [warn] no .onnx at $ONNX — add it to models/ before shipping" +if [ -f "$ONNX" ]; then + cp "$ONNX" "$OUT/models/$MODEL_BASENAME" +else + echo " [warn] no .onnx at $ONNX — add it to models/ before shipping" +fi # metadata sidecar: the runner reads class names + imgsz from metadata.yaml next to the # engine (v1.1.0), so --classes is no longer needed when this ships. for MD in "${ONNX%.onnx}.metadata.yaml" "$(dirname "$ONNX")/metadata.yaml"; do [ -f "$MD" ] && { cp "$MD" "$OUT/models/metadata.yaml"; break; } done -cat > "$OUT/build_engine.sh" <<'EOS' -#!/usr/bin/env bash +{ + printf '%s\n' '#!/usr/bin/env bash' + printf 'MODEL_FILE=%q\n' "$MODEL_BASENAME" + printf 'ENGINE_FILE=%q\n' "$ENGINE_BASENAME" + cat <<'EOS' # Build the FP16 TensorRT engine on THIS Jetson (engines are device + TRT-version specific). -# OPT=3 sidesteps the KTM FP16 build bug on Orin/TRT10; swap covers the 4GB Nano. +# OPT=3 is a conservative default for TensorRT 10; tune it on the target and +# record any change in the deployment log. The swap covers the 4GB Nano. set -e; cd "$(dirname "$0")" TRTEXEC=$(find /usr -name trtexec -type f 2>/dev/null | head -1) [ -n "$TRTEXEC" ] || { echo "trtexec not found — install: sudo apt install nvidia-jetpack"; exit 1; } +MODEL_PATH="models/$MODEL_FILE" +ENGINE_PATH="models/$ENGINE_FILE" +[ -f "$MODEL_PATH" ] || { echo "model not found: $MODEL_PATH" >&2; exit 1; } if ! swapon --show | grep -q .; then echo "adding 8G swap for the build (remove later with: sudo swapoff /swapfile && sudo rm /swapfile)" sudo fallocate -l 8G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile fi -"$TRTEXEC" --onnx=models/esmoe_n_visdrone_sim.onnx --fp16 \ - --saveEngine=models/esmoe_n_fp16.engine \ +"$TRTEXEC" --onnx="$MODEL_PATH" --fp16 \ + --saveEngine="$ENGINE_PATH" \ --memPoolSize=workspace:256 --builderOptimizationLevel=3 --maxAuxStreams=0 -echo "engine -> models/esmoe_n_fp16.engine" -echo "run: ./yolomaster_edge --model models/esmoe_n_fp16.engine --source --out out" +echo "engine -> $ENGINE_PATH" +echo "run: ./yolomaster_edge --model $ENGINE_PATH --source --out out" EOS +} > "$OUT/build_engine.sh" chmod +x "$OUT/build_engine.sh" cat > "$OUT/README.md" <<'EOS' -# YOLO-Master-EsMoE-N — Jetson Orin (JetPack 7) native TensorRT runner +# YOLO-Master-EsMoE-N — Jetson Orin native TensorRT runner -Prebuilt aarch64 GPU runner. Runs on any **Jetson Orin** (Nano / NX / AGX, sm87) on **JetPack 7** -(CUDA 13 / TensorRT 10). Depends on JetPack's TensorRT + CUDA (already installed); OpenCV is bundled. +Locally packaged aarch64 runner. It requires a compatible **Jetson Orin** and +the target's JetPack TensorRT + CUDA installation; OpenCV is bundled when found +by the packaging step. Verify the generated binary and engine on the target. ## 1. Build the engine — once per device (engines are device-specific) ./build_engine.sh - # ~10-15 min; writes models/esmoe_n_fp16.engine (FP16). Adds an 8G swapfile if none exists. + # writes models/_fp16.engine (FP16). Adds an 8G swapfile if none exists. ## 2. Run on the GPU - ./yolomaster_edge --model models/esmoe_n_fp16.engine --source \ + ./yolomaster_edge --model models/_fp16.engine --source \ --conf 0.25 --out out -Class names + input size come from `models/metadata.yaml` (shipped); `--classes visdrone` +The package contains the selected `.onnx` file under `models/`; use `ls models/*.onnx` +to see its name. The generated build script uses that exact filename, so custom +`ONNX=/path/to/model.onnx` packages remain self-consistent. + +Class names + input size come from `models/metadata.yaml` (included when available); `--classes visdrone` still overrides. Video sources decode through the bundled ffmpeg-based OpenCV. ## 3. v1.1.0 features @@ -89,7 +114,10 @@ Segmentation engines work too: build one from a seg .onnx (e.g. v0.1-seg-n.onnx) build_engine.sh and put its metadata.yaml next to it — masks render and label export emits real polygons. -Validated on Orin Nano 4GB: 35.7 FPS, 0.3488 mAP50 (VisDrone val, -0.46% vs FP32). +Performance and accuracy are intentionally left unspecified here. Populate +them only from a fixed validation manifest, per-image predictions, and archived +target-device logs; external Jetson measurements are not valid evidence for +this package. EOS tar czf "$OUT.tar.gz" -C "$(dirname "$OUT")" "$(basename "$OUT")" diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/DEPLOYMENT_LOG.md b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/DEPLOYMENT_LOG.md index a84eb7adb..f47119480 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/DEPLOYMENT_LOG.md +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/DEPLOYMENT_LOG.md @@ -1,59 +1,78 @@ -# Jetson Orin Nano — Deployment Log +# Jetson deployment log template -Deploying YOLO-Master-EsMoE-N to a Jetson Orin Nano (4 GB) via TensorRT: the deployment numbers, -on-device accuracy, and the reproducible build issues on Orin / TensorRT 10.16.2. +Use this file as the human-readable companion to an evidence manifest. It is a +template, not a record of a run performed in this repository. Replace every +`TBD` field from the target device and archive the unedited command output. ## Platform -| | | -|---|---| -| Device | Jetson Orin Nano 4 GB (`Orin`, sm87, 4 SMs @ 0.624 GHz, ~3.4 GB usable) | -| Software | JetPack 7 — Ubuntu 24.04, CUDA 13.2, TensorRT 10.16.2 | -| Model | `esmoe_n_visdrone_sim.onnx` (opset 12, `images[1,3,640,640] → output0[1,14,8400]`) | - -## Result - -Deployment engine: **clean FP16** (`trtexec --fp16 --builderOptimizationLevel=3`). - -| Engine | GPU compute | FPS | mAP50 | mAP50-95 | -|---|---|---|---|---| -| **FP16 (shipped)** | **27.8 ms** | **35.7** | **0.3488** | **0.2029** | -| QDQ INT8 (+FP32 fallback) | 45.4 ms | 21.7 | 0.3202 | 0.1834 | -| uncalibrated `--int8` | 31.4 ms | 31.6 | 0.128 | — | - -mAP over the full 548 VisDrone val images (`eval_map_standalone.py`); FP16 is **−0.46% / −0.34%** vs the -FP32 baseline (0.3504 / 0.2036) — near-lossless. Context: x86 CPU (ORT) 25 FPS · Orin FP16 35.7 FPS · -H200 CUDA 128 FPS. - -**FP16, not INT8.** The mixed-precision recipe keeps the area-attention, head, and router out of INT8, so -INT8 leaves the compute-heavy attention on FP32/FP16 kernels; combined with TensorRT's lossier INT8, the -calibrated engine is both slower and less accurate than FP16. The `uncalibrated --int8` row is a trap: -`--int8` on a plain FP32 ONNX (no calibration/QDQ) silently builds a broken INT8 engine that benchmarks -fast but collapses to 0.128 mAP — a speed benchmark can't catch this, so validate mAP. - -## Build issues (Orin / TensorRT 10.16.2) - -**1. Pure-FP16 build fails at low opt levels.** At `--builderOptimizationLevel<=2` TensorRT's kernel timing -model references an `sm80` FP16 conv shader with no `sm87` base and asserts, producing an empty engine -(`KTM assertion failure: convolutionTimingModel.cpp:65 shader != nullptr` → `Created engine with size: 0 MiB`). -Fix: **`--builderOptimizationLevel=3`** (profiles tactics on-device instead of estimating) — needed for any -build that keeps FP16 layers (pure FP16 and mixed-precision QDQ INT8). - -**2. ORT-quantized QDQ won't parse in TensorRT.** An `onnxruntime.quantization` QDQ model needs three things -the parser requires: (a) **symmetric** activations (ORT defaults to asymmetric → `Non-zero zero point is not -supported`); (b) **no int32-bias DQ** (ORT quantizes bias to int32, TensorRT handles bias internally → -`DequantizeLayer can only run in kINT8/…`); (c) **opset ≥ 13** for per-channel DQ. All handled by -`scripts/quantize_int8.py --symmetric` (`ActivationSymmetric` + `QuantizeBias=False` + opset upgrade). - -**3. 4 GB build OOM.** The builder profiles tactics wanting 100s of MB each; on a 4 GB module it OOMs or -skips every fast tactic. Fix for the *build* (inference itself needs ~20 MB): go headless -(`sudo systemctl isolate multi-user.target`), add swap (`fallocate -l 8G /swapfile`), and cap -`--memPoolSize=workspace:256 --maxAuxStreams=0`. - -## Reproduce -```bash -# on the Jetson (headless + 8 GB swap on the 4 GB Nano) -trtexec --onnx=esmoe_n_visdrone_sim.onnx --fp16 \ - --saveEngine=esmoe_n_fp16.engine \ - --memPoolSize=workspace:256 --builderOptimizationLevel=3 --maxAuxStreams=0 -``` -Scripts: `jetson/{00_setup,10_trt_bench,21_build_trt_runner,30_package}.sh`. Power: `nvpmodel -m 0 && jetson_clocks`. + +| Field | Value | +| --- | --- | +| Device and memory | TBD | +| JetPack / Ubuntu | TBD | +| CUDA / cuDNN / TensorRT | TBD | +| Power mode and clocks | TBD | +| CPU/GPU temperature during benchmark | TBD | +| Runner commit | TBD | +| Compiler and CMake version | TBD | + +## Model and engine + +| Field | Value | +| --- | --- | +| Checkpoint path and SHA256 | TBD | +| ONNX path and SHA256 | TBD | +| Engine path and SHA256 | TBD | +| Input shape and output shape | TBD | +| Precision recipe | TBD (FP32 / FP16 / calibrated INT8) | +| Calibration list digest and count | TBD / not applicable | + +## Benchmark protocol + +| Parameter | Value | +| --- | --- | +| Ordered image-list digest | TBD | +| Image count | TBD | +| Input size | TBD | +| Confidence / IoU / max detections | TBD | +| Multi-label decoding | TBD | +| Warm-up runs | TBD | +| Timed runs | TBD | +| Threads / execution provider | TBD | + +## Results + +Report device-side compute and end-to-end timing separately. Do not fill a +metric cell until the corresponding prediction directory and reference JSON +have been verified by `evidence_manifest.py verify`. + +| Engine | Compute mean (ms) | End-to-end P50 (ms) | P95 (ms) | P99 (ms) | FPS | mAP50 | mAP50-95 | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| FP32 | TBD | TBD | TBD | TBD | TBD | TBD | TBD | +| FP16 | TBD | TBD | TBD | TBD | TBD | TBD | TBD | +| Calibrated INT8 | TBD | TBD | TBD | TBD | TBD | TBD | TBD | + +An uncalibrated `trtexec --int8` run may be retained as a parser or throughput +diagnostic, but it must be labelled diagnostic and must not be used as an +accuracy result. + +## Build observations + +Record only observations reproduced on this device and software stack. For +each failure include the exact command, exit status, relevant log excerpt and +the change that resolved it. + +| Symptom | Reproduction command | Resolution | Evidence path | +| --- | --- | --- | --- | +| TBD | TBD | TBD | TBD | + +## Required attachments + +* raw `trtexec` and runner logs; +* model/engine and image-list SHA256 records; +* per-image predictions and the PyTorch/reference metric JSON; +* the completed evidence manifest and its verification output; +* the exact build and benchmark commands. + +Without these attachments, this log supports only a procedural description and +not a cross-platform deployment claim. diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/README.md b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/README.md index 13a8b2205..0351518ef 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/README.md +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson/README.md @@ -1,26 +1,29 @@ -# Jetson Orin Nano (Super) Deployment Kit — YOLO-Master-EsMoE-N +# Jetson Orin Deployment Kit — YOLO-Master-EsMoE-N -aarch64 / JetPack deployment for the edge runner + TensorRT. Runs on a **Jetson Orin Nano (Super)** -with **JetPack 6.x** (Ubuntu 22.04, CUDA 12, TensorRT 10, cuDNN 9 — all preinstalled). +aarch64 / JetPack deployment procedure for the edge runner and TensorRT. The +scripts must be run on a compatible Jetson device; this checkout does not +contain a device binary, engine, model, or benchmark evidence. ## Prerequisites -- JetPack 6.x flashed (CUDA / TensorRT / cuDNN come with it — you do **not** install them). +- A Jetson Orin device with a supported JetPack image. CUDA, TensorRT and + cuDNN are supplied by JetPack; do **not** mix libraries from another release. + The native TensorRT runner in `cpp/` requires the TensorRT 10 named-I/O API. + On images that ship TensorRT 8, use the ORT + TensorRT-EP route instead. - The VisDrone model files placed in `jetson/models/`: - `esmoe_n_visdrone_sim.onnx` (for TensorRT + the ONNX backend) - `esmoe_n_visdrone_ncnn/` (for the ncnn backend, optional) - - Get them by `scp` from your server (`/data/yolo-master-edge/models/`) or from the repo's GitHub Release. + - Supply them from the experiment's model archive and record their SHA256. - Internet (for `apt` build deps + fetching the ONNXRuntime aarch64 SDK). ## Quick start (in order) ```bash -git clone https://github.com/skywalker-lt/yolo-master-edge.git -cd yolo-master-edge/jetson +cd examples/YOLO-Master-Cross-Platform-Edge-Deployment/jetson # put the model in models/ (scp esmoe_n_visdrone_sim.onnx here) bash 00_setup.sh # verify JetPack, set MAX power, install build deps -bash 10_trt_bench.sh # TensorRT FP16 + INT8 engines + throughput <- the headline number +bash 10_trt_bench.sh # TensorRT FP16 + INT8 engines and timing output bash 20_build_runner.sh # build the C++ runner (aarch64) + run it ``` @@ -29,35 +32,74 @@ bash 20_build_runner.sh # build the C++ runner (aarch64) + run it | Step | Output | Why | |---|---|---| | `00_setup.sh` | versions, MAXN power mode, `cmake`/OpenCV installed | reproducible perf (clocks locked) | -| `10_trt_bench.sh` | `*.engine` + **GPU FPS** (FP16, INT8) | the real payoff — Orin's tensor cores; INT8 finally *faster* than FP16 | +| `10_trt_bench.sh` | `*.engine` + timing output (FP16, INT8) | compare modes on the target and archive the raw log | | `20_build_runner.sh` | `yolomaster_edge` (aarch64) + per-frame latency | the portable runner, same binary as Linux/Windows | +Before selecting the native route, record the installed TensorRT major version: + +```bash +grep -E 'NV_TENSORRT_(MAJOR|MINOR|PATCH)' \ + /usr/include/aarch64-linux-gnu/NvInferVersion.h 2>/dev/null || \ +grep -E 'NV_TENSORRT_(MAJOR|MINOR|PATCH)' /usr/include/NvInferVersion.h +``` + +The native build discovers the JetPack installation under `/usr` and +`/usr/local/cuda` by default. When the headers and libraries are staged in a +non-standard SDK or sysroot, pass the roots explicitly; the same values are +written to `configure.log` for later audit: + +```bash +TENSORRT_ROOT=/opt/tensorrt CUDA_ROOT=/opt/cuda bash 21_build_trt_runner.sh +``` + ## GPU inference — two routes | Route | Script | Ships | Pros | Needs | |---|---|---|---|---| -| **Native TRT** | `21_build_trt_runner.sh` | a prebuilt `.engine` | leanest deps (just JetPack TRT+CUDA); direct | build the engine per device via `trtexec` (KTM/OOM caveats, §4) | +| **Native TRT 10** | `21_build_trt_runner.sh` | a device-local `.engine` | direct TensorRT execution | TensorRT 10.x and CUDA from the target JetPack; build the engine per device via `trtexec` | | **ORT + TRT-EP** | `22_build_ort_trt.sh` | the `.onnx` | portable; auto engine build+cache; auto INT8(QDQ)/FP16/CUDA fallback | a Jetson ONNXRuntime **with the TensorRT EP**, matched to your CUDA/TRT | -> **ORT provisioning caveat:** the TRT-EP path needs a Jetson ONNXRuntime built with the TensorRT EP. NVIDIA ships this for **standard JetPack (jp6/cu12x)** via `pypi.jetson-ai-lab.dev` (PyPI's `onnxruntime-gpu` is x86-only). On **bleeding-edge CUDA (13.x)** no prebuilt wheel exists yet → build ORT from source, or use the **native TRT backend** (`21_build_trt_runner.sh`), which needs only JetPack and is the validated path (35.7 FPS / 0.3488 mAP50). +> **Version boundary:** the native backend is intentionally limited to TensorRT +> 10.x because it uses the named-I/O (`enqueueV3`) API. TensorRT 8.x targets +> should use an ONNXRuntime build with the TensorRT EP, matched to the target +> CUDA/TensorRT versions, or build a separate legacy binding backend. + +> **ORT provisioning caveat:** the TRT-EP path needs a Jetson ONNXRuntime built +> with the TensorRT EP. Use a version matched to the installed CUDA and +> TensorRT, or build ORT from source. -Both run on the GPU. Native TRT is easiest to provision (JetPack only). ORT+TRT-EP is more portable — -ship one `.onnx`, ORT builds+caches the engine on first run and picks INT8 (where QDQ) / FP16 / CUDA per -layer automatically. On bleeding-edge CUDA (e.g. 13.x) a prebuilt Jetson ORT-gpu may not exist yet → -build ORT from source or use the native TRT path. +Both routes execute on the GPU. Native TRT requires a device-local engine; +ORT+TRT-EP accepts the ONNX model and can build/cache an engine on first run. +The selected route, CUDA version and TensorRT version must be recorded with the +benchmark log. ## Notes -- **FP16 build bug (Orin/TRT 10.16.2):** pure `--fp16` at `OPT<=2` fails with a KTM `sm80`-shader - assertion (empty engine). Use `--builderOptimizationLevel=3`, or the `--int8 --fp16` path. Full repro in [`DEPLOYMENT_LOG.md`](DEPLOYMENT_LOG.md) §4. +- **Builder diagnostics:** TensorRT tactic selection and memory requirements are + version- and device-dependent. If an engine build fails, record the exact + TensorRT error, builder optimization level and workspace in + [`DEPLOYMENT_LOG.md`](DEPLOYMENT_LOG.md); do not generalize one device's + workaround to another version. -- **4 GB Orin Nano:** the TensorRT *builder* is memory-hungry. Build **headless** +- **4 GB Orin Nano:** the TensorRT *builder* can be memory-hungry. Build **headless** (`sudo systemctl isolate multi-user.target`) and use a small workspace (`WORKSPACE=256 bash 10_trt_bench.sh`), - or tactic profiling OOMs. Runtime inference is fine — the model is tiny; only the build is tight. + if tactic profiling runs out of memory; record any swap and workspace changes. -- **INT8 accuracy:** `trtexec --int8` here measures *speed* with dynamic ranges (not calibrated). For the - <1% mAP INT8 model, build the engine from a calibrated model — keep the detection head in FP16 +- **INT8 accuracy:** `trtexec --int8` without a calibration/QDQ model is a speed + diagnostic only. For an INT8 acceptance run, use a calibrated model and keep + the detection head in FP16 (`--precisionConstraints`/`--layerPrecisions`), mirroring the mixed-precision recipe from `TECHNICAL_REPORT.md §3`. - **Power:** `00_setup.sh` sets `nvpmodel -m 0` (MAXN) + `jetson_clocks`. Re-run after every reboot for stable numbers. - **GPU via the C++ runner:** `20_build_runner.sh` builds the CPU path (functional + portable). GPU acceleration through the runner is a follow-up (ncnn-Vulkan or the ONNXRuntime TensorRT EP); `trtexec` already gives the GPU ceiling. + +## Evidence and reporting + +This directory contains procedures only. Do not copy FPS or mAP values from an +external deployment into a result table. For a target-device run, retain the +exact model and engine hashes, JetPack/CUDA/TensorRT versions, power mode, +workspace and builder options, image-list hash, and raw `trtexec`/runner logs. +Report GPU compute latency and FPS separately from end-to-end latency, and +report mAP50 and mAP50-95 only with the fixed validation manifest and per-image +predictions. A result is publishable only after it is referenced by the shared +`evidence_manifest.py` output. diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/collect_environment.py b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/collect_environment.py new file mode 100644 index 000000000..8e8fca910 --- /dev/null +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/collect_environment.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""Collect reproducible host and toolchain metadata for an Issue #51 run. + +The collector is deliberately dependency-free. Missing optional tools are +represented as ``available=false`` rather than guessed values, which makes the +result suitable for an evidence bundle on Linux, Windows, macOS or Jetson. +The output is metadata only; it does not run inference or claim a benchmark +result. + +Example:: + + python scripts/collect_environment.py \ + --repo-root . --backend onnx --execution-provider cpu \ + --threads 4 --warmup 2 --runs 20 \ + --output artifacts/environment.json +""" + +from __future__ import annotations + +import argparse +import importlib.metadata +import json +import os +import platform +import re +import shlex +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Sequence + + +SCHEMA_VERSION = "issue51-environment/v1" + + +def _first_line(value: str) -> Optional[str]: + for line in value.splitlines(): + line = line.strip() + if line: + return line + return None + + +def _display_command(argv: Sequence[str]) -> str: + return " ".join(shlex.quote(str(part)) for part in argv) + + +def run_probe(argv: Sequence[str], timeout: float = 5.0) -> Dict[str, Any]: + """Run a version/probe command without invoking a shell.""" + args = [str(part) for part in argv] + result: Dict[str, Any] = { + "command": _display_command(args), + "available": False, + "version": None, + "returncode": None, + } + try: + completed = subprocess.run( + args, + check=False, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + except (OSError, subprocess.SubprocessError) as exc: + result["error"] = str(exc) + return result + result["returncode"] = completed.returncode + result["available"] = completed.returncode == 0 + result["version"] = _first_line(completed.stdout) or _first_line(completed.stderr) + if completed.returncode != 0: + result["error"] = _first_line(completed.stderr) or _first_line(completed.stdout) + return result + + +def _command_name(value: Optional[str], fallbacks: Iterable[str]) -> Optional[str]: + candidates: List[str] = [] + if value: + candidates.append(value) + candidates.extend(fallbacks) + for candidate in candidates: + try: + probe = run_probe(_split_command(candidate) + ["--version"]) + except ValueError: + continue + if probe["available"]: + return candidate + return None + + +def _split_command(value: str) -> List[str]: + """Split a command override while retaining paths containing spaces.""" + try: + return shlex.split(value, posix=(os.name != "nt")) + except ValueError as exc: + raise ValueError("invalid command override {!r}: {}".format(value, exc)) from exc + + +def _probe_command(value: Optional[str], fallbacks: Iterable[str]) -> Dict[str, Any]: + selected = _command_name(value, fallbacks) + if selected is None: + return { + "command": value or next(iter(fallbacks), ""), + "available": False, + "version": None, + "returncode": None, + } + return run_probe(_split_command(selected) + ["--version"]) + + +def _linux_cpu_model() -> Optional[str]: + path = Path("/proc/cpuinfo") + if not path.is_file(): + return None + try: + for raw in path.read_text(encoding="utf-8", errors="replace").splitlines(): + if ":" not in raw: + continue + key, value = raw.split(":", 1) + if key.strip().lower() in {"model name", "hardware", "processor"} and value.strip(): + return value.strip() + except OSError: + return None + return None + + +def _memory_bytes() -> Optional[int]: + """Return physical memory when the platform exposes it without a package.""" + if sys.platform.startswith("linux"): + try: + for raw in Path("/proc/meminfo").read_text(encoding="ascii").splitlines(): + if raw.startswith("MemTotal:"): + match = re.search(r"(\d+)", raw) + if match: + return int(match.group(1)) * 1024 + except (OSError, ValueError): + pass + if sys.platform == "darwin": + probe = run_probe(["sysctl", "-n", "hw.memsize"]) + if probe.get("available") and probe.get("version"): + try: + return int(str(probe["version"])) + except ValueError: + pass + try: + # psutil is optional; use it only when already installed. + import psutil # type: ignore + + return int(psutil.virtual_memory().total) + except (ImportError, AttributeError, OSError, ValueError): + return None + + +def _physical_cpus() -> Optional[int]: + try: + import psutil # type: ignore + + value = psutil.cpu_count(logical=False) + return int(value) if value else None + except (ImportError, AttributeError, OSError, ValueError): + pass + return None + + +def _package_version(names: Iterable[str]) -> Optional[str]: + for name in names: + try: + return importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: + continue + return None + + +def _python_packages() -> Dict[str, Optional[str]]: + return { + "numpy": _package_version(("numpy",)), + "opencv": _package_version(("opencv-python", "opencv-python-headless")), + "onnx": _package_version(("onnx",)), + "onnxruntime": _package_version(("onnxruntime", "onnxruntime-gpu")), + "ultralytics": _package_version(("ultralytics",)), + } + + +def _git_metadata(repo_root: Path) -> Dict[str, Any]: + root = repo_root.expanduser().resolve() + result: Dict[str, Any] = {"root": str(root), "commit": None, "branch": None, "dirty": None} + commit = run_probe(["git", "-C", str(root), "rev-parse", "HEAD"]) + if commit.get("available") and commit.get("version"): + result["commit"] = str(commit["version"]) + branch = run_probe(["git", "-C", str(root), "branch", "--show-current"]) + if branch.get("available"): + result["branch"] = branch.get("version") + try: + completed = subprocess.run( + ["git", "-C", str(root), "status", "--porcelain"], + check=False, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=5, + ) + if completed.returncode == 0: + result["dirty"] = bool(completed.stdout.strip()) + except (OSError, subprocess.SubprocessError): + pass + return result + + +def _sdk_probe(root_value: Optional[str], kind: str) -> Optional[Dict[str, Any]]: + if not root_value: + return None + root = Path(root_value).expanduser().resolve() + headers = { + "onnxruntime": ("include/onnxruntime_cxx_api.h",), + "ncnn": ("include/ncnn/net.h",), + "mnn": ("include/MNN/Interpreter.hpp",), + "tensorrt": ("include/NvInfer.h",), + }.get(kind, ()) + libraries = { + "onnxruntime": ("lib/libonnxruntime.so", "lib/onnxruntime.lib", "bin/onnxruntime.dll"), + "ncnn": ("lib/libncnn.so", "lib/libncnn.a", "lib/ncnn.lib"), + "mnn": ("lib/libMNN.so", "lib/libMNN.a", "lib/MNN.lib"), + "tensorrt": ("lib/libnvinfer.so", "lib/nvinfer.dll", "lib/nvinfer.lib"), + }.get(kind, ()) + header_state = {item: (root / item).is_file() for item in headers} + library_state = {item: (root / item).is_file() for item in libraries} + # Versioned Unix libraries are common in release archives; record the + # directory scan without treating a missing unversioned symlink as failure. + library_dirs = [root / "lib", root / "lib64", root / "bin", root / "build/src", root / "build"] + patterns = { + "onnxruntime": ("libonnxruntime.so*", "onnxruntime*.dll", "onnxruntime*.lib"), + "ncnn": ("libncnn.so*", "libncnn.a", "ncnn*.dll", "ncnn*.lib"), + "mnn": ("libMNN.so*", "libMNN.a", "MNN*.dll", "MNN*.lib"), + "tensorrt": ("libnvinfer.so*", "nvinfer*.dll", "nvinfer*.lib"), + }.get(kind, ()) + discovered: List[str] = [] + for directory in library_dirs: + if not directory.is_dir(): + continue + for pattern in patterns: + discovered.extend(str(path.relative_to(root).as_posix()) for path in directory.glob(pattern) if path.is_file()) + return { + "root": str(root), + "exists": root.is_dir(), + "headers": header_state, + "libraries": library_state, + "discovered_libraries": sorted(set(discovered), key=str.casefold), + } + + +def collect_environment(args: argparse.Namespace) -> Dict[str, Any]: + """Build the JSON-serialisable environment record.""" + compiler_override = getattr(args, "compiler", None) or os.environ.get("CXX") + cpu_model = _linux_cpu_model() or platform.processor() or os.environ.get("PROCESSOR_IDENTIFIER") + host: Dict[str, Any] = { + "system": platform.system().lower() or None, + "release": platform.release() or None, + "version": platform.version() or None, + "platform": platform.platform(aliased=True) or None, + "machine": platform.machine() or None, + "processor": platform.processor() or None, + "cpu_model": cpu_model or None, + "logical_cpus": os.cpu_count(), + "physical_cpus": _physical_cpus(), + "memory_bytes": _memory_bytes(), + } + tools: Dict[str, Any] = { + "python": { + "version": platform.python_version(), + "implementation": platform.python_implementation(), + "executable": str(Path(sys.executable).resolve()), + }, + "compiler": _probe_command(compiler_override, ("g++", "clang++", "cl")), + "cmake": _probe_command(None, ("cmake",)), + "pkg_config": _probe_command(None, ("pkg-config",)), + "python_packages": _python_packages(), + } + # pkg-config's --version is useful, but OpenCV's module version is a + # separate query and is intentionally left unavailable if pkg-config is + # not installed. + pkg = tools["pkg_config"] + if pkg.get("available"): + tools["opencv_pkg_config"] = run_probe(["pkg-config", "--modversion", "opencv4"]) + else: + tools["opencv_pkg_config"] = {"available": False, "version": None, "returncode": None} + + sdk_roots = { + name: _sdk_probe(getattr(args, name + "_root", None), name) + for name in ("onnxruntime", "ncnn", "mnn", "tensorrt") + } + runtime = { + "backend": getattr(args, "backend", None), + "execution_provider": getattr(args, "execution_provider", None), + "threads": getattr(args, "threads", None), + "warmup": getattr(args, "warmup", None), + "runs": getattr(args, "runs", None), + } + gpu_probe = run_probe( + ["nvidia-smi", "--query-gpu=name,driver_version,memory.total", "--format=csv,noheader,nounits"] + ) + return { + "schema_version": SCHEMA_VERSION, + "captured_at_utc": datetime.now(timezone.utc).replace(microsecond=0).isoformat(), + "host": host, + "tools": tools, + "sdk_roots": sdk_roots, + "gpu": gpu_probe, + "runtime_protocol": runtime, + "repository": _git_metadata(Path(args.repo_root)), + } + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", type=Path, default=Path.cwd(), help="repository root for Git metadata") + parser.add_argument("--output", type=Path, help="write JSON to this path instead of stdout") + parser.add_argument("--backend", default=None, help="backend used by the planned run") + parser.add_argument("--execution-provider", default=None, help="execution provider used by the planned run") + parser.add_argument("--threads", type=int, default=None) + parser.add_argument("--warmup", type=int, default=None) + parser.add_argument("--runs", type=int, default=None) + parser.add_argument("--compiler", help="compiler command override (default: CXX, g++, clang++, or cl)") + parser.add_argument("--onnxruntime-root", dest="onnxruntime_root", help="ONNX Runtime SDK root") + parser.add_argument("--ncnn-root", dest="ncnn_root", help="NCNN SDK root") + parser.add_argument("--mnn-root", dest="mnn_root", help="MNN SDK root") + parser.add_argument("--tensorrt-root", dest="tensorrt_root", help="TensorRT SDK root") + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = _parser().parse_args(argv) + if (args.threads is not None and args.threads <= 0) or ( + args.warmup is not None and args.warmup < 0 + ) or (args.runs is not None and args.runs <= 0): + print("threads and runs must be positive; warmup must be non-negative", file=sys.stderr) + return 2 + payload = collect_environment(args) + text = json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n" + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text, encoding="utf-8") + else: + print(text, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/eval_map.py b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/eval_map.py index 56b583122..00df53ea6 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/eval_map.py +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/eval_map.py @@ -7,6 +7,11 @@ accepted for diagnostics; for the formal acceptance gate, use labels produced by the official ``visdrone2yolo`` conversion so ignored-region matching is defined by the dataset conversion. +The result exposes both delta conventions: ``delta_mAP50-95_pp`` is the +absolute difference in percentage points (``(candidate-reference)*100``), +while ``delta_mAP50-95_pct`` is the relative percentage difference. Use +``--max-abs-delta-pp`` for the Issue #51 acceptance budget; the older +``--max-abs-delta-pct`` option remains available for relative comparisons. """ from __future__ import annotations @@ -29,6 +34,11 @@ "visdrone": NAMES, "sku110k": {0: "object"}, } +PROFILE_PROTOCOLS = { + "visdrone": {"imgsz": 640, "conf": 0.001, "iou": 0.70, "max_det": 300}, + "sku110k": {"imgsz": 1280, "conf": 0.25, "iou": 0.60, "max_det": 300}, +} +ROUTING_SEMANTICS = ("native_sparse", "dense_fallback", "dense_native", "not_applicable") def manifest_name(path: Path, root: Path) -> str: @@ -48,6 +58,23 @@ def manifest_name(path: Path, root: Path) -> str: relative = Path(resolved_path.name) return relative.as_posix() + +def sha256_file(path: Path) -> str: + """Hash a file incrementally so large validation images stay bounded in memory.""" + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def image_content_manifest(images: list[Path], root: Path) -> str: + """Return a stable digest over ordered relative names and image contents.""" + payload = "\n".join( + f"{manifest_name(path, root)} {sha256_file(path)}" for path in images + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + EXAMPLE_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = EXAMPLE_ROOT.parents[1] if str(REPO_ROOT) not in sys.path: @@ -61,88 +88,244 @@ def load_gt( torch, label_format: str = "auto", num_classes: Optional[int] = None, + strict: bool = False, ): + """Load one label file. + + Diagnostic runs may ignore malformed rows for compatibility with legacy + exports. Formal Issue #51 runs pass ``strict=True`` so every annotation + is either parsed or reported with its source line; silently dropping a row + would change the denominator of the reported metric. + """ + if label_format not in ("auto", "yolo", "visdrone"): + raise ValueError("label_format must be yolo, visdrone, or auto") + if not np.isfinite([width, height]).all() or width <= 0 or height <= 0: + raise ValueError("image dimensions must be finite and positive") boxes, classes = [], [] - if path.is_file(): - for line in path.read_text(encoding="utf-8").splitlines(): - # Accept both YOLO whitespace rows and native VisDrone CSV rows. - # Native ignored regions are dropped here; formal acceptance runs - # should use visdrone2yolo-converted labels (see the module docstring). - fields = line.replace(",", " ").split() - if len(fields) < 5: + if not path.is_file(): + return torch.tensor(boxes, dtype=torch.float32).reshape(-1, 4), torch.tensor(classes, dtype=torch.int64) + try: + rows = [(line_no, line.strip()) for line_no, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), 1 + ) if line.strip() and not line.lstrip().startswith("#")] + except UnicodeDecodeError as exc: + raise ValueError(f"{path}: labels must be UTF-8 text") from exc + if not rows: + return torch.tensor(boxes, dtype=torch.float32).reshape(-1, 4), torch.tensor(classes, dtype=torch.int64) + first_line = rows[0][1] + use_visdrone = label_format == "visdrone" or ( + label_format == "auto" and ("," in first_line or len(first_line.replace(",", " ").split()) >= 8) + ) + for line_no, line in rows: + # Native VisDrone rows carry eight fields; converted YOLO rows carry + # exactly five. Keep the diagnostic path permissive, but never allow + # a malformed row through a formal metric. + if not use_visdrone and "," in line: + if strict: + raise ValueError(f"{path}:{line_no}: mixed label formats; expected whitespace-separated YOLO row") + continue + fields = [field.strip() for field in line.split(",")] if "," in line else line.split() + expected = 8 if use_visdrone else 5 + if len(fields) != expected: + if strict: + raise ValueError(f"{path}:{line_no}: expected exactly {expected} columns, got {len(fields)}") + continue + try: + values = [float(value) for value in fields] + except (TypeError, ValueError, OverflowError) as exc: + if strict: + raise ValueError(f"{path}:{line_no}: label contains a non-numeric value") from exc + continue + if not np.isfinite(values).all(): + if strict: + raise ValueError(f"{path}:{line_no}: label contains NaN or Inf") + continue + if use_visdrone: + left, top, bw, bh, score = values[0:5] + category_value = values[5] + if category_value != np.floor(category_value): + if strict: + raise ValueError(f"{path}:{line_no}: VisDrone category must be an integer") continue - try: - values = [float(value) for value in fields] - except ValueError: + category = int(category_value) + if bw <= 0 or bh <= 0 or left < 0 or top < 0 or not 0.0 <= score <= 1.0: + if strict: + raise ValueError(f"{path}:{line_no}: invalid VisDrone box, score, or origin") continue - # Check the complete row before converting class/category IDs with - # ``int``; ``int(float('inf'))`` raises OverflowError and should be - # treated as a malformed annotation, not abort the whole report. - if not np.isfinite(values).all(): + # Category 0 (ignored regions), category 11 (others), and score 0 + # are intentionally excluded by the official conversion. + if category < 0 or category > 11: + if strict: + raise ValueError(f"{path}:{line_no}: VisDrone category outside 0..11") continue - # Native VisDrone rows always carry at least eight fields - # (x/y/w/h/score/category plus truncation/occlusion). Use that - # structural marker instead of a value-magnitude heuristic, which - # misclassifies a tiny 1x1 raw box as a normalized YOLO row. - use_visdrone = label_format == "visdrone" or (label_format == "auto" and len(values) >= 8) - if use_visdrone: - if len(values) < 6: - continue - left, top, bw, bh = values[0:4] - score = values[4] - if values[5] != np.floor(values[5]): - continue - category = int(values[5]) - if score <= 0 or category <= 0: - continue - cls = category - 1 - box = [left, top, left + bw, top + bh] - else: - if values[0] != np.floor(values[0]): - continue - cls = int(values[0]) - cx, cy, bw, bh = values[1:5] - box = [(cx - bw / 2) * width, (cy - bh / 2) * height, - (cx + bw / 2) * width, (cy + bh / 2) * height] - if num_classes is not None and not 0 <= cls < num_classes: - # VisDrone category 11 ("others") and ignored/out-of-profile - # classes are not part of the ten-class detection task. + if score == 0 or category in (0, 11): continue - if not np.isfinite([cls, *box]).all() or bw <= 0 or bh <= 0 or cls < 0: + cls = category - 1 + box = [left, top, left + bw, top + bh] + else: + cls_value = values[0] + if cls_value != np.floor(cls_value): + if strict: + raise ValueError(f"{path}:{line_no}: YOLO class id must be an integer") + continue + cls = int(cls_value) + cx, cy, bw, bh = values[1:5] + if not (0.0 <= cx <= 1.0 and 0.0 <= cy <= 1.0 and 0.0 < bw <= 1.0 and 0.0 < bh <= 1.0): + if strict: + raise ValueError(f"{path}:{line_no}: YOLO coordinates must be normalized and positive") continue - classes.append(cls) - boxes.append(box) + box = [(cx - bw / 2) * width, (cy - bh / 2) * height, + (cx + bw / 2) * width, (cy + bh / 2) * height] + if num_classes is not None and not 0 <= cls < num_classes: + if strict: + raise ValueError(f"{path}:{line_no}: class {cls} outside [0, {num_classes})") + continue + if not np.isfinite([cls, *box]).all() or bw <= 0 or bh <= 0 or cls < 0: + if strict: + raise ValueError(f"{path}:{line_no}: label contains a degenerate box") + continue + classes.append(cls) + boxes.append(box) return torch.tensor(boxes, dtype=torch.float32).reshape(-1, 4), torch.tensor(classes, dtype=torch.int64) -def load_predictions(path: Path, torch, num_classes: Optional[int] = None): +def load_predictions(path: Path, torch, num_classes: Optional[int] = None, strict: bool = False): + """Load one pixel-xyxy prediction file with optional strict validation.""" boxes, scores, classes = [], [], [] - if path.is_file(): - for line in path.read_text(encoding="utf-8").splitlines(): - fields = line.split() - if len(fields) < 6: - continue - try: - cls_value = float(fields[0]); score = float(fields[1]) - box = [float(value) for value in fields[2:6]] - except (ValueError, OverflowError): - continue - if not np.isfinite([cls_value, score, *box]).all(): - continue - if cls_value != np.floor(cls_value): - continue - cls = int(cls_value) - if cls < 0 or (num_classes is not None and cls >= num_classes): - raise ValueError(f"prediction class {cls} outside [0, {num_classes}) in {path}") - # Keep the evaluator's candidate contract aligned with the C++ and - # MNN decoders: degenerate pixel boxes are discarded before IoU. - if box[2] <= box[0] or box[3] <= box[1]: - continue - classes.append(cls); scores.append(score); boxes.append(box) + if not path.is_file(): + return (torch.tensor(boxes, dtype=torch.float32).reshape(-1, 4), + torch.tensor(scores, dtype=torch.float32), torch.tensor(classes, dtype=torch.int64)) + try: + rows = [(line_no, line.strip()) for line_no, line in enumerate( + path.read_text(encoding="utf-8").splitlines(), 1 + ) if line.strip() and not line.lstrip().startswith("#")] + except UnicodeDecodeError as exc: + raise ValueError(f"{path}: predictions must be UTF-8 text") from exc + for line_no, line in rows: + fields = line.split() + if len(fields) != 6: + if strict: + raise ValueError(f"{path}:{line_no}: prediction expects exactly 6 columns, got {len(fields)}") + continue + try: + cls_value = float(fields[0]); score = float(fields[1]) + box = [float(value) for value in fields[2:6]] + except (TypeError, ValueError, OverflowError) as exc: + if strict: + raise ValueError(f"{path}:{line_no}: prediction contains a non-numeric value") from exc + continue + if not np.isfinite([cls_value, score, *box]).all(): + if strict: + raise ValueError(f"{path}:{line_no}: prediction contains NaN or Inf") + continue + if cls_value != np.floor(cls_value): + if strict: + raise ValueError(f"{path}:{line_no}: prediction class id must be an integer") + continue + cls = int(cls_value) + if cls < 0 or (num_classes is not None and cls >= num_classes): + if strict: + raise ValueError(f"{path}:{line_no}: prediction class {cls} outside [0, {num_classes})") + # Diagnostic runs may inspect legacy output directories that carry + # stale class IDs. Ignore the offending row consistently with the + # other permissive parsing branches; formal acceptance remains strict. + continue + if not 0.0 <= score <= 1.0: + if strict: + raise ValueError(f"{path}:{line_no}: prediction confidence must be in [0, 1]") + continue + # Keep the evaluator's candidate contract aligned with the C++ and + # MNN decoders: degenerate pixel boxes are never scored. + if box[2] <= box[0] or box[3] <= box[1]: + if strict: + raise ValueError(f"{path}:{line_no}: prediction box must have x2>x1 and y2>y1") + continue + classes.append(cls); scores.append(score); boxes.append(box) return (torch.tensor(boxes, dtype=torch.float32).reshape(-1, 4), torch.tensor(scores, dtype=torch.float32), torch.tensor(classes, dtype=torch.int64)) +def _txt_stem_map(directory: Path, kind: str) -> dict[str, Path]: + """Return a case-insensitive stem map and reject duplicate stems.""" + if not directory.is_dir(): + raise FileNotFoundError(f"{kind} directory not found: {directory}") + result: dict[str, Path] = {} + for path in sorted( + (candidate for candidate in directory.rglob("*") + if candidate.is_file() and candidate.suffix.lower() == ".txt"), + key=lambda p: p.as_posix().casefold(), + ): + stem = path.stem.casefold() + if stem in result: + raise RuntimeError(f"{kind} stems are not unique: {stem}") + result[stem] = path + return result + + +def _resolve_images(source: Path, root: Optional[Path] = None) -> tuple[Path, list[Path]]: + """Resolve images below one canonical root. + + An explicit root is useful when a frozen list is stored outside the + dataset tree. Requiring every image below that root prevents portable + evidence digests from containing absolute paths or ``..`` components. + """ + source = source.expanduser() + explicit_root = root.expanduser().resolve() if root is not None else None + if explicit_root is not None and not explicit_root.is_dir(): + raise NotADirectoryError(f"image normalization root not found: {explicit_root}") + + def finish(default_root: Path, paths: list[Path]) -> tuple[Path, list[Path]]: + image_root = (explicit_root or default_root).resolve() + resolved_paths = [path.resolve() for path in paths] + for path in resolved_paths: + try: + path.relative_to(image_root) + except ValueError as exc: + raise ValueError( + f"image {path} is outside evaluation root {image_root}; " + "pass --image-root containing every listed image" + ) from exc + return image_root, resolved_paths + + if source.is_dir(): + paths = sorted( + ( + candidate for candidate in source.rglob("*") + if candidate.is_file() and candidate.suffix.lower() in {".jpg", ".jpeg", ".png", ".bmp"} + ), + key=lambda path: path.as_posix().casefold(), + ) + return finish(source, paths) + if not source.is_file(): + raise FileNotFoundError(f"image source not found: {source}") + paths: list[Path] = [] + try: + lines = source.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError as exc: + raise ValueError(f"{source}: image list must be UTF-8 text") from exc + for line_no, raw in enumerate(lines, 1): + # Lists exported by Windows tools may carry a UTF-8 BOM. Quoted + # entries are accepted as well so paths containing spaces retain the + # same interpretation in Python and in the C++ runner. + line = raw.strip() + if line_no == 1: + line = line.lstrip("\ufeff") + if not line or line.startswith("#"): + continue + if len(line) >= 2 and line[0] == line[-1] and line[0] in {'"', "'"}: + line = line[1:-1].strip() + candidate = Path(line).expanduser() + if not candidate.is_absolute(): + candidate = source.parent / candidate + candidate = candidate.resolve() + if not candidate.is_file() or candidate.suffix.lower() not in {".jpg", ".jpeg", ".png", ".bmp"}: + raise ValueError(f"{source}:{line_no}: unsupported or missing image: {line}") + paths.append(candidate) + if not paths: + raise ValueError(f"image list is empty: {source}") + return finish(source.parent, paths) + + def match_predictions(pred_cls, true_cls, iou, torch, iou_thresholds): """Ultralytics-compatible greedy class-aware matching for 0.50:0.95 IoU.""" correct = np.zeros((pred_cls.shape[0], len(iou_thresholds)), dtype=bool) @@ -173,11 +356,34 @@ def nonnegative_finite_float(value: str) -> float: return parsed +def small_conf_float(value: str) -> float: + """Parse the optional small-object confidence floor (-1 disables it).""" + try: + parsed = float(value) + except (TypeError, ValueError) as exc: + raise argparse.ArgumentTypeError("must be a finite number in [-1, 1]") from exc + if not np.isfinite(parsed) or parsed < -1.0 or parsed > 1.0: + raise argparse.ArgumentTypeError("must be a finite number in [-1, 1]") + return parsed + + +def nonnegative_finite_area(value: str) -> float: + """Parse an original-image area threshold for the small-object sweep.""" + try: + parsed = float(value) + except (TypeError, ValueError) as exc: + raise argparse.ArgumentTypeError("must be a finite non-negative number") from exc + if not np.isfinite(parsed) or parsed < 0.0: + raise argparse.ArgumentTypeError("must be a finite non-negative number") + return parsed + + def delta_gate_passes(abs_delta_pct: float, max_abs_delta_pct: Optional[float]) -> bool: - """Return whether an observed relative mAP delta is within the budget. + """Return whether a relative mAP delta is within the requested budget. - ``None`` means that no gate was requested. Budgets are expressed as - percentage points of the reference mAP (for example, ``0.5`` means 0.5%). + ``None`` means that no gate was requested. Budgets are expressed as a + relative percentage of the reference mAP (for example, ``0.5`` means + 0.5% of the reference value). The comparison is inclusive so a result exactly on the declared maximum is accepted. """ @@ -186,8 +392,22 @@ def delta_gate_passes(abs_delta_pct: float, max_abs_delta_pct: Optional[float]) return float(abs_delta_pct) <= float(max_abs_delta_pct) +def delta_gate_passes_pp(abs_delta_pp: float, max_abs_delta_pp: Optional[float]) -> bool: + """Return whether an absolute mAP delta in percentage points passes. + + mAP is represented as a fraction in the range ``[0, 1]``. Consequently, + ``(candidate - reference) * 100`` is the absolute percentage-point delta; + it is different from the relative percentage used by + :func:`delta_gate_passes`. + """ + if max_abs_delta_pp is None: + return True + return float(abs_delta_pp) <= float(max_abs_delta_pp) + + def validate_delta_budget( - max_abs_delta_pct: Optional[float], reference_json: Optional[Path] + max_abs_delta_pct: Optional[float], reference_json: Optional[Path], + max_abs_delta_pp: Optional[float] = None, ) -> None: """Reject a requested gate that cannot be compared with a reference.""" if max_abs_delta_pct is not None and ( @@ -196,13 +416,25 @@ def validate_delta_budget( raise ValueError("--max-abs-delta-pct must be a finite non-negative number") if max_abs_delta_pct is not None and reference_json is None: raise ValueError("--max-abs-delta-pct requires --reference-json") + if max_abs_delta_pp is not None and ( + not np.isfinite(float(max_abs_delta_pp)) or float(max_abs_delta_pp) < 0 + ): + raise ValueError("--max-abs-delta-pp must be a finite non-negative number") + if max_abs_delta_pp is not None and reference_json is None: + raise ValueError("--max-abs-delta-pp requires --reference-json") + if max_abs_delta_pct is not None and max_abs_delta_pp is not None: + raise ValueError("choose either --max-abs-delta-pct or --max-abs-delta-pp") -def validate_smoke_gate(smoke: bool, max_abs_delta_pct: Optional[float]) -> None: +def validate_smoke_gate( + smoke: bool, + max_abs_delta_pct: Optional[float], + max_abs_delta_pp: Optional[float] = None, +) -> None: """Keep an acceptance delta gate from being attached to a smoke subset.""" - if smoke and max_abs_delta_pct is not None: + if smoke and (max_abs_delta_pct is not None or max_abs_delta_pp is not None): raise ValueError( - "--max-abs-delta-pct cannot be used with --smoke; smoke runs are not acceptance evidence" + "an mAP delta gate cannot be used with --smoke; smoke runs are not acceptance evidence" ) @@ -214,15 +446,30 @@ def validate_acceptance_image_floor(args: argparse.Namespace) -> None: ) -def apply_delta_gate(result, max_abs_delta_pct: Optional[float]) -> int: +def apply_delta_gate( + result, + max_abs_delta_pct: Optional[float], + max_abs_delta_pp: Optional[float] = None, +) -> int: """Annotate ``result`` and return the process code for the optional gate.""" - if max_abs_delta_pct is None: + if max_abs_delta_pct is None and max_abs_delta_pp is None: return 0 - observed = result.get("abs_delta_mAP50-95_pct") - if observed is None: - raise ValueError("--max-abs-delta-pct requires a computed reference delta") - passed = delta_gate_passes(observed, max_abs_delta_pct) - result["max_abs_delta_mAP50-95_pct"] = max_abs_delta_pct + passed = True + if max_abs_delta_pct is not None: + observed = result.get("abs_delta_mAP50-95_pct") + if observed is None: + raise ValueError("--max-abs-delta-pct requires a computed reference delta") + passed = delta_gate_passes(observed, max_abs_delta_pct) + result["max_abs_delta_mAP50-95_pct"] = max_abs_delta_pct + result["mAP50-95_relative_delta_gate_passed"] = passed + if max_abs_delta_pp is not None: + observed_pp = result.get("abs_delta_mAP50-95_pp") + if observed_pp is None: + raise ValueError("--max-abs-delta-pp requires a computed reference delta") + passed_pp = delta_gate_passes_pp(observed_pp, max_abs_delta_pp) + result["max_abs_delta_mAP50-95_pp"] = max_abs_delta_pp + result["mAP50-95_absolute_delta_gate_passed"] = passed_pp + passed = passed and passed_pp result["mAP50-95_delta_gate_passed"] = passed return 0 if passed else 2 @@ -253,7 +500,7 @@ def extract_reference_map(payload: dict) -> float: # used by Ultralytics for box results. for key, value in mapping.items(): normalized = str(key).lower().replace(" ", "") - if normalized in {"map50-95", "metrics/map50-95(b)", "metrics/map50-95"}: + if normalized in {"map50-95", "map50-95(b)", "metrics/map50-95(b)", "metrics/map50-95"}: try: return float(value) except (TypeError, ValueError) as exc: @@ -263,10 +510,111 @@ def extract_reference_map(payload: dict) -> float: ) +def _protocol_mismatches(reference: object, current: dict) -> list[str]: + """Return protocol/manifest mismatches between two metric reports.""" + if not isinstance(reference, dict): + return ["reference JSON must contain an object"] + errors: list[str] = [] + ref_manifest = reference.get("image_manifest_sha256") + cur_manifest = current.get("image_manifest_sha256") + if ref_manifest is None: + errors.append("reference JSON is missing image_manifest_sha256") + elif str(ref_manifest).lower() != str(cur_manifest).lower(): + errors.append("reference image_manifest_sha256 does not match the candidate image list") + if "image_manifest" in reference and reference.get("image_manifest") != current.get("image_manifest"): + errors.append("reference image_manifest order/content does not match the candidate") + # New reports pin image bytes in addition to their ordered names. Keep + # legacy scalar/name-only reports usable for diagnostics, but require the + # content digest whenever the candidate advertises one (strict acceptance). + current_content = current.get("image_list_sha256") or current.get("image_content_manifest_sha256") + reference_content = reference.get("image_list_sha256") or reference.get("image_content_manifest_sha256") + if current_content is not None: + if reference_content is None: + errors.append("reference JSON is missing image_list_sha256") + elif str(reference_content).lower() != str(current_content).lower(): + errors.append("reference image_list_sha256 does not match the candidate images") + ref_protocol = reference.get("protocol") + cur_protocol = current.get("protocol") + if not isinstance(ref_protocol, dict): + errors.append("reference JSON is missing protocol metadata") + elif not isinstance(cur_protocol, dict): + errors.append("candidate JSON is missing protocol metadata") + else: + for key in ("imgsz", "max_det", "multi_label", "letterbox", "color", "layout"): + if key not in ref_protocol: + errors.append(f"reference protocol is missing {key}") + elif ref_protocol[key] != cur_protocol.get(key): + errors.append(f"reference protocol.{key} does not match the candidate") + for key in ("conf", "iou", "small_conf", "small_area"): + if key not in ref_protocol: + errors.append(f"reference protocol is missing {key}") + continue + try: + if abs(float(ref_protocol[key]) - float(cur_protocol.get(key))) > 1e-9: + errors.append(f"reference protocol.{key} does not match the candidate") + except (TypeError, ValueError): + errors.append(f"reference protocol.{key} is not numeric") + ref_profile = reference.get("class_profile") + if ref_profile is None: + errors.append("reference JSON is missing class_profile") + elif ref_profile != current.get("class_profile"): + errors.append("reference class_profile does not match the candidate") + ref_classes = reference.get("classes") + if ref_classes is None: + errors.append("reference JSON is missing classes") + else: + try: + if int(ref_classes) != int(current.get("classes")): + errors.append("reference class count does not match the candidate") + except (TypeError, ValueError): + errors.append("reference classes is not an integer") + try: + if int(reference.get("images")) != int(current.get("images")): + errors.append("reference image count does not match the candidate") + except (TypeError, ValueError): + errors.append("reference JSON is missing a valid images field") + if reference.get("label_format") is not None and reference.get("label_format") != current.get("label_format"): + errors.append("reference label_format does not match the candidate") + # A dense export and a sparse eager run can produce different predictions + # even when every image and threshold is identical. Compare the optional + # field whenever either report declares it; formal callers additionally + # require a non-null value before applying a delta gate. + ref_routing = ref_protocol.get("routing_semantics") if isinstance(ref_protocol, dict) else None + cur_routing = cur_protocol.get("routing_semantics") if isinstance(cur_protocol, dict) else None + if ref_routing is not None or cur_routing is not None: + if ref_routing is None: + errors.append("reference protocol is missing routing_semantics") + elif cur_routing is None: + errors.append("candidate protocol is missing routing_semantics") + elif ref_routing != cur_routing: + errors.append("reference protocol.routing_semantics does not match the candidate") + return errors + + +def validate_reference_metadata(reference: dict, current: dict, *, strict: bool) -> None: + """Validate metadata needed for an auditable cross-backend comparison. + + Legacy metric JSON files containing only a scalar mAP remain usable for a + diagnostic comparison. A requested acceptance gate, however, must carry + the same ordered image manifest, class profile and post-processing + protocol; otherwise a passing delta could compare different experiments. + """ + errors = _protocol_mismatches(reference, current) + if errors and strict: + raise ValueError("; ".join(errors)) + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--preds", type=Path, required=True) - parser.add_argument("--images", type=Path, default=Path("/data/datasets/VisDrone/images/val")) + parser.add_argument( + "--images", type=Path, default=Path("/data/datasets/VisDrone/images/val"), + help="validation image directory or ordered UTF-8 image list", + ) + parser.add_argument( + "--image-root", type=Path, + help="root used to normalize list entries and compute portable image digests", + ) parser.add_argument("--labels", type=Path, default=Path("/data/datasets/VisDrone/labels/val")) parser.add_argument("--classes", choices=tuple(CLASS_TABLES), default="visdrone") parser.add_argument( @@ -298,20 +646,79 @@ def parse_args() -> argparse.Namespace: "requires --reference-json" ), ) - return parser.parse_args() + parser.add_argument( + "--max-abs-delta-pp", + type=nonnegative_finite_float, + default=None, + metavar="POINTS", + help=( + "fail when the absolute mAP50-95 delta exceeds POINTS percentage points " + "(for example, 0.5 for the Issue #51 FP32 budget); requires --reference-json" + ), + ) + # These values are recorded in the result so a prediction directory cannot + # be detached from the post-processing protocol used to produce it. + parser.add_argument("--imgsz", type=int, default=None) + parser.add_argument("--conf", type=nonnegative_finite_float, default=None) + parser.add_argument("--iou", type=nonnegative_finite_float, default=None) + parser.add_argument("--max-det", type=int, default=None) + parser.add_argument( + "--small-conf", type=small_conf_float, default=-1.0, + help="optional lower confidence for boxes below --small-area (-1 disables)", + ) + parser.add_argument( + "--small-area", type=nonnegative_finite_area, default=32.0 * 32.0, + help="original-image area threshold for --small-conf (default: 1024)", + ) + parser.add_argument( + "--multi-label", dest="multi_label", action="store_true", default=True, + help="record one detection per class and anchor (the Issue #51 recipe)", + ) + parser.add_argument( + "--single-label", dest="multi_label", action="store_false", + help="record argmax-per-anchor post-processing (diagnostic only)", + ) + parser.add_argument( + "--routing-semantics", choices=ROUTING_SEMANTICS, default=None, + help=( + "EsMoE inference path: native_sparse, dense_fallback, dense_native, " + "or not_applicable; required for formal runs" + ), + ) + args = parser.parse_args() + defaults = PROFILE_PROTOCOLS[args.classes] + for key in ("imgsz", "conf", "iou", "max_det"): + if getattr(args, key) is None: + setattr(args, key, defaults[key]) + return args def main() -> int: args = parse_args() - validate_delta_budget(args.max_abs_delta_pct, args.reference_json) - validate_smoke_gate(args.smoke, args.max_abs_delta_pct) - if not args.images.is_dir(): - raise FileNotFoundError(f"image directory not found: {args.images}") + validate_delta_budget(args.max_abs_delta_pct, args.reference_json, args.max_abs_delta_pp) + validate_smoke_gate(args.smoke, args.max_abs_delta_pct, args.max_abs_delta_pp) + if not args.images.exists(): + raise FileNotFoundError(f"image source not found: {args.images}") if not args.preds.is_dir(): raise FileNotFoundError(f"prediction directory not found: {args.preds}") - if args.limit < 0 or args.min_images < 1: - raise ValueError("limit must be non-negative and min-images must be positive") + if args.limit < 0 or args.min_images < 1 or args.max_det < 1 or args.imgsz <= 0: + raise ValueError("imgsz/min-images/max-det must be positive (limit may be zero)") + if not np.isfinite([args.conf, args.iou, args.small_conf, args.small_area]).all(): + raise ValueError("conf/iou/small-conf/small-area must be finite") + if (not 0.0 <= args.conf <= 1.0 or not 0.0 <= args.iou <= 1.0 + or not -1.0 <= args.small_conf <= 1.0 or args.small_area < 0.0): + raise ValueError("conf/iou must be in [0, 1], small-conf in [-1, 1], and small-area non-negative") validate_acceptance_image_floor(args) + if not args.smoke and args.routing_semantics is None: + raise ValueError( + "formal Issue #51 evaluation requires --routing-semantics; " + "use dense_fallback for the static export path" + ) + if not args.smoke and args.label_format != "yolo": + raise ValueError( + "formal Issue #51 evaluation requires converted YOLO labels; " + "use --label-format yolo (native VisDrone rows are diagnostic only)" + ) import torch from PIL import Image @@ -320,15 +727,12 @@ def main() -> int: iou_thresholds = torch.linspace(0.5, 0.95, 10) # Match the portable C++ runner's stb decoder so every backend evaluates # the same ordered image set. - images = sorted( - path for path in args.images.rglob("*") - if path.is_file() and path.suffix.lower() in {".jpg", ".jpeg", ".png", ".bmp"} - ) + image_root, images = _resolve_images(args.images, args.image_root) if args.limit > 0: images = images[: args.limit] if not images: raise RuntimeError(f"no validation images found under {args.images}") - stems = [image_path.stem for image_path in images] + stems = [image_path.stem.casefold() for image_path in images] if len(stems) != len(set(stems)): raise RuntimeError("validation image stems are not unique; use a flattened/renamed validation split") if not args.smoke and len(images) < args.min_images: @@ -337,25 +741,49 @@ def main() -> int: "Use --smoke only for a non-acceptance check." ) + # A formal run must account for exactly one label and prediction file per + # image. Ignoring an extra file can hide a stale prediction from a prior + # run and makes the reported image manifest ambiguous. Smoke runs may use + # a subset, so they only require unique stems. + label_files = ( + {} + if args.smoke and not args.labels.exists() + else _txt_stem_map(args.labels, "label") + ) + prediction_files = _txt_stem_map(args.preds, "prediction") + expected_stems = set(stems) + if not args.smoke: + missing_labels = sorted(expected_stems - set(label_files)) + missing_predictions = sorted(expected_stems - set(prediction_files)) + extra_labels = sorted(set(label_files) - expected_stems) + extra_predictions = sorted(set(prediction_files) - expected_stems) + problems = [] + if missing_labels: + problems.append("missing labels: " + ", ".join(missing_labels[:5])) + if missing_predictions: + problems.append("missing predictions: " + ", ".join(missing_predictions[:5])) + if extra_labels: + problems.append("unexpected label stems: " + ", ".join(extra_labels[:5])) + if extra_predictions: + problems.append("unexpected prediction stems: " + ", ".join(extra_predictions[:5])) + if problems: + raise RuntimeError("formal evaluation requires an exact image/file set; " + "; ".join(problems)) + names = CLASS_TABLES[args.classes] metrics = DetMetrics() metrics.names = names for image_index, image_path in enumerate(images): - label_path = args.labels / f"{image_path.stem}.txt" - prediction_path = args.preds / f"{image_path.stem}.txt" - if not args.smoke: - missing = [str(path) for path in (label_path, prediction_path) if not path.is_file()] - if missing: - raise FileNotFoundError( - "acceptance requires one label and prediction file per image; missing: " - + ", ".join(missing) - ) + image_stem = image_path.stem.casefold() + label_path = label_files.get(image_stem, args.labels / f"{image_path.stem}.txt") + prediction_path = prediction_files.get(image_stem, args.preds / f"{image_path.stem}.txt") with Image.open(image_path) as image: width, height = image.size gt_boxes, gt_classes = load_gt( - label_path, width, height, torch, args.label_format, len(names) + label_path, width, height, torch, args.label_format, len(names), strict=not args.smoke + ) + pred_boxes, pred_scores, pred_classes = load_predictions( + prediction_path, torch, len(names), strict=not args.smoke ) - pred_boxes, pred_scores, pred_classes = load_predictions(prediction_path, torch, len(names)) n_pred, n_true = pred_boxes.shape[0], gt_boxes.shape[0] if n_pred and n_true: true_positive = match_predictions( @@ -377,18 +805,38 @@ def main() -> int: }) metrics.process() - manifest_names = [manifest_name(path, args.images) for path in images] + manifest_names = [manifest_name(path, image_root) for path in images] image_manifest = "\n".join(manifest_names) + "\n" map50 = float(metrics.box.map50) map5095 = float(metrics.box.map) if not np.isfinite([map50, map5095]).all(): raise RuntimeError("mAP computation returned NaN or Inf; check labels and predictions") + image_list_sha256 = image_content_manifest(images, image_root) result = { "images": len(images), "classes": len(names), "class_profile": args.classes, "label_format": args.label_format, + "protocol": { + "imgsz": args.imgsz, + "conf": args.conf, + "iou": args.iou, + "max_det": args.max_det, + "multi_label": bool(args.multi_label), + "letterbox": True, + "small_conf": args.small_conf, + "small_area": args.small_area, + "color": "RGB", + "layout": "NCHW", + "routing_semantics": args.routing_semantics, + }, "image_manifest_sha256": hashlib.sha256(image_manifest.encode("utf-8")).hexdigest(), + "image_manifest": manifest_names, + # This is the same ordered ``relative-path + file-SHA256`` digest used + # by evidence_manifest.py. Retain the older key for report readers + # created before the evidence schema was introduced. + "image_list_sha256": image_list_sha256, + "image_content_manifest_sha256": image_list_sha256, "mAP50": map50, "mAP50-95": map5095, } @@ -396,25 +844,58 @@ def main() -> int: if not args.reference_json.is_file(): raise FileNotFoundError(f"reference JSON not found: {args.reference_json}") reference = json.loads(args.reference_json.read_text(encoding="utf-8")) + # A delta gate is an acceptance claim; require the reference run to + # identify the same ordered images, classes and post-processing. A + # legacy scalar-only JSON remains available for smoke diagnostics. + reference_metadata_strict = not args.smoke and ( + args.max_abs_delta_pct is not None or args.max_abs_delta_pp is not None + ) + reference_metadata_errors = _protocol_mismatches(reference, result) + result["reference_metadata_match"] = not reference_metadata_errors + if reference_metadata_errors and not reference_metadata_strict: + result["reference_metadata_warnings"] = reference_metadata_errors + validate_reference_metadata(reference, result, strict=reference_metadata_strict) reference_map = extract_reference_map(reference) - if not np.isfinite(reference_map) or reference_map <= 0: - raise ValueError("reference mAP50-95 must be a finite positive value for a relative delta") + if not np.isfinite(reference_map) or not 0.0 <= reference_map <= 1.0: + raise ValueError("reference mAP50-95 must be finite and in [0, 1]") + if args.max_abs_delta_pct is not None and reference_map <= 0: + raise ValueError("reference mAP50-95 must be positive when applying a relative delta gate") result["reference_mAP50-95"] = reference_map - delta_pct = float((result["mAP50-95"] - reference_map) / reference_map * 100.0) - result["delta_mAP50-95_pct"] = delta_pct - result["abs_delta_mAP50-95_pct"] = abs(delta_pct) - gate_exit_code = apply_delta_gate(result, args.max_abs_delta_pct) + delta_abs = float(result["mAP50-95"] - reference_map) + result["delta_mAP50-95_abs"] = delta_abs + # Absolute percentage points (the terminology used in the Issue #51 + # reports) and relative percent are both retained to prevent ambiguity. + result["delta_mAP50-95_pp"] = delta_abs * 100.0 + result["abs_delta_mAP50-95_pp"] = abs(delta_abs) * 100.0 + if reference_map > 0.0: + delta_pct = float(delta_abs / reference_map * 100.0) + result["delta_mAP50-95_pct"] = delta_pct + result["abs_delta_mAP50-95_pct"] = abs(delta_pct) + else: + # A zero reference is meaningful for an absolute percentage-point + # comparison, but a relative percentage is undefined. + result["delta_mAP50-95_pct"] = None + result["abs_delta_mAP50-95_pct"] = None + gate_exit_code = apply_delta_gate(result, args.max_abs_delta_pct, args.max_abs_delta_pp) print(json.dumps(result, indent=2)) if args.json: args.json.parent.mkdir(parents=True, exist_ok=True) args.json.write_text(json.dumps(result, indent=2), encoding="utf-8") if gate_exit_code: - print( - "mAP50-95 delta gate failed: " - f"observed {result['abs_delta_mAP50-95_pct']:.6g}% > " - f"maximum {args.max_abs_delta_pct:.6g}%", - file=sys.stderr, - ) + if args.max_abs_delta_pp is not None: + print( + "mAP50-95 absolute delta gate failed: " + f"observed {result['abs_delta_mAP50-95_pp']:.6g} pp > " + f"maximum {args.max_abs_delta_pp:.6g} pp", + file=sys.stderr, + ) + else: + print( + "mAP50-95 relative delta gate failed: " + f"observed {result['abs_delta_mAP50-95_pct']:.6g}% > " + f"maximum {args.max_abs_delta_pct:.6g}%", + file=sys.stderr, + ) return gate_exit_code diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/eval_map_standalone.py b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/eval_map_standalone.py index 0b2377689..4fea4423d 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/eval_map_standalone.py +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/eval_map_standalone.py @@ -1,15 +1,51 @@ #!/usr/bin/env python3 """Standalone mAP50 / mAP50-95 — numpy only (no ultralytics/torch/cv2/PIL required). -Scores per-image prediction txts ('class conf x1 y1 x2 y2', pixel xyxy) against VisDrone -YOLO-format labels, replicating ultralytics' matching + AP (so numbers match eval_map.py). -Runs on-device (e.g. Jetson) where preds+labels+images already live. +Scores per-image prediction txts ('class conf x1 y1 x2 y2', pixel xyxy) against +YOLO-format labels, replicating the matching + AP integration used by +``eval_map.py``. The macro average uses a fixed class profile (VisDrone's ten +classes by default), including classes absent from a particular split. Runs +on-device (e.g. Jetson) where preds+labels+images already live. python3 eval_map_standalone.py --preds preds_fp16 --images images/val --labels labels/val + python3 eval_map_standalone.py --preds preds --images images/val --labels labels/val \ + --profile sku110k --nc 1 --smoke """ -import argparse, glob, os, struct +import argparse, hashlib, json, os, struct import numpy as np +IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp"} +CLASS_PROFILES = {"visdrone": 10, "sku110k": 1} +ROUTING_SEMANTICS = ("native_sparse", "dense_fallback", "dense_native", "not_applicable") +PROFILE_PROTOCOLS = { + "visdrone": { + "imgsz": 640, + "conf": 0.001, + "iou": 0.70, + "max_det": 300, + "multi_label": True, + "letterbox": True, + "small_conf": -1.0, + "small_area": 32.0 * 32.0, + "color": "RGB", + "layout": "NCHW", + }, + "sku110k": { + "imgsz": 1280, + "conf": 0.25, + "iou": 0.60, + "max_det": 300, + "multi_label": True, + "letterbox": True, + "small_conf": -1.0, + "small_area": 32.0 * 32.0, + "color": "RGB", + "layout": "NCHW", + }, +} +# Backward-compatible alias for callers that imported the VisDrone defaults. +PROTOCOL = PROFILE_PROTOCOLS["visdrone"] + _trapz = getattr(np, "trapezoid", None) or np.trapz # numpy>=2.0 renamed trapz->trapezoid @@ -34,42 +70,397 @@ def jpeg_size(path): f.seek(seg_len - 2, 1) -def load_gt(path, w, h): +def image_size(path): + """Read dimensions for the formats accepted by the portable runner.""" + suffix = os.path.splitext(path)[1].lower() + if suffix in (".jpg", ".jpeg"): + width, height = jpeg_size(path) + if width <= 0 or height <= 0: + raise ValueError("image dimensions must be positive: {}".format(path)) + return width, height + with open(path, "rb") as f: + header = f.read(32) + if suffix == ".png" and header[:8] == b"\x89PNG\r\n\x1a\n" and len(header) >= 24: + w, h = struct.unpack(">II", header[16:24]) + if w <= 0 or h <= 0: + raise ValueError("image dimensions must be positive: {}".format(path)) + return int(w), int(h) + if suffix == ".bmp" and header[:2] == b"BM" and len(header) >= 26: + w, h = struct.unpack("= num_classes: + raise ValueError("{}:{}: {} class id {} outside [0, {})".format( + path, line_no, kind, cls, num_classes)) + return cls + + +def _resolve_num_classes(num_classes=None, nc=None): + if nc is not None: + if num_classes is not None and int(num_classes) != int(nc): + raise ValueError("num_classes and nc disagree") + num_classes = nc + if num_classes is None: + num_classes = CLASS_PROFILES["visdrone"] + try: + numeric = float(num_classes) + if not np.isfinite(numeric) or numeric != np.floor(numeric): + raise ValueError + num_classes = int(numeric) + except (TypeError, ValueError) as exc: + raise ValueError("num_classes must be a positive integer") from exc + if num_classes < 1: + raise ValueError("num_classes must be a positive integer") + return num_classes + + +def _prediction_files(directory, kind): + """Index per-image text files by case-folded stem and reject duplicates.""" + if not os.path.isdir(directory): + raise ValueError("{} directory not found: {}".format(kind, directory)) + result = {} + paths = sorted( + (os.path.join(root, name) + for root, _, names in os.walk(directory) + for name in names if name.lower().endswith(".txt")), + key=lambda path: path.replace(os.sep, "/").casefold(), + ) + for path in paths: + stem = os.path.splitext(os.path.basename(path))[0].casefold() + if not stem: + raise ValueError("{} file has an empty stem: {}".format(kind, path)) + if stem in result: + raise ValueError("{} stems are not unique: {}".format(kind, stem)) + result[stem] = path + return result + + +def _resolve_images(source, root=None): + """Resolve images below one root used for portable evidence paths.""" + source = os.path.expanduser(os.fspath(source)) + explicit_root = None if root is None else os.path.realpath(os.path.expanduser(os.fspath(root))) + if explicit_root is not None and not os.path.isdir(explicit_root): + raise ValueError("image normalization root not found: {}".format(explicit_root)) + + def finish(default_root, paths): + image_root = explicit_root or os.path.realpath(default_root) + resolved_paths = [os.path.realpath(path) for path in paths] + for path in resolved_paths: + try: + inside = os.path.commonpath([path, image_root]) == image_root + except ValueError: + inside = False + if not inside: + raise ValueError( + "image {} is outside evaluation root {}; pass --image-root " + "containing every listed image".format(path, image_root) + ) + return image_root, resolved_paths + + if os.path.isdir(source): + paths = sorted( + ( + os.path.join(root, name) + for root, _, names in os.walk(source) + for name in names if os.path.splitext(name)[1].lower() in IMAGE_EXTS + ), + key=lambda path: path.replace(os.sep, "/").casefold(), + ) + return finish(source, paths) + if not os.path.isfile(source): + raise ValueError("image source not found: {}".format(source)) + base = os.path.dirname(os.path.abspath(source)) + paths = [] + try: + with open(source, encoding="utf-8") as handle: + rows = list(enumerate(handle, 1)) + except (OSError, UnicodeDecodeError) as exc: + raise ValueError("unable to read image list {}: {}".format(source, exc)) from exc + for line_no, raw in rows: + # Match the C++ runner and the Ultralytics evaluator: tolerate a BOM + # from Windows text editors and preserve quoted paths with spaces. + line = raw.strip() + if line_no == 1: + line = line.lstrip("\ufeff") + if not line or line.startswith("#"): + continue + if len(line) >= 2 and line[0] == line[-1] and line[0] in {'"', "'"}: + line = line[1:-1].strip() + path = os.path.expanduser(line) + if not os.path.isabs(path): + path = os.path.join(base, path) + path = os.path.abspath(path) + if (not os.path.isfile(path) + or os.path.splitext(path)[1].lower() not in IMAGE_EXTS): + raise ValueError("{}:{}: unsupported or missing image: {}".format(source, line_no, line)) + paths.append(path) + if not paths: + raise ValueError("image list is empty: {}".format(source)) + return finish(base, paths) + + +def _image_manifest(images, root): + """Return the canonical ordered image names and their SHA256 digest.""" + root = os.path.realpath(root) + names = [os.path.relpath(os.path.realpath(path), root).replace(os.sep, "/") for path in images] + payload = "\n".join(names) + "\n" + return hashlib.sha256(payload.encode("utf-8")).hexdigest(), names + + +def _sha256_file(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _image_content_manifest(images, root, names=None): + """Digest the ordered image names and bytes used by the metric run.""" + if names is None: + names = _image_manifest(images, root)[1] + payload = "\n".join( + "{} {}".format(name, _sha256_file(path)) + for path, name in zip(images, names) + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _reference_map(payload): + """Extract mAP50-95 from common Ultralytics result JSON layouts.""" + if not isinstance(payload, dict): + raise ValueError("reference JSON must contain an object") + mappings = [payload] + for key in ("metrics", "results", "results_dict", "metrics_dict"): + value = payload.get(key) + if isinstance(value, dict): + mappings.append(value) + keys = {"map50-95", "map50-95(b)", "metrics/map50-95", "metrics/map50-95(b)"} + for mapping in mappings: + for key, value in mapping.items(): + if str(key).lower().replace(" ", "") in keys: + try: + return float(value) + except (TypeError, ValueError) as exc: + raise ValueError("reference mAP50-95 must be numeric") from exc + raise ValueError("reference JSON must contain mAP50-95") + + +def _reference_metadata_errors( + reference, image_manifest_sha256, image_content_manifest_sha256, + image_count, profile, num_classes, protocol, label_format="yolo" +): + """Return mismatches that would invalidate a cross-backend comparison.""" + if not isinstance(reference, dict): + return ["reference JSON must contain an object"] + errors = [] + if reference.get("image_manifest_sha256") is None: + errors.append("reference JSON is missing image_manifest_sha256") + elif str(reference["image_manifest_sha256"]).lower() != image_manifest_sha256.lower(): + errors.append("reference image_manifest_sha256 does not match the candidate image list") + reference_content = reference.get("image_list_sha256") or reference.get("image_content_manifest_sha256") + if reference_content is None: + errors.append("reference JSON is missing image_list_sha256") + elif str(reference_content).lower() != str(image_content_manifest_sha256).lower(): + errors.append("reference image_list_sha256 does not match the candidate images") + if reference.get("class_profile") is None: + errors.append("reference JSON is missing class_profile") + elif reference.get("class_profile") != profile: + errors.append("reference class_profile does not match the candidate") + try: + if int(reference.get("classes")) != int(num_classes): + errors.append("reference class count does not match the candidate") + except (TypeError, ValueError): + errors.append("reference JSON is missing a valid classes field") + try: + if int(reference.get("images")) != int(image_count): + errors.append("reference image count does not match the candidate") + except (TypeError, ValueError): + errors.append("reference JSON is missing a valid images field") + if reference.get("label_format") is not None and reference.get("label_format") != label_format: + errors.append("reference label_format does not match the candidate") + ref_protocol = reference.get("protocol") + if not isinstance(ref_protocol, dict): + errors.append("reference JSON is missing protocol metadata") + else: + for key in ("imgsz", "max_det", "multi_label", "letterbox", "color", "layout"): + if key not in ref_protocol: + errors.append("reference protocol is missing {}".format(key)) + elif ref_protocol[key] != protocol.get(key): + errors.append("reference protocol.{} does not match the candidate".format(key)) + for key in ("conf", "iou", "small_conf", "small_area"): + if key not in ref_protocol: + errors.append("reference protocol is missing {}".format(key)) continue - c = int(v[5]) - 1 - if c < 0 or c > 9: + try: + if abs(float(ref_protocol[key]) - float(protocol.get(key))) > 1e-9: + errors.append("reference protocol.{} does not match the candidate".format(key)) + except (TypeError, ValueError): + errors.append("reference protocol.{} is not numeric".format(key)) + ref_routing = ref_protocol.get("routing_semantics") + cur_routing = protocol.get("routing_semantics") + if ref_routing is not None or cur_routing is not None: + if ref_routing is None: + errors.append("reference protocol is missing routing_semantics") + elif cur_routing is None: + errors.append("candidate protocol is missing routing_semantics") + elif ref_routing != cur_routing: + errors.append("reference protocol.routing_semantics does not match the candidate") + return errors + + +def load_gt(path, w, h, label_format="yolo", num_classes=None, nc=None): + """Load YOLO or native VisDrone labels with strict, contextual validation. + + Missing and empty files represent an image without annotations and return + empty arrays. Existing malformed rows raise ``ValueError`` naming the + source path and one-based line number; silently dropping a bad annotation + would make an acceptance metric irreproducible. + """ + if not np.isfinite([w, h]).all() or float(w) <= 0 or float(h) <= 0: + raise ValueError("image dimensions must be finite and positive") + if label_format not in ("yolo", "visdrone", "auto"): + raise ValueError("label_format must be yolo, visdrone, or auto") + num_classes = _resolve_num_classes(num_classes, nc) + rows = _read_label_lines(path) + if not rows: + return _empty_gt() + + first_line = rows[0][1] + # Native VisDrone annotations are commonly comma-separated, but several + # conversion pipelines emit the same eight fields separated by spaces. + # In auto mode, distinguish that form from the five-column normalized YOLO + # dialect without weakening the strict row validation below. + first_fields = first_line.split() + use_visdrone = label_format == "visdrone" or ( + label_format == "auto" and ("," in first_line or len(first_fields) == 8) + ) + visdrone_comma = "," in first_line + boxes, classes = [], [] + for line_no, line in rows: + if use_visdrone: + if visdrone_comma: + if "," not in line: + raise ValueError( + "{}:{}: mixed label formats; expected comma-separated VisDrone row".format( + path, line_no)) + fields = [field.strip() for field in line.split(",")] + else: + if "," in line: + raise ValueError( + "{}:{}: mixed label formats; expected whitespace-separated VisDrone row".format( + path, line_no)) + fields = line.split() + values = _parse_numeric(fields, path, line_no, 8, "VisDrone ground truth") + x, y, bw, bh, score, category = values[:6] + if bw <= 0 or bh <= 0: + raise ValueError("{}:{}: VisDrone ground-truth width and height must be positive".format( + path, line_no)) + if x < 0 or y < 0: + raise ValueError("{}:{}: VisDrone ground-truth x/y must be non-negative".format( + path, line_no)) + if not 0.0 <= score <= 1.0: + raise ValueError("{}:{}: VisDrone score must be in [0, 1]".format(path, line_no)) + if category != np.floor(category): + raise ValueError("{}:{}: VisDrone category must be an integer".format(path, line_no)) + category = int(category) + if category < 0 or category > 11: + raise ValueError("{}:{}: VisDrone category {} outside 0..11".format( + path, line_no, category)) + # Category 0 (ignored regions), category 11 ('others'), and score==0 + # are ignored by visdrone2yolo. + if score == 0 or category in (0, 11): continue - x, y, bw, bh = float(v[0]), float(v[1]), float(v[2]), float(v[3]) - boxes.append([x, y, x + bw, y + bh]); cls.append(c) - return (np.array(boxes, float).reshape(-1, 4), np.array(cls, int)) - # YOLO: class cx cy w h (normalized) - a = np.array([l.split() for l in lines], float) - cls = a[:, 0].astype(int) - cx, cy, bw, bh = a[:, 1] * w, a[:, 2] * h, a[:, 3] * w, a[:, 4] * h - xyxy = np.stack([cx - bw / 2, cy - bh / 2, cx + bw / 2, cy + bh / 2], 1) - return xyxy, cls - - -def load_pred(path): - if not os.path.exists(path): - return np.zeros((0, 4)), np.zeros((0,)), np.zeros((0,), int) - rows = [r.split() for r in open(path).read().splitlines() if r.strip()] + cls = category - 1 + if cls >= num_classes: + raise ValueError("{}:{}: VisDrone class {} outside [0, {})".format( + path, line_no, cls, num_classes)) + boxes.append([x, y, x + bw, y + bh]) + classes.append(cls) + else: + if "," in line: + raise ValueError("{}:{}: mixed label formats; expected whitespace-separated YOLO row".format( + path, line_no)) + values = _parse_numeric(line.split(), path, line_no, 5, "YOLO ground truth") + cls = _class_id(values[0], path, line_no, num_classes, "YOLO ground-truth") + cx, cy, bw, bh = values[1:5] + if not (0.0 <= cx <= 1.0 and 0.0 <= cy <= 1.0): + raise ValueError("{}:{}: YOLO ground-truth center must be in [0, 1]".format( + path, line_no)) + if not (0.0 < bw <= 1.0 and 0.0 < bh <= 1.0): + raise ValueError("{}:{}: YOLO ground-truth width and height must be in (0, 1]".format( + path, line_no)) + cx, cy, bw, bh = cx * w, cy * h, bw * w, bh * h + boxes.append([cx - bw / 2, cy - bh / 2, cx + bw / 2, cy + bh / 2]) + classes.append(cls) + return np.asarray(boxes, dtype=float).reshape(-1, 4), np.asarray(classes, dtype=int) + + +def load_pred(path, num_classes=None, nc=None): + """Load ``class conf x1 y1 x2 y2`` predictions with strict validation.""" + num_classes = _resolve_num_classes(num_classes, nc) + rows = _read_label_lines(path) if not rows: - return np.zeros((0, 4)), np.zeros((0,)), np.zeros((0,), int) - a = np.array(rows, float) - return a[:, 2:6], a[:, 1], a[:, 0].astype(int) # xyxy, conf, cls + return _empty_pred() + boxes, scores, classes = [], [], [] + for line_no, line in rows: + values = _parse_numeric(line.split(), path, line_no, 6, "prediction") + cls = _class_id(values[0], path, line_no, num_classes, "prediction") + score = values[1] + if not 0.0 <= score <= 1.0: + raise ValueError("{}:{}: prediction confidence must be in [0, 1]".format(path, line_no)) + x1, y1, x2, y2 = values[2:6] + if x2 <= x1 or y2 <= y1: + raise ValueError("{}:{}: prediction box must have x2>x1 and y2>y1".format(path, line_no)) + boxes.append([x1, y1, x2, y2]); scores.append(score); classes.append(cls) + return (np.asarray(boxes, dtype=float).reshape(-1, 4), + np.asarray(scores, dtype=float), np.asarray(classes, dtype=int)) def box_iou(a, b): # (N,4),(M,4) -> (N,M) @@ -108,22 +499,55 @@ def compute_ap(recall, precision): return _trapz(np.interp(x, mrec, mpre), x) -def ap_per_class(tp, conf, pred_cls, target_cls): - i = np.argsort(-conf) - tp, pred_cls = tp[i], pred_cls[i] - classes = np.unique(target_cls) - ap = np.zeros((len(classes), tp.shape[1])) - for ci, c in enumerate(classes): - m = pred_cls == c +def ap_per_class(tp, conf, pred_cls, target_cls, num_classes=None, nc=None): + """Compute AP for every class in the declared profile. + + ``np.unique(target_cls)`` is deliberately not used to size the result: + classes that have no ground truth (or no predictions) still contribute a + zero AP to the macro average. This keeps the standalone evaluator's mAP + definition fixed and comparable across splits. + """ + num_classes = _resolve_num_classes(num_classes, nc) + tp = np.asarray(tp, dtype=bool) + conf = np.asarray(conf, dtype=float).reshape(-1) + pred_cls = np.asarray(pred_cls).reshape(-1) + target_cls = np.asarray(target_cls).reshape(-1) + if tp.ndim != 2 or tp.shape[1] != len(IOUV): + raise ValueError("tp must have shape (N, {})".format(len(IOUV))) + if tp.shape[0] != conf.size or tp.shape[0] != pred_cls.size: + raise ValueError("tp, conf, and pred_cls must contain the same number of predictions") + if not np.isfinite(conf).all(): + raise ValueError("prediction confidences must be finite") + for values, name in ((pred_cls, "prediction"), (target_cls, "target")): + if values.size: + try: + numeric = values.astype(float) + except (TypeError, ValueError) as exc: + raise ValueError("{} class ids must be numeric".format(name)) from exc + if not np.isfinite(numeric).all() or not np.equal(numeric, np.floor(numeric)).all(): + raise ValueError("{} class ids must be finite integers".format(name)) + if np.any((numeric < 0) | (numeric >= num_classes)): + raise ValueError("{} class ids outside [0, {})".format(name, num_classes)) + pred_cls = pred_cls.astype(int, copy=False) + target_cls = target_cls.astype(int, copy=False) + order = np.argsort(-conf, kind="stable") + tp, pred_cls = tp[order], pred_cls[order] + ap = np.zeros((num_classes, tp.shape[1]), dtype=float) + for c in range(num_classes): + mask = pred_cls == c n_gt = int((target_cls == c).sum()) - if m.sum() == 0 or n_gt == 0: + # A class without GT has AP=0 by the fixed-profile macro definition. + if not mask.any() or n_gt == 0: continue - fpc = (1 - tp[m]).cumsum(0) - tpc = tp[m].cumsum(0) + fpc = (1 - tp[mask]).cumsum(axis=0) + tpc = tp[mask].cumsum(axis=0) recall = tpc / (n_gt + 1e-16) + # Every selected prediction contributes either a TP or FP, so this + # denominator is strictly positive. Avoid an epsilon here: adding one + # would bias a perfect one-prediction class to AP=0.995 instead of 1.0. precision = tpc / (tpc + fpc) for j in range(tp.shape[1]): - ap[ci, j] = compute_ap(recall[:, j], precision[:, j]) + ap[c, j] = compute_ap(recall[:, j], precision[:, j]) return ap @@ -131,26 +555,247 @@ def main(): ap = argparse.ArgumentParser() ap.add_argument("--preds", required=True) ap.add_argument("--images", default="images/val") + ap.add_argument("--image-root", help="root used to normalize list entries and compute portable image digests") ap.add_argument("--labels", default="labels/val") + ap.add_argument("--profile", "--classes", dest="profile", choices=tuple(CLASS_PROFILES), + default="visdrone", help="class profile (default: visdrone)") + ap.add_argument("--nc", type=int, default=None, + help="number of classes; overrides the selected profile") + ap.add_argument("--label-format", choices=("yolo", "visdrone", "auto"), default="yolo") + ap.add_argument("--imgsz", type=int, default=None, + help="square model input; defaults to the selected profile") + ap.add_argument("--conf", type=float, default=None, + help="global confidence threshold recorded for the run") + ap.add_argument("--iou", type=float, default=None, + help="NMS IoU threshold recorded for the run") + ap.add_argument("--max-det", type=int, default=None, + help="maximum detections per image; defaults to the selected profile") + ap.add_argument( + "--multi-label", dest="multi_label", action="store_true", default=None, + help="record multi-label decoding (the Issue #51 recipe)", + ) + ap.add_argument( + "--single-label", dest="multi_label", action="store_false", + help="record argmax-per-anchor decoding (diagnostic only)", + ) + ap.add_argument( + "--small-conf", type=float, default=-1.0, + help="optional lower confidence for boxes below --small-area (-1 disables)", + ) + ap.add_argument( + "--small-area", type=float, default=32.0 * 32.0, + help="original-image area threshold for --small-conf (default: 1024)", + ) + ap.add_argument("--min-images", type=int, default=500) + ap.add_argument("--smoke", action="store_true", help="allow a smaller diagnostic subset") + ap.add_argument("--limit", type=int, default=0) + ap.add_argument("--json", help="write a machine-readable result JSON") + ap.add_argument("--reference-json", help="PyTorch JSON containing mAP50-95") + ap.add_argument( + "--max-abs-delta-pct", type=float, default=None, + help="absolute relative mAP50-95 gate in percent (requires --reference-json)", + ) + ap.add_argument("--max-abs-delta-pp", type=float, default=None, + help="absolute mAP50-95 gate in percentage points") + ap.add_argument( + "--routing-semantics", choices=ROUTING_SEMANTICS, default=None, + help=( + "EsMoE inference path; required for formal runs (use dense_fallback " + "for static exports)" + ), + ) a = ap.parse_args() - imgs = sorted(glob.glob(os.path.join(a.images, "*.jpg"))) + if a.min_images < 1 or a.limit < 0: + ap.error("--min-images must be positive and --limit must be non-negative") + if not a.smoke and a.min_images < 500: + ap.error("Issue #51 acceptance requires --min-images >= 500; use --smoke for a smaller diagnostic run") + if a.nc is not None and a.nc < 1: + ap.error("--nc must be a positive integer") + profile_protocol = PROFILE_PROTOCOLS[a.profile] + protocol = dict(profile_protocol) + for key in ("imgsz", "conf", "iou", "max_det", "multi_label"): + value = getattr(a, key) + if value is not None: + protocol[key] = value + if (protocol["imgsz"] <= 0 or protocol["max_det"] <= 0 + or not np.isfinite([protocol["conf"], protocol["iou"]]).all() + or not 0.0 <= protocol["conf"] <= 1.0 + or not 0.0 <= protocol["iou"] <= 1.0): + ap.error("imgsz/max-det must be positive and conf/iou must be finite in [0, 1]") + if (not np.isfinite([a.small_conf, a.small_area]).all() + or not -1.0 <= a.small_conf <= 1.0 or a.small_area < 0.0): + ap.error("--small-conf must be finite in [-1, 1] and --small-area must be finite and non-negative") + if a.max_abs_delta_pct is not None and a.reference_json is None: + ap.error("--max-abs-delta-pct requires --reference-json") + if a.max_abs_delta_pct is not None and ( + not np.isfinite(a.max_abs_delta_pct) or a.max_abs_delta_pct < 0): + ap.error("--max-abs-delta-pct must be a finite non-negative number") + if a.max_abs_delta_pp is not None and a.reference_json is None: + ap.error("--max-abs-delta-pp requires --reference-json") + if a.max_abs_delta_pp is not None and ( + not np.isfinite(a.max_abs_delta_pp) or a.max_abs_delta_pp < 0): + ap.error("--max-abs-delta-pp must be a finite non-negative number") + if a.max_abs_delta_pct is not None and a.max_abs_delta_pp is not None: + ap.error("choose either --max-abs-delta-pct or --max-abs-delta-pp") + if a.smoke and (a.max_abs_delta_pct is not None or a.max_abs_delta_pp is not None): + ap.error("mAP delta gates cannot be used with --smoke") + if not a.smoke and a.label_format != "yolo": + ap.error("formal acceptance requires converted YOLO labels; use --label-format yolo") + if not a.smoke and a.routing_semantics is None: + ap.error( + "formal Issue #51 evaluation requires --routing-semantics; " + "use dense_fallback for the static export path" + ) + num_classes = a.nc if a.nc is not None else CLASS_PROFILES[a.profile] + image_root, imgs = _resolve_images(a.images, a.image_root) + if a.limit: + imgs = imgs[:a.limit] + if not imgs: + ap.error("no validation images found") + stems = [os.path.splitext(os.path.basename(p))[0].casefold() for p in imgs] + if len(stems) != len(set(stems)): + ap.error("validation image stems are not unique") + if not a.smoke and len(imgs) < a.min_images: + ap.error("Issue #51 acceptance requires at least {} images (found {})".format(a.min_images, len(imgs))) + try: + label_files = _prediction_files(a.labels, "label") if os.path.isdir(a.labels) else {} + prediction_files = _prediction_files(a.preds, "prediction") + except ValueError as exc: + ap.error(str(exc)) + expected_stems = {os.path.splitext(os.path.basename(path))[0].casefold() for path in imgs} + if not a.smoke: + missing_labels = sorted(expected_stems - set(label_files)) + missing_predictions = sorted(expected_stems - set(prediction_files)) + extra_labels = sorted(set(label_files) - expected_stems) + extra_predictions = sorted(set(prediction_files) - expected_stems) + problems = [] + if missing_labels: + problems.append("missing labels: " + ", ".join(missing_labels[:5])) + if missing_predictions: + problems.append("missing predictions: " + ", ".join(missing_predictions[:5])) + if extra_labels: + problems.append("unexpected label stems: " + ", ".join(extra_labels[:5])) + if extra_predictions: + problems.append("unexpected prediction stems: " + ", ".join(extra_predictions[:5])) + if problems: + ap.error("formal evaluation requires an exact image/file set; " + "; ".join(problems)) all_tp, all_conf, all_pcls, all_tcls = [], [], [], [] for p in imgs: stem = os.path.splitext(os.path.basename(p))[0] - w, h = jpeg_size(p) - gtb, gtc = load_gt(os.path.join(a.labels, stem + ".txt"), w, h) - pb, ps, pc = load_pred(os.path.join(a.preds, stem + ".txt")) + stem_key = stem.casefold() + if not a.smoke: + for required in (label_files[stem_key], prediction_files[stem_key]): + if not os.path.isfile(required): + ap.error("missing per-image file: {}".format(required)) + try: + w, h = image_size(p) + gtb, gtc = load_gt( + label_files.get(stem_key, os.path.join(a.labels, stem + ".txt")), w, h, a.label_format, + num_classes=num_classes, + ) + pb, ps, pc = load_pred( + prediction_files.get(stem_key, os.path.join(a.preds, stem + ".txt")), num_classes=num_classes, + ) + except (ValueError, OSError, struct.error, IndexError) as exc: + ap.error(str(exc)) all_tcls.append(gtc) if pb.shape[0] == 0: continue tp = (match(pc, gtc, box_iou(pb, gtb)) if gtb.shape[0] - else np.zeros((pb.shape[0], 10), bool)) + else np.zeros((pb.shape[0], len(IOUV)), bool)) all_tp.append(tp); all_conf.append(ps); all_pcls.append(pc) - tp = np.concatenate(all_tp); conf = np.concatenate(all_conf) - pcls = np.concatenate(all_pcls); tcls = np.concatenate(all_tcls) - APc = ap_per_class(tp, conf, pcls, tcls) - print(f"images={len(imgs)} mAP50={APc[:,0].mean():.4f} mAP50-95={APc.mean():.4f}") + tcls = np.concatenate(all_tcls) if all_tcls and any(x.size for x in all_tcls) else np.zeros((0,), int) + if all_tp: + tp = np.concatenate(all_tp) + conf = np.concatenate(all_conf) + pcls = np.concatenate(all_pcls) + else: + tp = np.zeros((0, len(IOUV)), bool); conf = np.zeros((0,)); pcls = np.zeros((0,), int) + APc = ap_per_class(tp, conf, pcls, tcls, num_classes=num_classes) + map50, map5095 = float(APc[:, 0].mean()), float(APc.mean()) + if not np.isfinite([map50, map5095]).all(): + ap.error("mAP computation returned NaN or Inf; check labels and predictions") + image_manifest_sha256, image_manifest_names = _image_manifest(imgs, image_root) + image_content_manifest_sha256 = _image_content_manifest( + imgs, image_root, image_manifest_names + ) + result = { + "images": len(imgs), "classes": num_classes, "class_profile": a.profile, + "mAP50": map50, "mAP50-95": map5095, + "label_format": a.label_format, + "image_manifest_sha256": image_manifest_sha256, + "image_manifest": image_manifest_names, + "image_list_sha256": image_content_manifest_sha256, + "image_content_manifest_sha256": image_content_manifest_sha256, + "protocol": dict( + protocol, + classes=num_classes, + small_conf=a.small_conf, + small_area=a.small_area, + routing_semantics=a.routing_semantics, + ), + } + exit_code = 0 + if a.reference_json: + try: + with open(a.reference_json, encoding="utf-8") as handle: + payload = json.load(handle) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + ap.error("unable to read reference JSON: {}".format(exc)) + metadata_errors = _reference_metadata_errors( + payload, image_manifest_sha256, image_content_manifest_sha256, + len(imgs), a.profile, num_classes, result["protocol"], a.label_format + ) + result["reference_metadata_match"] = not metadata_errors + if metadata_errors and ( + a.smoke or (a.max_abs_delta_pct is None and a.max_abs_delta_pp is None)): + result["reference_metadata_warnings"] = metadata_errors + if metadata_errors and not a.smoke and ( + a.max_abs_delta_pct is not None or a.max_abs_delta_pp is not None): + ap.error("; ".join(metadata_errors)) + try: + reference = _reference_map(payload) + except (TypeError, ValueError) as exc: + ap.error(str(exc)) + if not np.isfinite(reference) or not 0.0 <= reference <= 1.0: + ap.error("reference mAP50-95 must be finite and in [0, 1]") + if a.max_abs_delta_pct is not None and reference <= 0.0: + ap.error("reference mAP50-95 must be positive when applying a relative delta gate") + delta_pp = (map5095 - reference) * 100.0 + delta_abs = map5095 - reference + result.update({ + "reference_mAP50-95": reference, + "delta_mAP50-95_abs": delta_abs, + "delta_mAP50-95_pp": delta_pp, + "abs_delta_mAP50-95_pp": abs(delta_pp), + }) + if reference > 0.0: + delta_pct = delta_abs / reference * 100.0 + result.update({ + "delta_mAP50-95_pct": delta_pct, + "abs_delta_mAP50-95_pct": abs(delta_pct), + }) + else: + result.update({"delta_mAP50-95_pct": None, "abs_delta_mAP50-95_pct": None}) + if a.max_abs_delta_pct is not None: + result["max_abs_delta_mAP50-95_pct"] = a.max_abs_delta_pct + result["mAP50-95_relative_delta_gate_passed"] = ( + abs(result["delta_mAP50-95_pct"]) <= a.max_abs_delta_pct + ) + exit_code = 0 if result["mAP50-95_relative_delta_gate_passed"] else 2 + if a.max_abs_delta_pp is not None: + result["max_abs_delta_mAP50-95_pp"] = a.max_abs_delta_pp + result["mAP50-95_absolute_delta_gate_passed"] = abs(delta_pp) <= a.max_abs_delta_pp + exit_code = 0 if result["mAP50-95_absolute_delta_gate_passed"] else 2 + if a.max_abs_delta_pct is not None or a.max_abs_delta_pp is not None: + result["mAP50-95_delta_gate_passed"] = exit_code == 0 + print(json.dumps(result, indent=2)) + if a.json: + with open(a.json, "w", encoding="utf-8") as handle: + json.dump(result, handle, indent=2) + handle.write("\n") + return exit_code if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/evidence_manifest.py b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/evidence_manifest.py new file mode 100644 index 000000000..26554d6ec --- /dev/null +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/evidence_manifest.py @@ -0,0 +1,1114 @@ +#!/usr/bin/env python3 +"""Create and validate a reproducible Issue #51 evidence manifest. + +The manifest is intentionally independent of Ultralytics and of optional edge +runtime SDKs. It records the ordered image set, hashes of the inputs and model +artifacts, the exact post-processing protocol, and the acceptance gates. This +makes a result bundle auditable without placing large models or datasets in Git. + +Typical use:: + + python scripts/evidence_manifest.py create \ + --dataset visdrone --split val --images /data/VisDrone/images/val \ + --labels /data/VisDrone/labels/val --predictions artifacts/onnx_txt \ + --model onnx=artifacts/model.onnx --checkpoint runs/best.pt \ + --training-metadata artifacts/training-provenance.json \ + --acceptance --output artifacts/onnx-evidence.json + + python scripts/evidence_manifest.py validate artifacts/onnx-evidence.json \ + --acceptance + +The ``--template`` mode is useful before a target machine and its data are +available. A template is explicitly marked ``status=template`` and cannot be +mistaken for an acceptance result. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import os +import platform +import re +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + + +# Keep the manifest image universe identical to the portable C++ runner and +# both mAP evaluators. TIFF files are intentionally excluded because stb's +# decoder used by the runner does not support them. +IMAGE_EXTENSIONS = frozenset({".jpg", ".jpeg", ".png", ".bmp"}) +SCHEMA_VERSION = "issue51-evidence/v1" +_SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$") +_MODEL_FORMAT_RE = re.compile(r"^(onnx|ncnn|mnn)(?:$|[_.-])", re.IGNORECASE) +ROUTING_SEMANTICS = ("native_sparse", "dense_fallback", "dense_native", "not_applicable") + + +def sha256_file(path: Path) -> str: + """Return the SHA256 digest of *path* without loading it into memory.""" + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _is_image(path: Path) -> bool: + return path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS + + +def _normalise_path(path: Path, base: Path) -> str: + """Return a stable, mount-independent path relative to *base*. + + Evidence records are later joined to a caller-provided verification root. + Refuse files outside that root instead of emitting ``../`` paths that would + be non-portable and could escape the verifier's sandbox. + """ + resolved_path = path.resolve() + resolved_base = base.resolve() + try: + relative = resolved_path.relative_to(resolved_base) + except ValueError as exc: + raise ValueError( + "{} is outside evidence root {}; pass an explicit --image-root or " + "--calibration-root when using an image list".format(path, base) + ) from exc + value = relative.as_posix() + if _safe_relative_path(value) is None: + raise ValueError("unsafe relative evidence path: {}".format(value)) + return value + + +def resolve_image_list(source: Path, root: Optional[Path] = None) -> Tuple[Path, List[Path]]: + """Resolve a directory, image, or newline-delimited image list. + + List entries are resolved relative to the list file, while directory walks + are recursive and sorted by their POSIX spelling. ``root`` can be used to + select an explicit normalization root when a list file lives outside the + dataset directory. Every selected file must remain below that root. + """ + source = source.expanduser() + explicit_root = root.expanduser().resolve() if root is not None else None + if explicit_root is not None and not explicit_root.is_dir(): + raise NotADirectoryError("image normalization root not found: {}".format(explicit_root)) + + def finish(base: Path, paths: List[Path]) -> Tuple[Path, List[Path]]: + base = (explicit_root or base).resolve() + resolved_paths = [path.resolve() for path in paths] + for path in resolved_paths: + try: + path.relative_to(base) + except ValueError as exc: + raise ValueError( + "image {} is outside evidence root {}; pass a root containing " + "every listed image".format(path, base) + ) from exc + return base, resolved_paths + + if source.is_dir(): + paths = sorted( + (p for p in source.rglob("*") if _is_image(p)), + key=lambda p: p.as_posix().casefold(), + ) + return finish(source, paths) + if _is_image(source): + return finish(source.parent, [source]) + if not source.is_file(): + raise FileNotFoundError("image source not found: {}".format(source)) + paths: List[Path] = [] + for line_number, raw in enumerate(source.read_text(encoding="utf-8").splitlines(), 1): + # Keep list semantics identical across the manifest tool, evaluators + # and C++ runner. BOMs are common in lists written on Windows; a + # surrounding quote pair permits paths containing spaces. + line = raw.strip() + if line_number == 1: + line = line.lstrip("\ufeff") + if not line or line.startswith("#"): + continue + if len(line) >= 2 and line[0] == line[-1] and line[0] in {'"', "'"}: + line = line[1:-1].strip() + candidate = Path(line).expanduser() + if not candidate.is_absolute(): + candidate = source.parent / candidate + if not _is_image(candidate): + raise ValueError("image list entry is not a supported image: {}".format(line)) + paths.append(candidate) + if not paths: + raise ValueError("image list is empty: {}".format(source)) + return finish(source.parent, paths) + + +def file_record(path: Path, base: Path) -> Dict[str, object]: + """Describe a file using only portable metadata and a content hash.""" + if not path.is_file(): + raise FileNotFoundError("file not found: {}".format(path)) + stat = path.stat() + return { + "path": _normalise_path(path, base), + "bytes": stat.st_size, + "sha256": sha256_file(path), + } + + +def collect_records( + source: Optional[Path], base: Optional[Path] = None, + suffixes: Optional[Iterable[str]] = None, +) -> List[Dict[str, object]]: + """Collect deterministic records for a file or a directory tree.""" + if source is None: + return [] + source = source.expanduser() + if base is None: + base = source if source.is_dir() else source.parent + allowed = {suffix.lower() for suffix in suffixes} if suffixes else None + if source.is_dir(): + files = sorted( + (p for p in source.rglob("*") if p.is_file() and (allowed is None or p.suffix.lower() in allowed)), + key=lambda p: p.as_posix().casefold(), + ) + elif source.is_file(): + if allowed is not None and source.suffix.lower() not in allowed: + raise ValueError("artifact path has an unsupported suffix: {}".format(source)) + files = [source] + else: + raise FileNotFoundError("artifact path not found: {}".format(source)) + return [file_record(path, base) for path in files] + + +def _image_records(paths: Sequence[Path], base: Path) -> List[Dict[str, object]]: + return [file_record(path, base) for path in paths] + + +def _list_digest(records: Sequence[Dict[str, object]]) -> str: + payload = "\n".join("{} {}".format(item["path"], item["sha256"]) for item in records) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _valid_digest(value: object) -> bool: + return isinstance(value, str) and _SHA256_RE.fullmatch(value) is not None + + +def _safe_relative_path(value: object) -> Optional[str]: + """Return a canonical relative path, or ``None`` for an unsafe value. + + Manifest paths are later joined to user-supplied verification roots. An + absolute path or a ``..`` component would let a crafted manifest escape + that root and would also make the evidence non-portable across hosts. + """ + if not isinstance(value, str) or not value or "\x00" in value: + return None + if "\\" in value: + return None + posix = PurePosixPath(value) + windows = PureWindowsPath(value) + if posix.is_absolute() or windows.is_absolute() or windows.drive: + return None + if any(part in ("", ".", "..") for part in posix.parts): + # ``.`` is not emitted by our writer and accepting it would create + # multiple spellings for the same artifact. Empty parts are likewise + # rejected to keep list digests canonical. + return None + return posix.as_posix() + + +def _validate_list_digest( + records: Sequence[Dict[str, object]], value: object, label: str, required: bool, +) -> List[str]: + """Validate a digest over an ordered file-record list.""" + errors: List[str] = [] + if value is None or value == "": + if required: + errors.append("{} is required".format(label)) + return errors + if not _valid_digest(value): + errors.append("{} must be a 64-character hex digest".format(label)) + return errors + if any(not isinstance(item, dict) or "path" not in item or "sha256" not in item for item in records): + # The record validator reports the structural problem; avoid raising a + # secondary KeyError while trying to calculate a digest for it. + return errors + if records and str(value).lower() != _list_digest(records): + errors.append("{} does not match its file list".format(label)) + elif not records and str(value).lower() != _list_digest(records): + # A non-null digest on an empty list is still auditable and must be exact. + errors.append("{} does not match its file list".format(label)) + return errors + + +def _model_format(name: object, model: Optional[Dict[str, object]] = None) -> Optional[str]: + """Resolve a model artifact's runtime format from its key or file suffix. + + Release manifests commonly use keys such as ``onnx_fp32`` or + ``ncnn-int8``. Requiring the literal key ``onnx`` made otherwise valid + manifests fail the acceptance gate, so the prefix is treated as the + canonical format and a suffix is used as a fallback for generic keys. + """ + match = _MODEL_FORMAT_RE.match(str(name).strip().lower()) + if match: + return match.group(1).lower() + if isinstance(model, dict) and isinstance(model.get("files"), list): + formats = set() + for item in model["files"]: + if not isinstance(item, dict): + continue + suffix = Path(str(item.get("path", ""))).suffix.lower() + if suffix == ".onnx": + formats.add("onnx") + elif suffix in {".param", ".bin"}: + formats.add("ncnn") + elif suffix == ".mnn": + formats.add("mnn") + if len(formats) == 1: + return next(iter(formats)) + return None + + +def _stem_set(records: Iterable[Dict[str, object]]) -> Tuple[set, List[str]]: + stems: Dict[str, str] = {} + duplicates: List[str] = [] + for item in records: + if not isinstance(item, dict) or not str(item.get("path", "")): + continue + stem = Path(str(item["path"])).stem.casefold() + if stem in stems: + duplicates.append("{} ({}, {})".format(stem, stems[stem], item["path"])) + else: + stems[stem] = str(item["path"]) + return set(stems), duplicates + + +def _stems(records: Iterable[Dict[str, object]]) -> set: + """Return case-folded file stems for correspondence checks.""" + return { + Path(str(item.get("path", ""))).stem.casefold() + for item in records + if isinstance(item, dict) and str(item.get("path", "")) + } + + +def _validate_records(records: object, label: str) -> List[str]: + """Validate the portable shape of a file-record list.""" + errors: List[str] = [] + if not isinstance(records, list): + return ["{}.files must be a list".format(label)] + seen_paths: Dict[str, int] = {} + for index, item in enumerate(records): + if not isinstance(item, dict): + errors.append("{}[{}] must be an object".format(label, index)) + continue + raw_path = item.get("path") + if not isinstance(raw_path, str) or not raw_path: + errors.append("{}[{}].path is required".format(label, index)) + else: + safe_path = _safe_relative_path(raw_path) + if safe_path is None: + errors.append("{}[{}].path must be a relative POSIX path without '..'".format(label, index)) + else: + key = safe_path.casefold() + if key in seen_paths: + errors.append( + "{} contains duplicate path {} (records {} and {})".format( + label, raw_path, seen_paths[key], index + ) + ) + else: + seen_paths[key] = index + digest = str(item.get("sha256", "")) + if not re.fullmatch(r"[0-9a-fA-F]{64}", digest): + errors.append("{}[{}].sha256 must be a 64-character hex digest".format(label, index)) + size = item.get("bytes", -1) + if isinstance(size, bool) or not isinstance(size, int) or size < 0: + errors.append("{}[{}].bytes must be non-negative".format(label, index)) + return errors + + +def _verify_records( + records: object, root: Optional[Path], label: str, +) -> List[str]: + """Verify file sizes and SHA256 values when a local root is supplied.""" + if root is None: + return [] + if not isinstance(records, list): + return ["{}.files must be a list".format(label)] + errors: List[str] = [] + root = root.expanduser().resolve() + for item in records: + if not isinstance(item, dict): + continue + raw_path = item.get("path", "") + safe_path = _safe_relative_path(raw_path) + if safe_path is None: + errors.append("{} has an unsafe relative path: {}".format(label, raw_path)) + continue + path = (root / PurePosixPath(safe_path)).resolve() + try: + path.relative_to(root) + except ValueError: + errors.append("{} escapes verification root: {}".format(label, raw_path)) + continue + if not path.is_file(): + errors.append("{} missing: {}".format(label, path)) + continue + recorded_size = item.get("bytes", -1) + if not isinstance(recorded_size, int) or isinstance(recorded_size, bool): + continue + if recorded_size != path.stat().st_size: + errors.append("{} size mismatch: {}".format(label, path)) + if str(item.get("sha256", "")).lower() != sha256_file(path): + errors.append("{} SHA256 mismatch: {}".format(label, path)) + return errors + + +def _actual_record_hashes(records: object, root: Optional[Path]) -> set: + """Hash existing records under *root* for the calibration disjoint gate.""" + if root is None or not isinstance(records, list): + return set() + hashes = set() + root = root.expanduser().resolve() + for item in records: + if not isinstance(item, dict): + continue + safe_path = _safe_relative_path(item.get("path", "")) + if safe_path is None: + continue + path = (root / PurePosixPath(safe_path)).resolve() + try: + path.relative_to(root) + except ValueError: + continue + if path.is_file(): + try: + hashes.add(sha256_file(path).lower()) + except OSError: + # _verify_records emits the user-facing missing/read error. + continue + return hashes + + +def _git_commit(start: Path) -> Optional[str]: + try: + completed = subprocess.run( + ["git", "-C", str(start), "rev-parse", "HEAD"], + check=False, + capture_output=True, + text=True, + ) + except OSError: + return None + value = completed.stdout.strip() + return value if completed.returncode == 0 and value else None + + +def _command_version(command: str) -> Optional[str]: + """Return the first version line of an installed command, if available.""" + try: + completed = subprocess.run( + [command, "--version"], check=False, capture_output=True, text=True, + ) + except (OSError, UnicodeError): + return None + output = (completed.stdout or completed.stderr).splitlines() + return output[0].strip() if completed.returncode == 0 and output else None + + +def _parse_named_paths(values: Sequence[str], option: str) -> Dict[str, Path]: + """Parse repeatable ``NAME=PATH`` arguments with stable diagnostics.""" + models: Dict[str, Path] = {} + for value in values: + if "=" not in value: + raise ValueError("{} must use NAME=PATH (got {!r})".format(option, value)) + name, raw_path = value.split("=", 1) + name = name.strip().lower() + if not name or not raw_path.strip(): + raise ValueError("{} must use a non-empty NAME=PATH".format(option)) + if name in models: + raise ValueError("duplicate {} name: {}".format(option.lstrip("-"), name)) + models[name] = Path(raw_path).expanduser() + return models + + +def _parse_model_specs(values: Sequence[str]) -> Dict[str, Path]: + """Parse repeatable exported-model specifications.""" + return _parse_named_paths(values, "--model") + + +def _environment(repo_root: Path) -> Dict[str, object]: + return { + "python": sys.version.split()[0], + "python_executable": str(Path(sys.executable).resolve()), + "platform": platform.platform(aliased=True), + "machine": platform.machine(), + "processor": platform.processor(), + "git_commit": _git_commit(repo_root), + "cmake": _command_version("cmake"), + "cxx": _command_version(os.environ.get("CXX", "g++")), + } + + +def _load_training_metadata(path: Optional[Path]) -> Optional[Dict[str, object]]: + """Load an optional, JSON-serialisable training provenance record.""" + if path is None: + return None + if not path.is_file(): + raise FileNotFoundError("training metadata not found: {}".format(path)) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError("unable to read training metadata: {}".format(exc)) from exc + if not isinstance(payload, dict): + raise ValueError("training metadata must contain a JSON object") + return payload + + +def _default_protocol(args: argparse.Namespace) -> Dict[str, object]: + return { + "imgsz": args.imgsz, + "conf": args.conf, + "iou": args.iou, + "max_det": args.max_det, + "multi_label": bool(args.multi_label), + "letterbox": not args.stretch, + # Keep the optional small-object sweep in the signed protocol. A + # disabled sweep is represented by -1 rather than by omission so a + # reference and candidate cannot silently use different thresholds. + "small_conf": getattr(args, "small_conf", -1.0), + "small_area": getattr(args, "small_area", 32.0 * 32.0), + "color": "RGB", + "layout": "NCHW", + "normalization": "float32 / 255.0", + "routing_semantics": getattr(args, "routing_semantics", None), + } + + +def _empty_template(args: argparse.Namespace) -> Dict[str, object]: + return { + "schema_version": SCHEMA_VERSION, + "status": "template", + "dataset": { + "name": args.dataset, + "split": args.split, + "image_count": 0, + "images": [], + "image_list_sha256": None, + }, + "protocol": _default_protocol(args), + "training": None, + # ``labels`` and ``predictions`` are nullable file collections in the + # schema. A bare list is ambiguous (and fails JSON-Schema validation), + # so an unavailable artifact is represented consistently as null. + "artifacts": { + "checkpoint": None, + "models": {}, + "reports": {}, + "labels": None, + "predictions": None, + }, + "calibration": { + "enabled": bool(args.int8), + "image_count": 0, + "images": [], + "image_list_sha256": None, + "disjoint_from_validation": None, + }, + "environment": _environment(args.repo_root), + "run": {"command": args.command or None}, + "gates": { + "accuracy_min_images": 500, + "fp32_max_abs_delta_pp": 0.5, + "int8_max_abs_delta_pp": 1.0, + "calibration_min_images": 300, + }, + } + + +def build_manifest(args: argparse.Namespace) -> Dict[str, object]: + if args.template: + if args.acceptance: + raise ValueError("--template cannot be combined with --acceptance") + if args.int8 or args.calibration_images: + raise ValueError("--template cannot be combined with --int8 or --calibration-images") + template = _empty_template(args) + template_errors = validate_manifest(template) + if template_errors: + raise ValueError("; ".join(template_errors)) + return template + if args.images is None: + raise ValueError("--images is required unless --template is used") + image_base, image_paths = resolve_image_list(args.images, getattr(args, "image_root", None)) + image_records = _image_records(image_paths, image_base) + models = _parse_model_specs(args.model) + model_records: Dict[str, List[Dict[str, object]]] = {} + for name, path in models.items(): + model_records[name] = collect_records(path) + reports = _parse_named_paths(getattr(args, "report", []), "--report") + report_records: Dict[str, List[Dict[str, object]]] = {} + for name, path in reports.items(): + report_records[name] = collect_records(path) + checkpoint = file_record(args.checkpoint, args.checkpoint.parent) if args.checkpoint else None + label_records = collect_records(args.labels, suffixes={".txt"}) + prediction_records = collect_records(args.predictions, suffixes={".txt"}) + calibration_records: List[Dict[str, object]] = [] + calibration_base = None + if args.calibration_images: + calibration_base, calibration_paths = resolve_image_list( + args.calibration_images, getattr(args, "calibration_root", None) + ) + calibration_records = _image_records(calibration_paths, calibration_base) + training = _load_training_metadata(getattr(args, "training_metadata", None)) + + manifest: Dict[str, object] = { + "schema_version": SCHEMA_VERSION, + "status": "acceptance-candidate" if args.acceptance else "diagnostic", + "dataset": { + "name": args.dataset, + "split": args.split, + "image_root": image_base.as_posix(), + "image_count": len(image_records), + "images": image_records, + "image_list_sha256": _list_digest(image_records), + }, + "protocol": _default_protocol(args), + "training": training, + "artifacts": { + "checkpoint": checkpoint, + "models": { + name: {"files": records, "sha256": _list_digest(records)} + for name, records in model_records.items() + }, + "reports": { + name: {"files": records, "sha256": _list_digest(records)} + for name, records in report_records.items() + }, + "labels": {"files": label_records, "count": len(label_records)} if args.labels else None, + "predictions": { + "files": prediction_records, + "count": len(prediction_records), + } + if args.predictions + else None, + }, + "calibration": { + "enabled": bool(args.int8 or args.calibration_images), + "image_root": calibration_base.as_posix() if calibration_base else None, + "image_count": len(calibration_records), + "images": calibration_records, + "image_list_sha256": _list_digest(calibration_records) if calibration_records else None, + "disjoint_from_validation": None, + }, + "environment": _environment(args.repo_root), + "run": {"command": args.command or None}, + "gates": { + "accuracy_min_images": 500, + "fp32_max_abs_delta_pp": 0.5, + "int8_max_abs_delta_pp": 1.0, + "calibration_min_images": 300, + }, + } + if calibration_records: + val_hashes = {str(item["sha256"]) for item in image_records} + overlap = bool(val_hashes.intersection(str(item["sha256"]) for item in calibration_records)) + manifest["calibration"]["disjoint_from_validation"] = not overlap # type: ignore[index] + if overlap: + raise ValueError("calibration images overlap the validation set by SHA256") + elif args.int8: + raise ValueError("--int8 requires --calibration-images and at least 300 images") + validation_errors = validate_manifest(manifest, acceptance=args.acceptance) + if validation_errors: + raise ValueError("; ".join(validation_errors)) + return manifest + + +def validate_manifest(manifest: Dict[str, object], acceptance: bool = False) -> List[str]: + """Return human-readable schema/gate violations (empty means valid).""" + errors: List[str] = [] + if not isinstance(manifest, dict): + return ["manifest must be an object"] + if manifest.get("schema_version") != SCHEMA_VERSION: + errors.append("unsupported schema_version") + status = manifest.get("status") + if status is not None and status not in {"template", "diagnostic", "acceptance-candidate"}: + errors.append("unsupported status") + if acceptance and status != "acceptance-candidate": + errors.append("acceptance validation requires status=acceptance-candidate") + dataset = manifest.get("dataset") + if not isinstance(dataset, dict): + return ["dataset must be an object"] + if "name" in dataset and dataset.get("name") not in {"visdrone", "sku110k"}: + errors.append("dataset.name must be visdrone or sku110k") + if "split" in dataset and ( + not isinstance(dataset.get("split"), str) or not dataset.get("split") + ): + errors.append("dataset.split must be a non-empty string") + images = dataset.get("images") + if not isinstance(images, list): + errors.append("dataset.images must be a list") + images = [] + image_count = dataset.get("image_count") + if isinstance(image_count, bool) or not isinstance(image_count, int) or image_count < 0: + errors.append("dataset.image_count must be a non-negative integer") + if dataset.get("image_count") != len(images): + errors.append("dataset.image_count does not match dataset.images") + record_errors = _validate_records(images, "dataset.images") + errors.extend(record_errors) + listed_digest = dataset.get("image_list_sha256") + # A non-empty image list must always carry its ordered-list digest. Empty + # templates remain valid with a null digest, while acceptance manifests + # are required to provide the field even before the image-floor check. + if isinstance(images, list): + errors.extend(_validate_list_digest( + images, listed_digest, "dataset.image_list_sha256", required=acceptance or bool(images) + )) + _, duplicate_stems = _stem_set(images) + if duplicate_stems: + errors.append("duplicate image stems: " + ", ".join(duplicate_stems[:3])) + if acceptance and len(images) < 500: + errors.append("Issue #51 acceptance requires at least 500 validation images") + protocol = manifest.get("protocol") + if isinstance(protocol, dict): + required_protocol = ( + "imgsz", "conf", "iou", "max_det", "multi_label", "letterbox", + ) + if acceptance: + required_protocol = required_protocol + ( + "small_conf", "small_area", "routing_semantics", + ) + if acceptance: + for key in required_protocol: + if key not in protocol: + errors.append("acceptance protocol is missing {}".format(key)) + for key in ("imgsz", "max_det"): + try: + if isinstance(protocol.get(key), bool): + raise ValueError + if int(protocol.get(key, 0)) <= 0: + errors.append("protocol.{} must be positive".format(key)) + except (TypeError, ValueError): + errors.append("protocol.{} must be numeric".format(key)) + for key in ("conf", "iou"): + try: + if isinstance(protocol.get(key), bool): + raise ValueError + value = float(protocol.get(key, -1)) + if not math.isfinite(value) or not 0.0 <= value <= 1.0: + errors.append("protocol.{} must be in [0, 1]".format(key)) + except (TypeError, ValueError): + errors.append("protocol.{} must be numeric".format(key)) + if "small_conf" in protocol: + try: + if isinstance(protocol.get("small_conf"), bool): + raise ValueError + small_conf = float(protocol.get("small_conf")) + if not math.isfinite(small_conf) or not -1.0 <= small_conf <= 1.0: + errors.append("protocol.small_conf must be in [-1, 1]") + except (TypeError, ValueError): + errors.append("protocol.small_conf must be numeric") + if "small_area" in protocol: + try: + if isinstance(protocol.get("small_area"), bool): + raise ValueError + small_area = float(protocol.get("small_area")) + if not math.isfinite(small_area) or small_area < 0.0: + errors.append("protocol.small_area must be non-negative") + except (TypeError, ValueError): + errors.append("protocol.small_area must be numeric") + for key in ("multi_label", "letterbox"): + if key in protocol and not isinstance(protocol.get(key), bool): + errors.append("protocol.{} must be boolean".format(key)) + if acceptance and protocol.get("letterbox") is not True: + errors.append("Issue #51 acceptance requires aspect-preserving letterbox preprocessing") + routing_semantics = protocol.get("routing_semantics") + if routing_semantics is not None and routing_semantics not in ROUTING_SEMANTICS: + errors.append( + "protocol.routing_semantics must be one of {}".format( + ", ".join(ROUTING_SEMANTICS) + ) + ) + if acceptance and routing_semantics is None: + errors.append( + "acceptance protocol.routing_semantics is required; " + "use dense_fallback for static exports" + ) + elif acceptance: + errors.append("protocol must be an object") + training = manifest.get("training") + if acceptance and not isinstance(training, dict): + errors.append("acceptance manifest requires training provenance") + if training is not None: + if not isinstance(training, dict): + errors.append("training must be an object or null") + else: + for key in ("epochs", "batch_size"): + if key in training: + value = training[key] + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + errors.append("training.{} must be a positive integer".format(key)) + if "seed" in training: + seed = training["seed"] + if isinstance(seed, bool) or not isinstance(seed, int): + errors.append("training.seed must be an integer") + for key in ("base_model", "dataset_version", "optimizer", "lr_schedule", "command"): + if key in training and training[key] is not None and not isinstance(training[key], str): + errors.append("training.{} must be a string or null".format(key)) + if acceptance: + for key in ("base_model", "dataset_version", "command"): + if not isinstance(training.get(key), str) or not training[key].strip(): + errors.append("acceptance training.{} must be a non-empty string".format(key)) + if isinstance(training.get("epochs"), bool) or not isinstance(training.get("epochs"), int): + errors.append("acceptance training.epochs is required") + if isinstance(training.get("seed"), bool) or not isinstance(training.get("seed"), int): + errors.append("acceptance training.seed is required") + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, dict): + errors.append("artifacts must be an object") + artifacts = {} + checkpoint = artifacts.get("checkpoint") + if acceptance and not checkpoint: + errors.append("acceptance manifest requires a checkpoint record") + elif checkpoint is not None: + if isinstance(checkpoint, dict): + errors.extend(_validate_records([checkpoint], "artifacts.checkpoint")) + else: + errors.append("artifacts.checkpoint must be an object or null") + models = artifacts.get("models") + if acceptance and (not isinstance(models, dict) or not models): + errors.append("acceptance manifest requires at least one exported model") + if acceptance and isinstance(models, dict): + model_formats = { + resolved for name, model in models.items() + if (resolved := _model_format(name, model)) is not None + } + if "onnx" not in model_formats: + errors.append("Issue #51 acceptance requires an ONNX export") + if not model_formats.intersection({"ncnn", "mnn"}): + errors.append("Issue #51 acceptance requires an NCNN or MNN export") + elif models is not None and not isinstance(models, dict): + errors.append("artifacts.models must be an object") + if isinstance(models, dict): + for name, model in models.items(): + if not isinstance(model, dict): + errors.append("artifacts.models.{} must be an object".format(name)) + continue + model_label = "artifacts.models.{}".format(name) + files = model.get("files") + model_record_errors = _validate_records(files, model_label + ".files") + errors.extend(model_record_errors) + if acceptance and isinstance(files, list) and not files: + errors.append(model_label + ".files must contain at least one artifact") + digest = model.get("sha256") + if acceptance: + if not _valid_digest(digest): + errors.append(model_label + ".sha256 must be a 64-character hex digest") + elif isinstance(files, list) and not model_record_errors and digest.lower() != _list_digest(files): + errors.append(model_label + ".sha256 does not match files") + if acceptance: + model_format = _model_format(name, model) + if model_format == "ncnn" and isinstance(files, list): + suffixes = {Path(str(item.get("path", ""))).suffix.lower() + for item in files if isinstance(item, dict)} + if not {".param", ".bin"}.issubset(suffixes): + errors.append(model_label + " NCNN export requires both .param and .bin files") + elif digest is not None: + if not _valid_digest(digest): + errors.append(model_label + ".sha256 must be a 64-character hex digest") + elif isinstance(files, list) and not model_record_errors and digest.lower() != _list_digest(files): + errors.append(model_label + ".sha256 does not match files") + reports = artifacts.get("reports", {}) + if reports is None: + reports = {} + if not isinstance(reports, dict): + errors.append("artifacts.reports must be an object") + reports = {} + if acceptance and not reports: + errors.append("acceptance manifest requires at least one metric or benchmark report") + if isinstance(reports, dict): + for name, report in reports.items(): + report_label = "artifacts.reports.{}".format(name) + if not isinstance(name, str) or not name.strip(): + errors.append("artifacts.reports names must be non-empty strings") + if not isinstance(report, dict): + errors.append(report_label + " must be an object") + continue + files = report.get("files") + report_record_errors = _validate_records(files, report_label + ".files") + errors.extend(report_record_errors) + if acceptance and isinstance(files, list) and not files: + errors.append(report_label + ".files must contain at least one artifact") + digest = report.get("sha256") + if not _valid_digest(digest): + errors.append(report_label + ".sha256 must be a 64-character hex digest") + elif isinstance(files, list) and not report_record_errors and digest.lower() != _list_digest(files): + errors.append(report_label + ".sha256 does not match files") + for key in ("labels", "predictions"): + value = artifacts.get(key) + if isinstance(value, dict): + errors.extend(_validate_records(value.get("files"), "artifacts.{}.files".format(key))) + count = value.get("count") + if isinstance(count, bool) or not isinstance(count, int) or count < 0: + errors.append("artifacts.{}.count must be a non-negative integer".format(key)) + if count != len(value.get("files", [])): + errors.append("artifacts.{}.count does not match files".format(key)) + elif acceptance: + errors.append("acceptance manifest requires artifacts.{}".format(key)) + elif value is not None: + errors.append("artifacts.{} must be an object or null".format(key)) + if acceptance and isinstance(artifacts.get("labels"), dict): + if artifacts["labels"].get("count") != len(images): + errors.append("acceptance requires one label record per validation image") + elif _stems(artifacts["labels"].get("files", [])) != _stems(images): + errors.append("label records do not correspond one-to-one with validation images") + if acceptance and isinstance(artifacts.get("predictions"), dict): + if artifacts["predictions"].get("count") != len(images): + errors.append("acceptance requires one prediction record per validation image") + elif _stems(artifacts["predictions"].get("files", [])) != _stems(images): + errors.append("prediction records do not correspond one-to-one with validation images") + calibration = manifest.get("calibration") + if not isinstance(calibration, dict): + errors.append("calibration must be an object") + calibration = {} + enabled = calibration.get("enabled") + if enabled is not None and not isinstance(enabled, bool): + errors.append("calibration.enabled must be boolean") + if acceptance and enabled is None: + errors.append("acceptance calibration.enabled is required") + if enabled is True: + cal_images = calibration.get("images") + if not isinstance(cal_images, list): + errors.append("calibration.images must be a list") + cal_images = [] + if calibration.get("image_count") != len(cal_images): + errors.append("calibration.image_count does not match calibration.images") + if (isinstance(calibration.get("image_count"), bool) + or not isinstance(calibration.get("image_count"), int) + or calibration.get("image_count", -1) < 0): + errors.append("calibration.image_count must be a non-negative integer") + if len(cal_images) < 300: + errors.append("INT8 calibration requires at least 300 images") + _, cal_duplicates = _stem_set(cal_images) + if cal_duplicates: + errors.append("duplicate calibration stems: " + ", ".join(cal_duplicates[:3])) + disjoint = calibration.get("disjoint_from_validation") + if acceptance and disjoint is not True: + errors.append("acceptance requires calibration.disjoint_from_validation=true") + elif disjoint not in (None, True, False): + errors.append("calibration.disjoint_from_validation must be boolean or null") + cal_record_errors = _validate_records(cal_images, "calibration.images") + errors.extend(cal_record_errors) + if isinstance(images, list) and isinstance(cal_images, list) and not record_errors and not cal_record_errors: + overlap = {str(item["sha256"]).lower() for item in images}.intersection( + str(item["sha256"]).lower() for item in cal_images + ) + if overlap: + errors.append("calibration set overlaps validation set by SHA256") + errors.extend(_validate_list_digest( + cal_images, calibration.get("image_list_sha256"), + "calibration.image_list_sha256", required=acceptance or bool(cal_images) + )) + elif enabled is False: + if calibration.get("images") not in (None, []): + errors.append("calibration.images must be empty when calibration is disabled") + if calibration.get("image_count", 0) not in (0, None): + errors.append("calibration.image_count must be zero when calibration is disabled") + if calibration.get("image_list_sha256") not in (None, ""): + errors.append("calibration.image_list_sha256 must be null when calibration is disabled") + elif calibration.get("images") not in (None, []): + errors.append("calibration.images must be empty when calibration is disabled") + environment = manifest.get("environment") + if acceptance: + if not isinstance(environment, dict): + errors.append("acceptance manifest requires environment metadata") + else: + for key in ("python", "platform", "machine", "git_commit"): + if not isinstance(environment.get(key), str) or not environment[key].strip(): + errors.append("acceptance environment.{} must be a non-empty string".format(key)) + run = manifest.get("run") + if not isinstance(run, dict): + errors.append("acceptance manifest requires run metadata") + elif not isinstance(run.get("command"), str) or not run["command"].strip(): + errors.append("acceptance run.command must be a non-empty string") + return errors + + +def _write_json(path: Path, payload: Dict[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + canonical = json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n" + path.write_text(canonical, encoding="utf-8") + + +def _create_command(args: argparse.Namespace) -> int: + manifest = build_manifest(args) + manifest["generated_at_utc"] = datetime.now(timezone.utc).replace(microsecond=0).isoformat() + _write_json(args.output, manifest) + print(json.dumps({ + "status": manifest["status"], + "output": str(args.output), + "images": manifest["dataset"]["image_count"], # type: ignore[index] + "manifest_sha256": sha256_file(args.output), + }, indent=2)) + return 0 + + +def _validate_command(args: argparse.Namespace) -> int: + if not args.manifest.is_file(): + raise FileNotFoundError("manifest not found: {}".format(args.manifest)) + payload = json.loads(args.manifest.read_text(encoding="utf-8")) + errors = validate_manifest(payload, acceptance=args.acceptance) + if errors: + for error in errors: + print("[invalid] " + error, file=sys.stderr) + return 2 + print(json.dumps({"status": "valid", "manifest": str(args.manifest)}, indent=2)) + return 0 + + +def _verify_command(args: argparse.Namespace) -> int: + if not args.manifest.is_file(): + raise FileNotFoundError("manifest not found: {}".format(args.manifest)) + payload = json.loads(args.manifest.read_text(encoding="utf-8")) + errors = validate_manifest(payload, acceptance=args.acceptance) + dataset = payload.get("dataset", {}) + artifacts = payload.get("artifacts", {}) + calibration = payload.get("calibration", {}) + if isinstance(dataset, dict): + errors.extend(_verify_records(dataset.get("images"), args.images_root, "dataset.images")) + if isinstance(artifacts, dict): + errors.extend(_verify_records([artifacts.get("checkpoint")], args.checkpoint_root, "checkpoint")) + models = artifacts.get("models") + if isinstance(models, dict): + for name, model in models.items(): + if isinstance(model, dict): + errors.extend(_verify_records(model.get("files"), args.models_root, "model.{}".format(name))) + reports = artifacts.get("reports") + if isinstance(reports, dict): + for name, report in reports.items(): + if isinstance(report, dict): + errors.extend(_verify_records( + report.get("files"), getattr(args, "reports_root", None), + "report.{}".format(name), + )) + if isinstance(artifacts.get("labels"), dict): + errors.extend(_verify_records(artifacts["labels"].get("files"), args.labels_root, "labels")) + if isinstance(artifacts.get("predictions"), dict): + errors.extend(_verify_records(artifacts["predictions"].get("files"), args.predictions_root, "predictions")) + if isinstance(calibration, dict): + errors.extend(_verify_records( + calibration.get("images"), getattr(args, "calibration_root", None), "calibration.images" + )) + # Recompute the split intersection from the supplied roots. Checking + # only the boolean recorded in JSON would allow an edited manifest to + # bypass the INT8 calibration/validation separation gate. + images_root = getattr(args, "images_root", None) + calibration_root = getattr(args, "calibration_root", None) + if isinstance(dataset, dict) and images_root is not None and calibration_root is not None: + validation_hashes = _actual_record_hashes(dataset.get("images"), images_root) + calibration_hashes = _actual_record_hashes(calibration.get("images"), calibration_root) + if validation_hashes.intersection(calibration_hashes): + errors.append("calibration set overlaps validation set by SHA256 (verified roots)") + if errors: + for error in errors: + print("[invalid] " + error, file=sys.stderr) + return 2 + print(json.dumps({"status": "verified", "manifest": str(args.manifest)}, indent=2)) + return 0 + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="action", required=True) + create = sub.add_parser("create", help="create a manifest") + create.add_argument("--images", type=Path, help="validation image directory, image, or list file") + create.add_argument( + "--image-root", type=Path, + help=( + "explicit root used to normalise validation image paths; required when " + "a list file is outside the image tree" + ), + ) + create.add_argument("--labels", type=Path, help="converted YOLO label directory or file") + create.add_argument("--predictions", type=Path, help="prediction directory or file") + create.add_argument( + "--report", action="append", default=[], metavar="NAME=PATH", + help="mAP/benchmark or other report artifact (repeatable)", + ) + create.add_argument("--checkpoint", type=Path, help="PyTorch checkpoint") + create.add_argument( + "--training-metadata", type=Path, + help="training provenance JSON (required with --acceptance)", + ) + create.add_argument("--model", action="append", default=[], metavar="NAME=PATH", help="exported model (repeatable)") + create.add_argument("--calibration-images", type=Path, help="training-only calibration image directory/list") + create.add_argument( + "--calibration-root", type=Path, + help="explicit root used to normalise calibration image paths", + ) + create.add_argument("--dataset", choices=("visdrone", "sku110k"), default="visdrone") + create.add_argument("--split", default="val") + create.add_argument("--imgsz", type=int, default=640) + create.add_argument("--conf", type=float, default=0.001) + create.add_argument("--iou", type=float, default=0.70) + create.add_argument("--max-det", type=int, default=300) + create.add_argument( + "--small-conf", type=float, default=-1.0, + help="optional lower confidence for boxes below --small-area (-1 disables)", + ) + create.add_argument( + "--small-area", type=float, default=32.0 * 32.0, + help="original-image area threshold for --small-conf (default: 1024)", + ) + label_group = create.add_mutually_exclusive_group() + label_group.add_argument( + "--multi-label", dest="multi_label", action="store_true", default=True, + help="record one detection per class and anchor (default)", + ) + label_group.add_argument( + "--single-label", dest="multi_label", action="store_false", + help="record argmax-per-anchor decoding (diagnostic runs only)", + ) + create.add_argument("--stretch", action="store_true", help="record stretch preprocessing instead of letterbox") + create.add_argument( + "--routing-semantics", choices=ROUTING_SEMANTICS, + help=( + "EsMoE inference path (required with --acceptance): native_sparse, " + "dense_fallback, dense_native, or not_applicable" + ), + ) + create.add_argument("--int8", action="store_true", help="enable the >=300-image calibration gate") + create.add_argument("--acceptance", action="store_true", help="enforce the Issue #51 evidence floor") + create.add_argument("--command", help="exact command used for the run") + create.add_argument("--repo-root", type=Path, default=Path.cwd()) + create.add_argument("--template", action="store_true", help="write an explicitly non-acceptance template") + create.add_argument("--output", type=Path, required=True) + create.set_defaults(func=_create_command) + validate = sub.add_parser("validate", help="validate an existing manifest") + validate.add_argument("manifest", type=Path) + validate.add_argument("--acceptance", action="store_true") + validate.set_defaults(func=_validate_command) + verify = sub.add_parser("verify", help="validate and optionally verify file hashes") + verify.add_argument("manifest", type=Path) + verify.add_argument("--acceptance", action="store_true") + verify.add_argument("--images-root", type=Path) + verify.add_argument("--labels-root", type=Path) + verify.add_argument("--predictions-root", type=Path) + verify.add_argument("--models-root", type=Path) + verify.add_argument("--reports-root", type=Path) + verify.add_argument("--checkpoint-root", type=Path) + verify.add_argument("--calibration-root", type=Path) + verify.set_defaults(func=_verify_command) + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = _parser().parse_args(argv) + try: + return int(args.func(args)) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print("error: {}".format(exc), file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/export_models.py b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/export_models.py index b16ab47ab..f83b19d83 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/export_models.py +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/export_models.py @@ -19,7 +19,7 @@ import subprocess import sys from pathlib import Path -from typing import Optional, Tuple +from typing import List, Optional, Tuple EXAMPLE_ROOT = Path(__file__).resolve().parents[1] @@ -28,6 +28,12 @@ sys.path.insert(0, str(REPO_ROOT)) +# These labels are consumed by the evidence manifest and metric evaluators. +# Keeping the vocabulary here avoids silently treating a dense export as a +# numerically equivalent sparse-eager baseline. +ROUTING_SEMANTICS = ("native_sparse", "dense_fallback", "dense_native", "not_applicable") + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--model", type=Path, required=True, help="trained .pt checkpoint") @@ -80,6 +86,87 @@ def _restore_metadata(source, target) -> None: entry.key, entry.value = key, value +def _force_ncnn_dense(model): + """Force export-safe dense routing and return the reversible state. + + EsMoE implementations have used both ``DynamicRoutingLayer.use_top_k`` and + ``ES_MOE.use_sparse_inference`` across YOLO-Master revisions. Detect both + forms, and also retain a conservative attribute-based fallback so a renamed + implementation cannot silently produce an unrecorded sparse graph. + """ + state = [] + router_count = esmoe_count = 0 + try: + from ultralytics.nn.modules.moe.routers import DynamicRoutingLayer + except ImportError: + DynamicRoutingLayer = None + try: + import ultralytics.nn.modules.moe.modules as moe_modules + esmoe_cls = getattr(moe_modules, "ES_MOE", None) + except ImportError: + esmoe_cls = None + modules = getattr(getattr(model, "model", None), "modules", None) + if not callable(modules): + return state, router_count, esmoe_count + for module in modules(): + is_router = DynamicRoutingLayer is not None and isinstance(module, DynamicRoutingLayer) + is_esmoe = esmoe_cls is not None and isinstance(module, esmoe_cls) + # Attribute checks cover compatible forks without weakening the explicit + # class checks above. Do not mutate an unrelated module unless it has + # a routing-specific name and flag. + class_name = type(module).__name__.lower() + has_router_flag = hasattr(module, "use_top_k") and ( + is_router or "routing" in class_name or "router" in class_name + ) + has_esmoe_flag = hasattr(module, "use_sparse_inference") and ( + is_esmoe or "moe" in class_name or "mixture" in class_name + ) + if has_router_flag: + state.append((module, "use_top_k", module.use_top_k)) + module.use_top_k = False + router_count += 1 + if has_esmoe_flag: + state.append((module, "use_sparse_inference", module.use_sparse_inference)) + module.use_sparse_inference = False + esmoe_count += 1 + return state, router_count, esmoe_count + + +def _routing_overlap(state) -> int: + """Count modules represented by both compatibility routing flags.""" + flags = {} + for module, attribute, _ in state: + flags.setdefault(id(module), set()).add(attribute) + return sum( + "use_top_k" in attributes and "use_sparse_inference" in attributes + for attributes in flags.values() + ) + + +def _routing_record(router_count: int, esmoe_count: int, overlap_count: int = 0) -> dict: + """Return a stable routing provenance record for an export result. + + ``router_count`` and ``esmoe_count`` count compatibility flags. A single + module can expose both flags, so ``overlap_count`` removes that duplicate + from the union-based ``total`` while retaining the detailed counts. + """ + router_count = int(router_count) + esmoe_count = int(esmoe_count) + overlap_count = int(overlap_count) + if min(router_count, esmoe_count, overlap_count) < 0 or overlap_count > min(router_count, esmoe_count): + raise ValueError("invalid routing layer counts") + layer_count = router_count + esmoe_count - overlap_count + return { + "routing_semantics": "dense_fallback" if layer_count else "not_applicable", + "routing_layers": { + "dynamic_router": router_count, + "esmoe": esmoe_count, + "overlap": overlap_count, + "total": layer_count, + }, + } + + def _check_static_input(graph, imgsz: int) -> None: if not graph.graph.input: raise RuntimeError("ONNX graph has no input tensor") @@ -94,14 +181,23 @@ def _check_static_input(graph, imgsz: int) -> None: def export_onnx(model, args: argparse.Namespace, out_dir: Path) -> dict: import onnx - exported_value = model.export( - format="onnx", - imgsz=args.imgsz, - opset=args.opset, - simplify=args.simplify, - dynamic=False, - half=args.half, - ) + # Use the same explicit dense fallback for ONNX that is required by pnnx. + # Restoring the flags in ``finally`` leaves the caller's model unchanged + # for a subsequent reference evaluation or a different export format. + routing_state, router_count, esmoe_count = _force_ncnn_dense(model) + overlap_count = _routing_overlap(routing_state) + try: + exported_value = model.export( + format="onnx", + imgsz=args.imgsz, + opset=args.opset, + simplify=args.simplify, + dynamic=False, + half=args.half, + ) + finally: + for module, attribute, value in reversed(routing_state): + setattr(module, attribute, value) if not exported_value: raise RuntimeError("ONNX exporter returned no path") exported = Path(exported_value) @@ -129,6 +225,7 @@ def export_onnx(model, args: argparse.Namespace, out_dir: Path) -> dict: "simplified": False, "acceptance_ready": False, } + result.update(_routing_record(router_count, esmoe_count, overlap_count)) if not args.simplify: if not args.allow_unsimplified: raise RuntimeError( @@ -219,7 +316,14 @@ def fingerprint(path: Path) -> Tuple[int, int, str]: return fingerprint(param), fingerprint(binary) -def _write_ncnn_metadata(out_dir: Path, model, imgsz: int) -> Path: +def _write_ncnn_metadata( + out_dir: Path, + model, + imgsz: int, + input_blob: Optional[str] = None, + output_blob: Optional[str] = None, + proto_blob: Optional[str] = None, +) -> Path: names = ( getattr(model, "names", None) or getattr(getattr(model, "model", None), "names", None) @@ -241,6 +345,12 @@ def _write_ncnn_metadata(out_dir: Path, model, imgsz: int) -> Path: metadata = out_dir / "metadata.yaml" with metadata.open("w", encoding="utf-8") as handle: handle.write(f"imgsz: [{imgsz}, {imgsz}]\n") + if input_blob: + handle.write(f"input_blob: {json.dumps(input_blob)}\n") + if output_blob: + handle.write(f"output_blob: {json.dumps(output_blob)}\n") + if proto_blob: + handle.write(f"proto_blob: {json.dumps(proto_blob)}\n") handle.write("names:\n") for index, name in items: # JSON quoting is valid YAML and preserves names containing ':'. @@ -248,33 +358,6 @@ def _write_ncnn_metadata(out_dir: Path, model, imgsz: int) -> Path: return metadata -def _force_ncnn_dense(model): - """Set NCNN-safe routing and return (module, attribute, old_value) state.""" - state = [] - router_count = esmoe_count = 0 - try: - from ultralytics.nn.modules.moe.routers import DynamicRoutingLayer - except ImportError: - DynamicRoutingLayer = None - try: - import ultralytics.nn.modules.moe.modules as moe_modules - esmoe_cls = getattr(moe_modules, "ES_MOE", None) - except ImportError: - esmoe_cls = None - for module in model.model.modules(): - if DynamicRoutingLayer is not None and isinstance(module, DynamicRoutingLayer): - if hasattr(module, "use_top_k"): - state.append((module, "use_top_k", module.use_top_k)) - module.use_top_k = False - router_count += 1 - if esmoe_cls is not None and isinstance(module, esmoe_cls): - if hasattr(module, "use_sparse_inference"): - state.append((module, "use_sparse_inference", module.use_sparse_inference)) - module.use_sparse_inference = False - esmoe_count += 1 - return state, router_count, esmoe_count - - def _param_io_names(param: Path) -> Tuple[Optional[str], Optional[str]]: tops, bottoms, inputs = [], set(), [] for line in param.read_text(encoding="utf-8", errors="ignore").splitlines(): @@ -301,7 +384,36 @@ def _param_io_names(param: Path) -> Tuple[Optional[str], Optional[str]]: return (inputs[0] if inputs else None), output +def _param_output_names(param: Path) -> List[str]: + """Return terminal NCNN blobs in graph order. + + pnnx commonly names detection/prototype outputs ``out0``/``out1``, but + those names are not part of the NCNN ABI. Persisting the actual terminal + names in the sidecar lets the C++ runtime consume renamed graphs as well. + """ + tops: List[str] = [] + bottoms = set() + for line in param.read_text(encoding="utf-8", errors="ignore").splitlines(): + fields = line.split() + if len(fields) < 4 or fields[0].startswith("#"): + continue + try: + bottom_count, top_count = int(fields[2]), int(fields[3]) + except ValueError: + continue + start = 4 + bottoms.update(fields[start : start + bottom_count]) + tops.extend(fields[start + bottom_count : start + bottom_count + top_count]) + # Preserve first appearance while removing duplicate intermediate tops. + return list(dict.fromkeys(name for name in tops if name not in bottoms)) + + def _ncnn_code(value) -> int: + # The reference ncnn wheel returns integer status codes, while a few + # bindings expose the same success/failure result as a boolean. In both + # conventions success must normalize to zero for the checks below. + if isinstance(value, bool): + return 0 if value else 1 return int(value) if isinstance(value, numbers.Integral) else 0 @@ -384,6 +496,7 @@ def export_ncnn(model, args: argparse.Namespace, out_dir: Path) -> dict: expected_dir = args.model.with_name(args.model.stem + "_ncnn_model") before_pair = _ncnn_pair_fingerprint(expected_dir) state, router_count, esmoe_count = _force_ncnn_dense(model) + overlap_count = _routing_overlap(state) exported_value = None note = None export_error = None @@ -440,7 +553,19 @@ def export_ncnn(model, args: argparse.Namespace, out_dir: Path) -> dict: destination.mkdir(parents=True, exist_ok=True) param_out = _copy_file(param, destination / param.name) bin_out = _copy_file(binary, destination / binary.name) - metadata = _write_ncnn_metadata(destination, model, args.imgsz) + input_blob, output_blob = _param_io_names(param_out) + terminal_outputs = _param_output_names(param_out) + proto_blob = next((name for name in terminal_outputs if name != output_blob), None) + metadata = _write_ncnn_metadata( + destination, model, args.imgsz, input_blob, output_blob, proto_blob + ) + # Keep a per-model sidecar alongside the shared legacy filename. This is + # important when several NCNN graphs are unpacked into one release + # directory: the C++ runtime gives the stem-specific file precedence and + # cannot accidentally apply another graph's blob names. + metadata_per_model = destination / f"{param_out.stem}.metadata.yaml" + if metadata_per_model.resolve() != metadata.resolve(): + _copy_file(metadata, metadata_per_model) smoke = _ncnn_smoke_check(param_out, bin_out, args.imgsz) result = { "format": "ncnn", @@ -448,6 +573,9 @@ def export_ncnn(model, args: argparse.Namespace, out_dir: Path) -> dict: "param": str(param_out), "bin": str(bin_out), "metadata": str(metadata), + "metadata_per_model": str(metadata_per_model), + **_routing_record(router_count, esmoe_count, overlap_count), + # Retain the detailed legacy keys for consumers of earlier summaries. "routing": {"routers_dense": router_count, "esmoe_dense": esmoe_count}, "checked": True, "acceptance_ready": True, @@ -463,6 +591,7 @@ def export_mnn( args: argparse.Namespace, out_dir: Path, onnx_path: Optional[Path], + onnx_result: Optional[dict] = None, ) -> dict: del model # MNN conversion intentionally consumes the canonical ONNX graph. if onnx_path is None: @@ -481,6 +610,11 @@ def export_mnn( raise RuntimeError( f"mnnconvert failed ({completed.returncode}): {completed.stderr[-1000:]}" ) + routing = {} + if isinstance(onnx_result, dict): + for key in ("routing_semantics", "routing_layers"): + if key in onnx_result: + routing[key] = onnx_result[key] return { "format": "mnn", "path": str(destination), @@ -493,6 +627,7 @@ def export_mnn( "acceptance_ready": False, "parity_required": True, "parity_command": "python scripts/mnn_parity.py --mnn --onnx --images ", + **routing, } @@ -515,6 +650,7 @@ def main() -> int: model = YOLO(str(args.model)) results, errors = [], [] onnx_path: Optional[Path] = None + onnx_result: Optional[dict] = None # A user may provide formats in any order; dependencies are still emitted # deterministically and MNN always sees the canonical ONNX artifact. ordered_formats = [fmt for fmt in ("onnx", "ncnn", "mnn") if fmt in args.formats] @@ -523,10 +659,11 @@ def main() -> int: if fmt == "onnx": result = export_onnx(model, args, args.out_dir) onnx_path = Path(result["path"]) + onnx_result = result elif fmt == "ncnn": result = export_ncnn(model, args, args.out_dir) else: - result = export_mnn(model, args, args.out_dir, onnx_path) + result = export_mnn(model, args, args.out_dir, onnx_path, onnx_result) results.append(result) print(f"[OK] {fmt}") except Exception as exc: diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/mnn_parity.py b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/mnn_parity.py index dbfd721ec..2c5760c22 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/mnn_parity.py +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/mnn_parity.py @@ -18,6 +18,7 @@ # Keep the parity image list identical to the portable C++ runner's stb decoder. IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp"} +LIST_EXTS = {".txt", ".list"} def status_failed(code) -> bool: @@ -42,16 +43,51 @@ def tensor_to_numpy(tensor, shape): return np.asarray(values, dtype=np.float32).reshape(tuple(shape)) -def image_list(directory: Path, limit: int) -> list[Path]: - if directory.is_file(): - paths = [directory] if directory.suffix.lower() in IMAGE_EXTS else [] +def image_list(source: Path, limit: int) -> list[Path]: + """Resolve a directory, image, or ordered UTF-8 image list.""" + source = source.expanduser() + if not source.exists(): + raise FileNotFoundError(f"validation source not found: {source}") + if source.is_file() and source.suffix.lower() in IMAGE_EXTS: + paths = [source.resolve()] + elif source.is_file() and source.suffix.lower() in LIST_EXTS: + base = source.resolve().parent + paths = [] + with source.open("r", encoding="utf-8-sig") as handle: + for line_number, raw in enumerate(handle, 1): + value = raw.strip() + if not value or value.startswith("#"): + continue + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1].strip() + path = Path(value).expanduser() + if not path.is_absolute(): + path = base / path + path = path.resolve() + if not path.is_file(): + raise FileNotFoundError( + f"image list line {line_number} does not name a file: {path}" + ) + if path.suffix.lower() not in IMAGE_EXTS: + raise ValueError( + f"unsupported image extension at list line {line_number}: {path}" + ) + paths.append(path) + elif source.is_dir(): + paths = sorted( + (path.resolve() for path in source.rglob("*") + if path.is_file() and path.suffix.lower() in IMAGE_EXTS), + # Keep directory discovery identical to mnn_val.py and the + # publication-grade evaluators. + key=lambda path: (path.as_posix().casefold(), path.as_posix()), + ) else: - paths = sorted(p for p in directory.rglob("*") if p.is_file() and p.suffix.lower() in IMAGE_EXTS) + raise ValueError(f"unsupported validation source: {source}") if limit > 0: paths = paths[:limit] if not paths: - raise RuntimeError(f"no validation images found under {directory}") - stems = [path.stem for path in paths] + raise RuntimeError(f"no validation images found under {source}") + stems = [path.stem.casefold() for path in paths] if len(stems) != len(set(stems)): raise RuntimeError("validation image stems are not unique; flatten/rename the split before parity checking") return paths @@ -203,7 +239,10 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--mnn", type=Path, default=Path("models/esmoe_n_visdrone.mnn")) parser.add_argument("--onnx", type=Path, default=Path("models/esmoe_n_visdrone_sim.onnx")) parser.add_argument("--images", type=Path, default=Path("/data/datasets/VisDrone/images/val")) - parser.add_argument("--n", type=int, default=100) + parser.add_argument( + "--limit", "--n", dest="n", type=int, default=100, + help="number of ordered images to compare (default: 100; --n is kept as a compatibility alias)", + ) parser.add_argument("--imgsz", type=int, default=640) parser.add_argument("--threads", type=int, default=4) parser.add_argument("--nc", type=int, default=10) diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/mnn_val.py b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/mnn_val.py index 93e229875..eef99a5ee 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/mnn_val.py +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/mnn_val.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Run MNN over a validation directory and dump C++-compatible predictions.""" +"""Run MNN over a fixed validation set and dump C++-compatible predictions.""" from __future__ import annotations @@ -10,6 +10,60 @@ # Keep the validation list identical to the portable C++ runner's stb decoder. IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".bmp"} +LIST_EXTS = {".txt", ".list"} + + +def image_list(source: Path, limit: int) -> list[Path]: + """Resolve a directory, image, or ordered UTF-8 image list.""" + source = source.expanduser() + if not source.exists(): + raise FileNotFoundError(f"validation source not found: {source}") + if source.is_file() and source.suffix.lower() in IMAGE_EXTS: + paths = [source.resolve()] + elif source.is_file() and source.suffix.lower() in LIST_EXTS: + base = source.resolve().parent + paths = [] + with source.open("r", encoding="utf-8-sig") as handle: + for line_number, raw in enumerate(handle, 1): + value = raw.strip() + if not value or value.startswith("#"): + continue + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1].strip() + path = Path(value).expanduser() + if not path.is_absolute(): + path = base / path + path = path.resolve() + if not path.is_file(): + raise FileNotFoundError( + f"image list line {line_number} does not name a file: {path}" + ) + if path.suffix.lower() not in IMAGE_EXTS: + raise ValueError( + f"unsupported image extension at list line {line_number}: {path}" + ) + paths.append(path) + elif source.is_dir(): + paths = sorted( + (path.resolve() for path in source.rglob("*") + if path.is_file() and path.suffix.lower() in IMAGE_EXTS), + # Match the case-folded, separator-normalized order used by the + # C++ runner, evaluator, and evidence manifest. The original + # spelling is a deterministic tie-break for case-sensitive hosts. + key=lambda path: (path.as_posix().casefold(), path.as_posix()), + ) + else: + raise ValueError(f"unsupported validation source: {source}") + if limit > 0: + paths = paths[:limit] + if not paths: + raise RuntimeError(f"no validation images found under {source}") + stems = [path.stem.casefold() for path in paths] + if len(stems) != len(set(stems)): + raise RuntimeError( + "validation image stems are not unique; flatten/rename the split before dumping predictions" + ) + return paths def status_failed(code) -> bool: @@ -90,7 +144,10 @@ def nms(boxes, scores, iou_thr: float, max_keep: int = 300): return [] x1, y1, x2, y2 = boxes.T areas = np.maximum(0, x2 - x1) * np.maximum(0, y2 - y1) - order = np.argsort(scores)[::-1] + # Stable score-descending order with an explicit original-index tie-break; + # default quicksort ordering can vary between NumPy builds and change NMS + # survivors when quantized outputs contain equal scores. + order = np.lexsort((np.arange(scores.size, dtype=np.int64), -scores)) keep = [] while order.size: current = int(order[0]) @@ -109,7 +166,7 @@ def nms(boxes, scores, iou_thr: float, max_keep: int = 300): return keep -def class_nms_offset(width: int, height: int) -> float: +def class_nms_offset(width: int, height: int, boxes=None) -> float: """Return the class-stratification offset used by the C++ decoder. Boxes are shifted before a single greedy NMS pass. The inverse letterbox @@ -118,7 +175,19 @@ def class_nms_offset(width: int, height: int) -> float: """ if width <= 0 or height <= 0: raise ValueError("image dimensions must be positive") - return 2.0 * max(width, height) + 8192.0 + # Match the C++ runner: derive the separation from the actual unclipped + # candidates so a finite out-of-frame prediction cannot make classes + # overlap after stratification. Keep the historical frame-based value + # when no candidate array is supplied for compatibility with callers. + extent = float(max(width, height)) + if boxes is not None: + import numpy as np + values = np.asarray(boxes, dtype=np.float64) + if values.size: + if values.ndim != 2 or values.shape[1] != 4 or not np.isfinite(values).all(): + raise ValueError("boxes must be a finite Nx4 array") + extent = max(extent, float(np.max(np.abs(values)))) + return 2.0 * extent + 1.0 if boxes is not None else 2.0 * extent + 8192.0 def get_session_output(interpreter, session): @@ -192,20 +261,7 @@ def main() -> int: raise ValueError("conf/iou/small-conf/small-area must be finite") if not 0 <= args.conf <= 1 or not 0 <= args.iou <= 1 or not -1 <= args.small_conf <= 1 or args.small_area < 0: raise ValueError("conf/iou must be in [0,1], small-conf in [-1,1], and small-area non-negative") - if args.images.is_file(): - image_paths = [args.images] if args.images.suffix.lower() in IMAGE_EXTS else [] - else: - image_paths = sorted( - path for path in args.images.rglob("*") - if path.is_file() and path.suffix.lower() in IMAGE_EXTS - ) - if args.limit > 0: - image_paths = image_paths[: args.limit] - if not image_paths: - raise RuntimeError(f"no validation images found under {args.images}") - stems = [path.stem for path in image_paths] - if len(stems) != len(set(stems)): - raise RuntimeError("validation image stems are not unique; flatten/rename the split before dumping predictions") + image_paths = image_list(args.images, args.limit) import MNN import numpy as np @@ -318,9 +374,9 @@ def run(batch): if scores.size > 30000: # Match Ultralytics' max_nms guard before the quadratic # suppression loop, keeping every parallel array aligned. - order = np.argsort(scores)[::-1][:30000] + order = np.lexsort((np.arange(scores.size, dtype=np.int64), -scores))[:30000] class_ids, scores, xyxy = class_ids[order], scores[order], xyxy[order] - shifted = xyxy + class_ids[:, None] * class_nms_offset(width, height) + shifted = xyxy + class_ids[:, None] * class_nms_offset(width, height, xyxy) keep = nms(shifted, scores, args.iou, args.max_det) else: keep, scores, xyxy = [], np.empty(0), np.empty((0, 4)) diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/package_linux.sh b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/package_linux.sh index 316556d87..d44b2f45a 100755 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/package_linux.sh +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/package_linux.sh @@ -4,9 +4,14 @@ # usage: package_linux.sh [cpu|gpu] [version] # package_linux.sh cpu 1.1.0 -> dist/yolomaster-edge-linux-x64-1.1.0.tar.gz # package_linux.sh gpu 1.1.0 -> dist/yolomaster-edge-linux-x64-gpu_cuda12-1.1.0.tar.gz +# optional SDK overrides: +# NCNN_ROOT=/opt/ncnn MNN_ROOT=/opt/mnn package_linux.sh cpu 1.1.0 # -# Both bundles carry all three backends (ONNX Runtime / ncnn / MNN) and full video -# support. OpenCV comes from a LEAN source build (core/imgproc/imgcodecs/videoio, +# Bundles carry ONNX Runtime plus every optional backend available in the staging +# tree (ncnn and/or MNN) and full video support. Issue #51 requires at least one +# of ncnn or MNN; the script selects that set automatically instead of requiring +# an SDK the caller did not install. OpenCV comes from a LEAN source build +# (core/imgproc/imgcodecs/videoio, # ffmpeg on, GStreamer/GDAL/GUI off) that this script builds once into # third_party/opencv-lean - the stock Ubuntu OpenCV would drag ~237 shared libraries # (GStreamer/GDAL/MySQL/X11) into the closure. The ffmpeg codec stack is bundled from @@ -32,18 +37,67 @@ case "$VARIANT" in cpu|gpu) ;; *) echo "usage: package_linux.sh [cpu|gpu] [versi ROOT="$(cd "$(dirname "$0")/.." && pwd)" if [ "$VARIANT" = gpu ]; then NAME="yolomaster-edge-linux-x64-gpu_cuda12-$VERSION" - ORT_ROOT="$ROOT/third_party/onnxruntime-linux-x64-gpu-1.20.1" + DEFAULT_ORT_ROOT="$ROOT/third_party/onnxruntime-linux-x64-gpu-1.20.1" else NAME="yolomaster-edge-linux-x64-$VERSION" - ORT_ROOT="$ROOT/third_party/onnxruntime-linux-x64-1.18.1" + DEFAULT_ORT_ROOT="$ROOT/third_party/onnxruntime-linux-x64-1.18.1" fi +ORT_ROOT="${ORT_ROOT:-$DEFAULT_ORT_ROOT}" DIST="$ROOT/dist/$NAME" BUILD="$ROOT/cpp/build_pkg-$VARIANT" OCV="$ROOT/third_party/opencv-lean" +NCNN_ROOT="${NCNN_ROOT:-$ROOT/third_party/ncnn}" +MNN_ROOT="${MNN_ROOT:-$ROOT/third_party/mnn-src}" -command -v patchelf >/dev/null 2>&1 || { echo "== installing patchelf =="; apt-get install -y patchelf || sudo apt-get install -y patchelf; } +command -v patchelf >/dev/null 2>&1 || { + echo "ERROR: patchelf is required to create a relocatable bundle." >&2 + echo " Install it with the host package manager, then rerun this script." >&2 + exit 1 +} [ -d "$ORT_ROOT/lib" ] || { echo "ERROR: ONNX Runtime not found at $ORT_ROOT"; exit 1; } +# Resolve optional secondary backends before configuring CMake. A release is +# valid with either NCNN or MNN (the Issue #51 requirement); if both are staged, +# both are included. Header-only trees are not sufficient because CMake needs a +# linkable library as well. Accept both installed SDKs and the uninstalled layouts +# produced by upstream CMake builds (NCNN build/src/, MNN build/{,Release,Debug}/). +# Keep this probe in sync with cpp/CMakeLists.txt so discovery cannot pass while +# configuration subsequently fails. +find_backend_library() { + local root="$1" name="$2" + find "$root" -maxdepth 4 \( -type f -o -type l \) \ + \( -name "lib${name}.a" -o -name "lib${name}.so" -o -name "lib${name}.so.*" \) \ + -print -quit 2>/dev/null || true +} +NCNN_LIB_PATH="$(find_backend_library "$NCNN_ROOT" ncnn)" +NCNN_AVAILABLE=0 +if [ -f "$NCNN_ROOT/include/ncnn/net.h" ] && [ -n "$NCNN_LIB_PATH" ]; then + NCNN_AVAILABLE=1 +fi +MNN_LIB_PATH="$(find_backend_library "$MNN_ROOT" MNN)" +MNN_AVAILABLE=0 +if [ -f "$MNN_ROOT/include/MNN/Interpreter.hpp" ] && [ -n "$MNN_LIB_PATH" ]; then + MNN_AVAILABLE=1 +fi +if [ "$NCNN_AVAILABLE" -eq 0 ] && [ "$MNN_AVAILABLE" -eq 0 ]; then + echo "ERROR: neither NCNN nor MNN SDK is available; stage one backend and rerun." >&2 + echo " NCNN_ROOT=$NCNN_ROOT" >&2 + echo " MNN_ROOT=$MNN_ROOT" >&2 + exit 1 +fi +if [ "$NCNN_AVAILABLE" -eq 0 ]; then + echo " [warn] NCNN SDK unavailable; packaging MNN only" +fi +if [ "$MNN_AVAILABLE" -eq 0 ]; then + echo " [warn] MNN SDK unavailable; packaging NCNN only" +fi +if [ "$NCNN_AVAILABLE" -eq 1 ]; then + echo " [ok] NCNN library: $NCNN_LIB_PATH" +fi +if [ "$MNN_AVAILABLE" -eq 1 ]; then + echo " [ok] MNN library: $MNN_LIB_PATH" +fi + # ---- 0/6: lean OpenCV (built once, cached) --------------------------------------- if [ ! -f "$OCV/lib/cmake/opencv4/OpenCVConfig.cmake" ]; then echo "== 0/6 building lean OpenCV (one-time, ~15 min) ==" @@ -63,8 +117,20 @@ fi # ---- 1/6: clean release build ----------------------------------------------------- echo "== 1/6 clean $VARIANT release build (ORT: $(basename "$ORT_ROOT")) ==" rm -rf "$BUILD" +BACKEND_ARGS=(-DREQUIRE_ORT=ON -DALLOW_NO_BACKENDS=OFF) +if [ "$NCNN_AVAILABLE" -eq 1 ]; then + BACKEND_ARGS+=( -DNCNN_ROOT="$NCNN_ROOT" -DREQUIRE_NCNN=ON ) +else + BACKEND_ARGS+=( -DUSE_NCNN=OFF -DREQUIRE_NCNN=OFF ) +fi +if [ "$MNN_AVAILABLE" -eq 1 ]; then + BACKEND_ARGS+=( -DMNN_ROOT="$MNN_ROOT" -DREQUIRE_MNN=ON ) +else + BACKEND_ARGS+=( -DUSE_MNN=OFF -DREQUIRE_MNN=OFF ) +fi cmake -S "$ROOT/cpp" -B "$BUILD" -DCMAKE_BUILD_TYPE=Release \ - -DONNXRUNTIME_ROOT="$ORT_ROOT" -DOpenCV_DIR="$OCV/lib/cmake/opencv4" + -DONNXRUNTIME_ROOT="$ORT_ROOT" "${BACKEND_ARGS[@]}" \ + -DOpenCV_DIR="$OCV/lib/cmake/opencv4" cmake --build "$BUILD" -j"$(nproc)" # ---- 2/6: stage binary + .so closure ---------------------------------------------- @@ -74,11 +140,24 @@ cp "$BUILD/yolomaster_edge" "$DIST/yolomaster_edge" # glibc / dynamic-loader core: MUST come from the target system, never bundle. EXCLUDE='libc\.so|libm\.so|libdl\.so|librt\.so|libpthread\.so|ld-linux|libresolv\.so|linux-vdso' -ldd "$DIST/yolomaster_edge" | awk '{print $3}' | grep -E '^/' | sort -u | while read -r so; do - base="$(basename "$so")" - echo "$base" | grep -qE "$EXCLUDE" && continue - cp -L "$so" "$DIST/lib/$base" -done +# Walk the complete ELF dependency closure. A single ldd pass misses libraries +# needed by a backend or by a codec library several levels below the executable. +declare -A SEEN_LIBS=() +copy_closure() { + local object="$1" so base dep + [ -f "$object" ] || return 0 + while read -r so; do + [ -n "$so" ] || continue + base="$(basename "$so")" + echo "$base" | grep -qE "$EXCLUDE" && continue + if [ -z "${SEEN_LIBS[$base]+x}" ]; then + SEEN_LIBS[$base]=1 + cp -L "$so" "$DIST/lib/$base" + copy_closure "$so" + fi + done < <(ldd "$object" 2>/dev/null | awk '/=> \/|^[[:space:]]*\// {for (i=1;i<=NF;i++) if ($i ~ /^\//) {print $i; break}}' | sort -u) +} +copy_closure "$DIST/yolomaster_edge" # ---- 3/6: GPU extras (dlopened provider + the CUDA/cuDNN runtime it hard-links) ---- if [ "$VARIANT" = gpu ]; then @@ -123,6 +202,11 @@ if [ "$VARIANT" = gpu ]; then echo " set CUDA_LIB_DIRS=\"/path/one /path/two\" and re-run." exit 1 fi + # Resolve dependencies introduced by the provider itself (and by CUDA/cuDNN + # libraries found above), not only those visible from the main executable. + # Otherwise a missing transitive .so can make ORT silently fall back to CPU. + copy_closure "$DIST/lib/libonnxruntime_providers_cuda.so" + copy_closure "$DIST/lib/libonnxruntime_providers_shared.so" fi # ---- 4/6: rpaths + models + README ------------------------------------------------ @@ -130,10 +214,15 @@ echo "== 4/6 rpaths, models, README ==" patchelf --set-rpath '$ORIGIN/lib' "$DIST/yolomaster_edge" for l in "$DIST"/lib/*.so*; do patchelf --set-rpath '$ORIGIN' "$l" 2>/dev/null || true; done -for m in v0.1-seg-n.onnx v0.1-seg-n.mnn v0.1-seg-n.metadata.yaml; do +for m in v0.1-seg-n.onnx v0.1-seg-n.metadata.yaml; do cp "$ROOT/models/$m" "$DIST/models/" 2>/dev/null || echo " [warn] model missing: $m" done -cp -r "$ROOT/models/v0.1-seg-n_ncnn" "$DIST/models/" 2>/dev/null || echo " [warn] model missing: v0.1-seg-n_ncnn" +if [ "$MNN_AVAILABLE" -eq 1 ]; then + cp "$ROOT/models/v0.1-seg-n.mnn" "$DIST/models/" 2>/dev/null || echo " [warn] model missing: v0.1-seg-n.mnn" +fi +if [ "$NCNN_AVAILABLE" -eq 1 ]; then + cp -r "$ROOT/models/v0.1-seg-n_ncnn" "$DIST/models/" 2>/dev/null || echo " [warn] model missing: v0.1-seg-n_ncnn" +fi [ -f "$DIST/models/v0.1-seg-n.onnx" ] || { echo "ERROR: default model v0.1-seg-n.onnx missing - the README quick start would not work"; exit 1; } GPU_NOTE="" @@ -147,13 +236,14 @@ right choice when you do not need ONNX-on-GPU." cat > "$DIST/README.txt" <=2.35 (Ubuntu 22.04+) x86_64, no install needed. -Backends: ONNX Runtime / ncnn (GPU via Vulkan when a driver is present) / MNN. -Detection and segmentation; image, folder, dataset.yaml and video sources (ffmpeg). +Backends: ONNX Runtime$([ "$NCNN_AVAILABLE" -eq 1 ] && printf ' / ncnn (GPU via Vulkan when a driver is present)')$([ "$MNN_AVAILABLE" -eq 1 ] && printf ' / MNN'). +Detection and segmentation; image, folder, newline-delimited .txt list, +dataset.yaml and video sources (ffmpeg). $GPU_NOTE Quick start: ./yolomaster_edge -m models/v0.1-seg-n.onnx -s --out out - ./yolomaster_edge -m models/v0.1-seg-n_ncnn -s --out out - ./yolomaster_edge -m models/v0.1-seg-n.mnn -s --out out +$(if [ "$NCNN_AVAILABLE" -eq 1 ]; then printf ' ./yolomaster_edge -m models/v0.1-seg-n_ncnn -s --out out\n'; fi) +$(if [ "$MNN_AVAILABLE" -eq 1 ]; then printf ' ./yolomaster_edge -m models/v0.1-seg-n.mnn -s --out out\n'; fi) New in 1.1.0: --slicing off|dense|sparse sliced inference (Sparse SAHI) for small objects @@ -164,7 +254,7 @@ New in 1.1.0: --label-format yolo|coco|voc --sampling all|1s|N (video frame sampling) All flags: ./yolomaster_edge --help -License: AGPL-3.0. (c) 2026 Thomas Li. https://github.com/skywalker-lt/yolo-master-edge +License: AGPL-3.0. See the repository LICENSE file. EOF # ---- 5/6: self-test the staged bundle ---------------------------------------------- @@ -177,7 +267,11 @@ if ldd "$TESTDIR/b/yolomaster_edge" | grep -q "not found"; then fi TEST_IMG="$(ls "$ROOT"/visdrone50/images/val/*.jpg 2>/dev/null | head -1 || true)" if [ -n "$TEST_IMG" ]; then - for mdl in models/v0.1-seg-n.onnx models/v0.1-seg-n_ncnn models/v0.1-seg-n.mnn; do + MODELS=(models/v0.1-seg-n.onnx) + [ "$NCNN_AVAILABLE" -eq 1 ] && MODELS+=(models/v0.1-seg-n_ncnn) + [ "$MNN_AVAILABLE" -eq 1 ] && MODELS+=(models/v0.1-seg-n.mnn) + for mdl in "${MODELS[@]}"; do + [ -e "$TESTDIR/b/$mdl" ] || { echo " [warn] model missing, skipping: $mdl"; continue; } run_clean -m "$TESTDIR/b/$mdl" -s "$TEST_IMG" --no-save --quiet >/dev/null \ && echo " [ok] inference: $mdl" \ || { echo "SELF-TEST FAILED: $mdl"; exit 1; } @@ -193,7 +287,7 @@ if [ "$VARIANT" = gpu ]; then echo " [ok] CUDA provider closure resolves" if command -v nvidia-smi >/dev/null 2>&1 && [ -n "$TEST_IMG" ]; then OUT="$(run_clean -m "$TESTDIR/b/models/v0.1-seg-n.onnx" -s "$TEST_IMG" -d cuda --no-save --quiet 2>&1 || true)" - echo "$OUT" | grep -q "ep=cuda" && echo " [ok] --device cuda runs on the GPU" \ + echo "$OUT" | grep -qiE "ep=(cuda|tensorrt)" && echo " [ok] --device cuda runs on the GPU" \ || { echo "SELF-TEST FAILED: --device cuda fell back:"; echo "$OUT" | head -5; exit 1; } else echo " [warn] no GPU on this host - --device cuda validation deferred" diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/prediction_diff.py b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/prediction_diff.py new file mode 100644 index 000000000..9aa7bb531 --- /dev/null +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/prediction_diff.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +"""Diagnose per-image differences between two YOLO prediction directories. + +The tool is intentionally independent of torch, OpenCV, and runtime SDKs. It +consumes the pixel-coordinate text format emitted by the C++ runner +(``class confidence x1 y1 x2 y2``), matches detections by class and IoU, and +keeps enough detail to investigate an accuracy-gate failure without rerunning +either backend. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + + +@dataclass(frozen=True) +class Prediction: + class_id: int + confidence: float + x1: float + y1: float + x2: float + y2: float + + @property + def box(self) -> tuple[float, float, float, float]: + return (self.x1, self.y1, self.x2, self.y2) + + +@dataclass(frozen=True) +class Match: + reference_index: int + candidate_index: int + iou: float + confidence_abs_delta: float + box_max_abs_delta: float + + +def _finite(value: str, field: str, path: Path, line_no: int) -> float: + try: + result = float(value) + except ValueError as exc: + raise ValueError(f"{path}:{line_no}: invalid {field}: {value!r}") from exc + if not math.isfinite(result): + raise ValueError(f"{path}:{line_no}: {field} must be finite") + return result + + +def read_predictions(path: Path) -> list[Prediction]: + """Read one YOLO pixel-xyxy prediction file with strict validation.""" + if not path.is_file(): + raise FileNotFoundError(f"prediction file not found: {path}") + result: list[Prediction] = [] + for line_no, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + line = raw.strip() + if not line or line.startswith("#"): + continue + fields = line.split() + if len(fields) != 6: + raise ValueError( + f"{path}:{line_no}: expected exactly 6 columns (class conf x1 y1 x2 y2), got {len(fields)}" + ) + try: + class_id = int(fields[0]) + except ValueError as exc: + raise ValueError(f"{path}:{line_no}: class id must be an integer") from exc + if class_id < 0: + raise ValueError(f"{path}:{line_no}: class id must be non-negative") + confidence = _finite(fields[1], "confidence", path, line_no) + x1 = _finite(fields[2], "x1", path, line_no) + y1 = _finite(fields[3], "y1", path, line_no) + x2 = _finite(fields[4], "x2", path, line_no) + y2 = _finite(fields[5], "y2", path, line_no) + if confidence < 0.0 or confidence > 1.0: + raise ValueError(f"{path}:{line_no}: confidence must be in [0,1]") + if x2 <= x1 or y2 <= y1: + raise ValueError(f"{path}:{line_no}: box must have positive width and height") + result.append(Prediction(class_id, confidence, x1, y1, x2, y2)) + return result + + +def image_stem(path: Path) -> str: + return path.stem.casefold() + + +def prediction_files(directory: Path) -> dict[str, Path]: + if not directory.is_dir(): + raise NotADirectoryError(f"prediction directory not found: {directory}") + files = sorted( + (path for path in directory.rglob("*") if path.is_file() and path.suffix.casefold() == ".txt"), + key=lambda path: path.as_posix().casefold(), + ) + result: dict[str, Path] = {} + for path in files: + stem = image_stem(path) + if not stem: + raise ValueError(f"prediction file has an empty stem: {path}") + if stem in result: + raise ValueError(f"prediction stems are not unique: {stem}") + result[stem] = path + return result + + +def image_files(source: Path, root: Path | None = None) -> dict[str, Path]: + """Resolve an image directory or list below one canonical root. + + The list syntax deliberately matches the mAP evaluators and evidence + manifest: UTF-8 BOMs, comments and quoted paths are accepted, while a + caller-supplied root prevents a list from reaching outside the dataset + tree. Keeping this parser identical is important because this tool is + normally used to explain a failed cross-backend comparison. + """ + extensions = {".jpg", ".jpeg", ".png", ".bmp"} + source = source.expanduser() + explicit_root = root.expanduser().resolve() if root is not None else None + if explicit_root is not None and not explicit_root.is_dir(): + raise NotADirectoryError(f"image normalization root not found: {explicit_root}") + + def finish(default_root: Path, paths: list[Path]) -> list[Path]: + image_root = (explicit_root or default_root).resolve() + resolved = [path.resolve() for path in paths] + for path in resolved: + try: + path.relative_to(image_root) + except ValueError as exc: + raise ValueError( + f"image {path} is outside evaluation root {image_root}; " + "pass --image-root containing every listed image" + ) from exc + return resolved + + if source.is_dir(): + paths = sorted( + (path for path in source.rglob("*") if path.is_file() and path.suffix.casefold() in extensions), + key=lambda path: path.as_posix().casefold(), + ) + elif source.is_file(): + paths = [] + base = source.resolve().parent + try: + lines = source.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError as exc: + raise ValueError(f"{source}: image list must be UTF-8 text") from exc + for line_no, raw in enumerate(lines, 1): + line = raw.strip() + if line_no == 1: + line = line.lstrip("\ufeff") + if not line or line.startswith("#"): + continue + if len(line) >= 2 and line[0] == line[-1] and line[0] in {'"', "'"}: + line = line[1:-1].strip() + path = Path(line).expanduser() + resolved = (base / path if not path.is_absolute() else path).resolve() + if resolved.suffix.casefold() not in extensions or not resolved.is_file(): + raise ValueError(f"{source}:{line_no}: image list entry is not a supported image: {line}") + paths.append(resolved) + else: + raise FileNotFoundError(f"image source not found: {source}") + paths = finish(source if source.is_dir() else source.parent, paths) + result: dict[str, Path] = {} + for path in paths: + stem = image_stem(path) + if stem in result: + raise ValueError(f"validation image stems are not unique: {stem}") + result[stem] = path + if not result: + raise ValueError(f"no images found under {source}") + return result + + +def box_iou(a: Prediction, b: Prediction) -> float: + ix1 = max(a.x1, b.x1) + iy1 = max(a.y1, b.y1) + ix2 = min(a.x2, b.x2) + iy2 = min(a.y2, b.y2) + inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1) + area_a = max(0.0, a.x2 - a.x1) * max(0.0, a.y2 - a.y1) + area_b = max(0.0, b.x2 - b.x1) * max(0.0, b.y2 - b.y1) + union = area_a + area_b - inter + return inter / union if union > 0.0 else 0.0 + + +def match_predictions( + reference: Iterable[Prediction], candidate: Iterable[Prediction], iou_threshold: float +) -> list[Match]: + """Greedily match same-class detections by descending IoU. + + Candidate/reference ordering in text files must not affect the result: all + eligible pairs are sorted by IoU, confidence, and indices before matching. + """ + if not math.isfinite(iou_threshold) or not 0.0 <= iou_threshold <= 1.0: + raise ValueError("IoU threshold must be finite and in [0,1]") + refs = list(reference) + cands = list(candidate) + pairs: list[tuple[float, float, int, int]] = [] + for ri, ref in enumerate(refs): + for ci, cand in enumerate(cands): + if ref.class_id != cand.class_id: + continue + overlap = box_iou(ref, cand) + if overlap >= iou_threshold: + pairs.append((overlap, min(ref.confidence, cand.confidence), ri, ci)) + pairs.sort(key=lambda item: (-item[0], -item[1], item[2], item[3])) + used_refs: set[int] = set() + used_cands: set[int] = set() + matches: list[Match] = [] + for overlap, _, ri, ci in pairs: + if ri in used_refs or ci in used_cands: + continue + used_refs.add(ri) + used_cands.add(ci) + ref, cand = refs[ri], cands[ci] + box_delta = max(abs(a - b) for a, b in zip(ref.box, cand.box)) + matches.append(Match(ri, ci, overlap, abs(ref.confidence - cand.confidence), box_delta)) + return matches + + +def _percentile(values: list[float], pct: float) -> float: + if not math.isfinite(pct) or not 0.0 <= pct <= 100.0: + raise ValueError("percentile must be finite and in [0,100]") + if not values: + return 0.0 + values = sorted(values) + rank = max(1, math.ceil(pct * len(values) / 100.0)) + return values[min(len(values) - 1, rank - 1)] + + +def _iou_statistics(values: list[float]) -> dict[str, float | int]: + """Return stable summary statistics for matched IoUs. + + Nearest-rank percentiles are used so that a report is deterministic for a + small validation subset and does not depend on a numerical interpolation + convention. Empty matches are represented by zero-valued statistics and + an explicit count, which makes a minimum-IoU gate fail closed. + """ + return { + "matched_iou_count": len(values), + "mean_iou": sum(values) / len(values) if values else 0.0, + "min_iou": min(values, default=0.0), + "p05_iou": _percentile(values, 5.0), + "p50_iou": _percentile(values, 50.0), + "p95_iou": _percentile(values, 95.0), + "p99_iou": _percentile(values, 99.0), + } + + +def compare_image(reference: list[Prediction], candidate: list[Prediction], iou_threshold: float) -> dict[str, object]: + matches = match_predictions(reference, candidate, iou_threshold) + matched_refs = {match.reference_index for match in matches} + matched_cands = {match.candidate_index for match in matches} + conf = [match.confidence_abs_delta for match in matches] + boxes = [match.box_max_abs_delta for match in matches] + ious = [match.iou for match in matches] + unmatched_ref = len(reference) - len(matched_refs) + unmatched_candidate = len(candidate) - len(matched_cands) + # Count mismatches dominate the ranking; confidence/coordinate deltas make + # otherwise equal-count images useful in the Top-K diagnostic list. + score = ( + unmatched_ref + + unmatched_candidate + + max(boxes, default=0.0) + + max(conf, default=0.0) + ) + return { + "reference_count": len(reference), + "candidate_count": len(candidate), + "matched": len(matches), + "unmatched_reference": unmatched_ref, + "unmatched_candidate": unmatched_candidate, + **_iou_statistics(ious), + "mean_confidence_abs_delta": sum(conf) / len(conf) if conf else 0.0, + "max_confidence_abs_delta": max(conf, default=0.0), + "p95_confidence_abs_delta": _percentile(conf, 95.0), + "mean_box_max_abs_delta": sum(boxes) / len(boxes) if boxes else 0.0, + "max_box_max_abs_delta": max(boxes, default=0.0), + "p95_box_max_abs_delta": _percentile(boxes, 95.0), + "difference_score": score, + } + + +def _write_csv(path: Path, rows: list[dict[str, object]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fields = [ + "image", "reference_count", "candidate_count", "matched", + "unmatched_reference", "unmatched_candidate", "mean_iou", "min_iou", + "matched_iou_count", "p05_iou", "p50_iou", "p95_iou", "p99_iou", + "mean_confidence_abs_delta", "max_confidence_abs_delta", + "mean_box_max_abs_delta", "max_box_max_abs_delta", "difference_score", + ] + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerows({field: row.get(field, "") for field in fields} for row in rows) + + +def _write_debug_images( + debug_dir: Path, rows: list[dict[str, object]], image_map: dict[str, Path], + refs: dict[str, list[Prediction]], cands: dict[str, list[Prediction]], top_k: int, +) -> str | None: + try: + from PIL import Image, ImageDraw + except ImportError: + return "Pillow is not installed; --debug-dir was skipped" + debug_dir.mkdir(parents=True, exist_ok=True) + selected = sorted(rows, key=lambda row: (-float(row["difference_score"]), str(row["image"])))[:top_k] + for row in selected: + stem = str(row["image"]) + source = image_map.get(stem) + if source is None: + continue + image = Image.open(source).convert("RGB") + draw = ImageDraw.Draw(image) + for pred in refs.get(stem, []): + draw.rectangle(pred.box, outline=(40, 190, 90), width=2) + for pred in cands.get(stem, []): + draw.rectangle(pred.box, outline=(235, 80, 70), width=2) + image.save(debug_dir / f"{stem}.jpg", quality=92) + return None + + +def compare_directories( + reference_dir: Path, + candidate_dir: Path, + *, + image_source: Path | None = None, + image_root: Path | None = None, + iou_threshold: float = 0.5, + allow_missing: bool = False, + top_k: int = 20, + min_iou: float | None = None, +) -> dict[str, object]: + if not math.isfinite(iou_threshold) or not 0.0 <= iou_threshold <= 1.0: + raise ValueError("matching IoU threshold must be finite and in [0,1]") + if min_iou is not None and (not math.isfinite(min_iou) or not 0.0 <= min_iou <= 1.0): + raise ValueError("minimum IoU gate must be finite and in [0,1]") + ref_files = prediction_files(reference_dir) + cand_files = prediction_files(candidate_dir) + image_map = image_files(image_source, image_root) if image_source is not None else {} + expected = set(image_map) if image_map else set(ref_files) | set(cand_files) + if not expected: + raise ValueError("both prediction directories are empty") + if not allow_missing: + missing_ref = sorted(expected - set(ref_files)) + missing_cand = sorted(expected - set(cand_files)) + extra_ref = sorted(set(ref_files) - expected) if image_map else [] + extra_cand = sorted(set(cand_files) - expected) if image_map else [] + if missing_ref or missing_cand or extra_ref or extra_cand: + details = [] + if missing_ref: details.append("reference missing: " + ", ".join(missing_ref[:5])) + if missing_cand: details.append("candidate missing: " + ", ".join(missing_cand[:5])) + if extra_ref: details.append("reference has unexpected stems: " + ", ".join(extra_ref[:5])) + if extra_cand: details.append("candidate has unexpected stems: " + ", ".join(extra_cand[:5])) + raise ValueError("prediction file sets differ; " + "; ".join(details)) + rows: list[dict[str, object]] = [] + references: dict[str, list[Prediction]] = {} + candidates: dict[str, list[Prediction]] = {} + for stem in sorted(expected): + ref = read_predictions(ref_files[stem]) if stem in ref_files else [] + cand = read_predictions(cand_files[stem]) if stem in cand_files else [] + references[stem], candidates[stem] = ref, cand + row = {"image": stem, **compare_image(ref, cand, iou_threshold)} + rows.append(row) + rows_by_difference = sorted(rows, key=lambda row: (-float(row["difference_score"]), str(row["image"]))) + total_ref = sum(int(row["reference_count"]) for row in rows) + total_cand = sum(int(row["candidate_count"]) for row in rows) + total_matched = sum(int(row["matched"]) for row in rows) + all_ious = [ + float(match.iou) + for stem in sorted(expected) + for match in match_predictions(references[stem], candidates[stem], iou_threshold) + ] + iou_summary = _iou_statistics(all_ious) + summary = { + "images": len(rows), + "reference_detections": total_ref, + "candidate_detections": total_cand, + "matched_detections": total_matched, + "unmatched_reference": sum(int(row["unmatched_reference"]) for row in rows), + "unmatched_candidate": sum(int(row["unmatched_candidate"]) for row in rows), + "images_with_count_difference": sum( + int(row["reference_count"]) != int(row["candidate_count"]) for row in rows + ), + "max_confidence_abs_delta": max(float(row["max_confidence_abs_delta"]) for row in rows), + "max_box_max_abs_delta": max(float(row["max_box_max_abs_delta"]) for row in rows), + **iou_summary, + } + summary["min_iou_gate_passed"] = ( + min_iou is None or float(iou_summary["min_iou"]) >= min_iou + ) + protocol = {"match_iou": iou_threshold, "box_format": "pixel_xyxy", "class_aware": True} + if min_iou is not None: + protocol["min_match_iou"] = min_iou + return { + "schema_version": 1, + "protocol": protocol, + "summary": summary, + "images": rows, + "top_differences": rows_by_difference[: max(1, top_k)], + "_image_map": image_map, + "_references": references, + "_candidates": candidates, + } + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--reference", type=Path, required=True, help="reference YOLO TXT directory") + parser.add_argument("--candidate", type=Path, required=True, help="candidate YOLO TXT directory") + parser.add_argument("--images", type=Path, help="validation image directory or ordered image list") + parser.add_argument( + "--image-root", type=Path, + help="explicit root containing every listed image (for portable path checks)", + ) + parser.add_argument("--iou", type=float, default=0.5, help="minimum same-class IoU for matching") + parser.add_argument("--top-k", type=int, default=20, help="number of largest per-image differences to retain") + parser.add_argument("--allow-missing", action="store_true", help="treat a missing prediction file as empty") + parser.add_argument("--json", type=Path, help="write full machine-readable report") + parser.add_argument("--csv", type=Path, help="write per-image CSV report") + parser.add_argument("--debug-dir", type=Path, help="optional PIL visualizations for top differences") + parser.add_argument("--max-unmatched", type=int, default=None, help="fail if total unmatched detections exceed this") + parser.add_argument("--max-box-delta", type=float, default=None, help="fail if any matched box coordinate delta exceeds this") + parser.add_argument("--max-conf-delta", type=float, default=None, help="fail if any matched confidence delta exceeds this") + parser.add_argument( + "--min-iou", "--min-match-iou", dest="min_iou", type=float, default=None, + help="fail when the minimum IoU among matched detections is below this value", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + if args.top_k <= 0: + raise ValueError("--top-k must be positive") + for name in ("max_unmatched", "max_box_delta", "max_conf_delta"): + value = getattr(args, name) + if value is not None and (not math.isfinite(float(value)) or value < 0): + raise ValueError(f"--{name.replace('_', '-')} must be finite and non-negative") + if args.min_iou is not None and (not math.isfinite(args.min_iou) or not 0.0 <= args.min_iou <= 1.0): + raise ValueError("--min-iou must be finite and in [0,1]") + report = compare_directories( + args.reference, args.candidate, image_source=args.images, + image_root=args.image_root, + iou_threshold=args.iou, allow_missing=args.allow_missing, top_k=args.top_k, + min_iou=args.min_iou, + ) + image_map = report.pop("_image_map") + references = report.pop("_references") + candidates = report.pop("_candidates") + if args.json: + args.json.parent.mkdir(parents=True, exist_ok=True) + args.json.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + if args.csv: + _write_csv(args.csv, report["images"]) + warning = None + if args.debug_dir: + warning = _write_debug_images(args.debug_dir, report["images"], image_map, references, candidates, args.top_k) + print(json.dumps({"summary": report["summary"], "top_differences": report["top_differences"]}, indent=2)) + if warning: + print(f"warning: {warning}") + summary = report["summary"] + unmatched = int(summary["unmatched_reference"]) + int(summary["unmatched_candidate"]) + if args.max_unmatched is not None and unmatched > args.max_unmatched: + return 1 + if args.max_box_delta is not None and float(summary["max_box_max_abs_delta"]) > args.max_box_delta: + return 1 + if args.max_conf_delta is not None and float(summary["max_confidence_abs_delta"]) > args.max_conf_delta: + return 1 + if args.min_iou is not None and float(summary["min_iou"]) < args.min_iou: + print( + "minimum matched IoU gate failed: " + f"observed {float(summary['min_iou']):.6g} < minimum {args.min_iou:.6g}", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/quantize_int8.py b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/quantize_int8.py index 5681f4a49..8435be62b 100644 --- a/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/quantize_int8.py +++ b/examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/quantize_int8.py @@ -27,7 +27,30 @@ def _image_paths(directory: Path) -> list[Path]: if not directory.is_dir(): return [] - return sorted(p for p in directory.rglob("*") if p.is_file() and p.suffix.lower() in IMAGE_EXTS) + return sorted( + (p for p in directory.rglob("*") if p.is_file() and p.suffix.lower() in IMAGE_EXTS), + key=lambda p: p.as_posix().casefold(), + ) + + +def _validate_unique_stems(paths: Iterable[Path], label: str) -> None: + """Reject ambiguous image names before writing an ordered manifest.""" + seen: dict[str, Path] = {} + for path in paths: + stem = path.stem.casefold() + if stem in seen: + raise ValueError( + f"{label} image stems are not unique: {stem} ({seen[stem]} and {path})" + ) + seen[stem] = path + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() def manifest_name(path: Path, root: Path) -> str: @@ -56,6 +79,7 @@ def select_calibration_images(directory: Path, count: int) -> list[Path]: images = _image_paths(directory) if len(images) < count: raise ValueError(f"calibration set contains {len(images)} images, but {count} were requested") + _validate_unique_stems(images, "calibration") indices = [(i * len(images)) // count for i in range(count)] return [images[i] for i in indices] @@ -104,6 +128,13 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--fp32", type=Path, default=Path("models/esmoe_n_visdrone_sim.onnx")) parser.add_argument("--train", type=Path, default=Path("/data/datasets/VisDrone/images/train")) + parser.add_argument( + "--validation-images", type=Path, default=None, + help=( + "optional validation image directory used to prove calibration/validation " + "disjointness by SHA256; required for an acceptance-ready manifest" + ), + ) parser.add_argument("--out", type=Path, default=Path("models/esmoe_n_visdrone_int8.onnx")) parser.add_argument("--imgsz", type=int, default=640) parser.add_argument("--n-calib", type=int, default=500) @@ -144,6 +175,11 @@ def main() -> int: raise FileNotFoundError(f"FP32 ONNX model not found: {args.fp32}") if args.imgsz <= 0: raise ValueError("--imgsz must be positive") + if args.out.resolve() == args.fp32.resolve(): + raise ValueError("--out must differ from --fp32; in-place quantization would destroy the source model") + prep_path = args.fp32.with_name(args.fp32.stem + ".prep.onnx") + if args.out.resolve() == prep_path.resolve(): + raise ValueError("--out must not use the temporary .prep.onnx path") # Heavy dependencies are imported only after argument/path validation so # ``--help`` and path errors remain useful on an edge-only host. @@ -172,6 +208,18 @@ def rewind(self): self._iter = iter(self.images) images = select_calibration_images(args.train, args.n_calib) + validation_images = _image_paths(args.validation_images) if args.validation_images else [] + if args.validation_images is not None and len(validation_images) == 0: + raise ValueError("--validation-images does not contain any supported images") + _validate_unique_stems(validation_images, "validation") + calibration_hashes = {_sha256(path) for path in images} + validation_hashes = {_sha256(path) for path in validation_images} + overlap = calibration_hashes.intersection(validation_hashes) + if overlap: + raise ValueError( + "calibration and validation sets overlap by SHA256; use training images only " + "(overlap count={})".format(len(overlap)) + ) using_default_exclude = args.exclude is None and not args.no_default_exclude exclude = list(DEFAULT_EXCLUDE) if using_default_exclude else list(args.exclude or []) print( @@ -184,13 +232,20 @@ def rewind(self): raise ValueError("FP32 ONNX graph has no input") input_name = source.graph.input[0].name input_dims = source.graph.input[0].type.tensor_type.shape.dim + if len(input_dims) != 4: + raise ValueError("FP32 ONNX input must be rank-4 NCHW for the shared calibration preprocessing") + channel_dim = input_dims[1] + if channel_dim.dim_value and channel_dim.dim_value != 3: + raise ValueError( + f"FP32 ONNX input channel dimension must be 3, got {channel_dim.dim_value}" + ) if len(input_dims) == 4 and input_dims[2].dim_value and input_dims[3].dim_value: model_h, model_w = input_dims[2].dim_value, input_dims[3].dim_value if model_h != args.imgsz or model_w != args.imgsz: raise ValueError( f"--imgsz={args.imgsz} does not match static ONNX input [{model_h}, {model_w}]" ) - prep = args.fp32.with_name(args.fp32.stem + ".prep.onnx") + prep = prep_path try: quant_pre_process(str(args.fp32), str(prep), skip_symbolic_shape=True) except TypeError: @@ -268,6 +323,18 @@ def rewind(self): "fp32": str(args.fp32), "output": str(args.out), "calibration_images": len(images), + "calibration_image_list_sha256": hashlib.sha256( + "\n".join(manifest_names).encode("utf-8") + ).hexdigest(), + "validation_images": len(validation_images), + "calibration_validation_disjoint": bool(validation_images) and not overlap, + "validation_image_list_sha256": ( + hashlib.sha256( + "\n".join(manifest_name(path, args.validation_images) for path in validation_images).encode("utf-8") + ).hexdigest() + if validation_images + else None + ), "calibration_manifest": str(manifest_path), "calibration_manifest_sha256": manifest_sha256, "format": args.format, @@ -278,6 +345,16 @@ def rewind(self): "imgsz": args.imgsz, "opset": next((op.version for op in quantized.opset_import if op.domain in ("", "ai.onnx")), None), "size_mb": {"fp32": round(fp32_mb, 3), "int8": round(int8_mb, 3)}, + # Quantization is not an accuracy claim until eval_map.py has consumed + # the generated predictions. Keep this false even when all structural + # checks pass, and point callers to the next gate explicitly. + "acceptance_ready": False, + "accuracy_gate_required": True, + "accuracy_gate_command": ( + "python scripts/eval_map.py --preds --images " + "--labels --reference-json " + "--max-abs-delta-pp 1.0" + ), } summary_path = args.out.with_suffix(args.out.suffix + ".json") summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8") diff --git a/examples/YOLO-Master-Edge-Deployment/README.md b/examples/YOLO-Master-Edge-Deployment/README.md index 3cefca938..3d872391d 100644 --- a/examples/YOLO-Master-Edge-Deployment/README.md +++ b/examples/YOLO-Master-Edge-Deployment/README.md @@ -14,20 +14,26 @@ checkpoint or dataset and does not bundle model weights or runtime SDKs. - `CMakeLists.txt` builds the dependency-light benchmark scaffold. - `cpp/` contains the scaffold benchmark and optional backend adapters. -The end-to-end C++ runner for ONNX Runtime, NCNN, MNN, and TensorRT is +The end-to-end C++ runner for ONNX Runtime, NCNN, MNN, and native TensorRT 10 is maintained in [`../YOLO-Master-Cross-Platform-Edge-Deployment/`](../YOLO-Master-Cross-Platform-Edge-Deployment/). Its export and validation scripts are described in that directory and in [`VALIDATION.md`](VALIDATION.md). +Targets that ship TensorRT 8 should use the runner's ONNX Runtime TensorRT +execution-provider route rather than the native TensorRT backend. ## Reference profiles The Python utilities expose two explicit profiles: - `visdrone`: aspect-ratio-preserving input preparation, `conf=0.001`, and - `iou=0.70`, matching the small-object evaluation protocol. -- `sku110k`: high-resolution shelf-image preparation, `conf=0.25`, and - `iou=0.60`. + `iou=0.70`, `max_det=300`, and multi-label decoding at `640x640`, matching + the small-object evaluation protocol used by the production runner. +- `sku110k`: high-resolution shelf-image preparation at `1280x1280`, + `conf=0.25`, `iou=0.60`, `max_det=300`, and multi-label decoding. This is + the same static-input protocol used by the production runner and evaluators. + A rectangular deployment must be requested with an explicit `--imgsz` + override and reported as a separate protocol. These values are defaults rather than hidden global state. Experiments should record any command-line overrides with the resulting metrics. @@ -40,7 +46,7 @@ For the lightweight wrapper: python export_edge_models.py \ --model runs/train/weights/best.pt \ --formats onnx ncnn \ - --imgsz 960 \ + --profile visdrone \ --half ``` @@ -90,14 +96,36 @@ For a real backend, configure the corresponding SDK in the CMake invocation. The cross-platform runner's CMake options and platform-specific toolchains are documented in its README. +The benchmark accepts either a directory or a fixed, newline-delimited image +list. Relative list entries are resolved against the list file, blank/comment +lines are ignored, and duplicate filename stems are rejected so prediction +artifacts cannot overwrite one another: + +```bash +./build/edge-scaffold/yolo_master_edge_benchmark \ + --backend onnx --model artifacts/model.onnx \ + --images artifacts/visdrone_val.txt --profile visdrone \ + --min-images 500 --warmup 10 --runs 3 \ + --output artifacts/onnx_benchmark.csv --json artifacts/onnx_benchmark.json +``` + +The JSON sidecar records the resolved profile, image count, warm-up/repeat +counts, thread count, host/compiler information, and mean/P50/P95/P99/FPS for +preprocess, inference, postprocess, and end-to-end latency. It is metadata for +the benchmark and does not replace the SHA256 evidence manifest. + ## Benchmark output Per-image CSV output uses the following columns: ```text -image,preprocess_ms,inference_ms,postprocess_ms,total_ms,detections +image,preprocess_ms,inference_ms,postprocess_ms,total_ms,detections,run ``` +The current runner appends a `run` column when repeated measurements are +requested. For acceptance measurements, retain the CSV together with the JSON +sidecar and evidence manifest; do not average values copied from console output. + `preprocess_ms` includes image loading, letterbox, color conversion, and tensor packing. `inference_ms` is runtime execution, `postprocess_ms` is decoding and NMS, and `total_ms` is their end-to-end sum. Aggregate statistics are reported @@ -109,10 +137,14 @@ both the scaffold's `latency_ms` column and the runner's `total_ms` column. 1. Record the checkpoint revision, class mapping, input size, and export opset. 2. Export ONNX and at least one mobile format (NCNN or MNN). 3. Validate graph structure, preprocessing, and conversion artifacts. -4. Evaluate all formats on the same ordered validation image list. -5. Enforce the image-count and accuracy gates in [`VALIDATION.md`](VALIDATION.md). -6. Report latency with platform, runtime version, precision, and thread count. - -Quantitative results are meaningful only when the image manifest, model -artifact, command line, and software versions are retained. This example does -not claim a result in the absence of those artifacts. +4. Generate a SHA256-pinned evidence manifest with + `../YOLO-Master-Cross-Platform-Edge-Deployment/scripts/evidence_manifest.py`. +5. Evaluate all formats on the same ordered validation image list. +6. Enforce the image-count and accuracy gates in [`VALIDATION.md`](VALIDATION.md). +7. Report latency with platform, runtime version, precision, and thread count. + +For VisDrone, use the C++ runner's `--profile visdrone` and the evaluator's +`--max-abs-delta-pp 0.5` (FP32) or `1.0` (INT8). Quantitative results are +meaningful only when the image manifest, model artifact, command line and +software versions are retained. A smoke run or a reference table without those +artifacts is not an acceptance result. diff --git a/examples/YOLO-Master-Edge-Deployment/VALIDATION.md b/examples/YOLO-Master-Edge-Deployment/VALIDATION.md index a62bb70d8..e14d7ab63 100644 --- a/examples/YOLO-Master-Edge-Deployment/VALIDATION.md +++ b/examples/YOLO-Master-Edge-Deployment/VALIDATION.md @@ -60,7 +60,7 @@ python examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/mnn_parity.py --mnn artifacts/exports/model.mnn \ --onnx artifacts/exports/model.onnx \ --images /data/VisDrone/images/val \ - --n 100 \ + --limit 100 \ --tolerance 0.1 \ --json artifacts/mnn_parity.json \ --debug-dir artifacts/mnn_parity_debug @@ -70,6 +70,33 @@ The parity tool normalizes the two common feature/anchor layouts, rejects shape mismatches and non-finite values, and returns a non-zero status when the maximum absolute error exceeds the declared tolerance. The debug directory is intended to retain the first input and tensor mismatch for diagnosis. +The example uses 100 images as a diagnostic subset; it is not a substitute for +the full validation image list or the 500-image accuracy gate below. + +## Per-image prediction diagnosis + +When an accuracy gate fails, retain the decoded predictions from both backends +and compare them without rerunning inference: + +```bash +python examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/prediction_diff.py \ + --reference artifacts/onnx_txt \ + --candidate artifacts/ncnn_txt \ + --images /data/VisDrone/images/val \ + --iou 0.50 --min-iou 0.90 --top-k 20 \ + --json artifacts/onnx_ncnn_prediction_diff.json \ + --csv artifacts/onnx_ncnn_prediction_diff.csv \ + --debug-dir artifacts/onnx_ncnn_prediction_diff_images +``` + +The report matches same-class boxes by IoU and lists, for every image, the +detection-count delta, unmatched boxes, matched IoU, confidence deltas, and +maximum coordinate delta. It also records nearest-rank IoU P05/P50/P95/P99 in +both the per-image rows and the aggregate summary. `--min-iou` (also accepted +as `--min-match-iou`), `--max-unmatched`, `--max-box-delta`, and +`--max-conf-delta` are optional diagnostic gates; they do not replace the mAP +acceptance gate. The script uses only the standard library unless +`--debug-dir` is requested (Pillow then provides the overlays). ## Accuracy gate @@ -83,10 +110,11 @@ python examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/eval_map.py \ --preds artifacts/onnx_txt \ --images /data/VisDrone/images/val \ --labels /data/VisDrone/labels/val \ - --classes visdrone \ + --classes visdrone --label-format yolo \ + --imgsz 640 --conf 0.001 --iou 0.70 --max-det 300 --multi-label \ --min-images 500 \ --reference-json artifacts/pytorch_map.json \ - --max-abs-delta-pct 0.5 \ + --max-abs-delta-pp 0.5 \ --json artifacts/onnx_map.json ``` @@ -94,15 +122,26 @@ The evaluator accepts normalized YOLO labels and native VisDrone rows. Native rows are intended for diagnostics: `score=0` and task-external categories are excluded, but ignored-region matching is not inferred. For an acceptance run, convert annotations with the official `visdrone2yolo` procedure first. Outside -`--smoke`, the evaluator requires one label and one prediction file per image -and enforces the 500-image floor. A relative mAP50-95 gate is evaluated only -when a positive PyTorch reference JSON is supplied. The recommended budgets are -below 0.5% for non-quantized exports and below 1.0% for INT8; these are decision -thresholds, not results claimed by this repository. +`--smoke`, the evaluator requires one label and one prediction file per image, +rejects extra stems and enforces the non-negotiable 500-image floor. +`--max-abs-delta-pp` applies an absolute percentage-point gate (the Issue #51 +convention); `--max-abs-delta-pct` remains available for a relative percentage +gate. They are mutually exclusive. The relative gate requires a positive +PyTorch reference mAP; the absolute gate records the reference/protocol +metadata used for the comparison. Every result JSON records both +`delta_mAP50-95_pp` and `delta_mAP50-95_pct` to make the units explicit. The +recommended budgets are below 0.5 pp for non-quantized exports and below 1.0 +pp for INT8; these are decision thresholds, not results claimed by this +repository. ## INT8 calibration Calibration must use training images only and must be disjoint from validation. +Pass `--validation-images` to the quantizer when both splits are available; it +compares content hashes and aborts on overlap. The quantizer's JSON is +intentionally marked `acceptance_ready: false`; a separate evidence manifest +may claim INT8 acceptance only after the prediction directory passes the 1.0 +percentage-point mAP gate. The quantizer enforces a minimum of 300 images, uses the same letterbox/RGB/NCHW preprocessing as the C++ runner, writes a deterministic calibration manifest, and records its SHA-256 digest: @@ -111,6 +150,7 @@ and records its SHA-256 digest: python examples/YOLO-Master-Cross-Platform-Edge-Deployment/scripts/quantize_int8.py \ --fp32 artifacts/exports/model.onnx \ --train /data/VisDrone/images/train \ + --validation-images /data/VisDrone/images/val \ --n-calib 300 \ --format QOperator \ --out artifacts/exports/model_int8.onnx diff --git a/examples/YOLO-Master-Edge-Deployment/cpp/edge_benchmark.cpp b/examples/YOLO-Master-Edge-Deployment/cpp/edge_benchmark.cpp index 08087c4e0..d9c2f0d3b 100644 --- a/examples/YOLO-Master-Edge-Deployment/cpp/edge_benchmark.cpp +++ b/examples/YOLO-Master-Edge-Deployment/cpp/edge_benchmark.cpp @@ -1,11 +1,15 @@ #include #include #include +#include #include #include #include +#include #include #include +#include +#include #include #include #include @@ -22,16 +26,25 @@ struct Args { std::string images; std::string profile = "visdrone"; std::string output = "benchmark.csv"; - int imgsz = 960; + std::string json_output; + int imgsz = 0; float conf = -1.0f; float iou = -1.0f; int warmup = 5; - int runs = 1; + int runs = 3; int limit = 0; + int min_images = 0; + int max_det = 300; int threads = 4; + bool multi_label = false; + bool multi_label_set = false; + bool imgsz_set = false; + bool conf_set = false; + bool iou_set = false; }; struct TimingRow { + int run = 0; std::string image; double preprocess_ms = 0.0; double inference_ms = 0.0; @@ -47,14 +60,18 @@ static void print_usage(const char* program) { << "--model MODEL " << "--images IMAGE_LIST " << "[--profile visdrone|sku110k] " - << "[--imgsz 960] " - << "[--conf 0.20] " - << "[--iou 0.55] " + << "[--imgsz 640] " + << "[--conf 0.001] " + << "[--iou 0.70] " + << "[--max-det 300] " + << "[--multi-label|--single-label] " << "[--warmup 5] " - << "[--runs 1] " + << "[--runs 3] " << "[--limit 500] " + << "[--min-images 500] " << "[--threads 4] " - << "[--output benchmark.csv]\n"; + << "[--output benchmark.csv] " + << "[--json benchmark.json]\n"; } static bool require_value(int i, int argc, const char* key) { @@ -74,6 +91,16 @@ static Args parse_args(int argc, char** argv) { print_usage(argv[0]); std::exit(0); } + if (key == "--multi-label") { + args.multi_label = true; + args.multi_label_set = true; + continue; + } + if (key == "--single-label") { + args.multi_label = false; + args.multi_label_set = true; + continue; + } if (!require_value(i, argc, argv[i])) { print_usage(argv[0]); std::exit(2); @@ -90,18 +117,27 @@ static Args parse_args(int argc, char** argv) { args.profile = value; } else if (key == "--output") { args.output = value; + } else if (key == "--json") { + args.json_output = value; } else if (key == "--imgsz") { args.imgsz = std::stoi(value); + args.imgsz_set = true; } else if (key == "--conf") { args.conf = std::stof(value); + args.conf_set = true; } else if (key == "--iou") { args.iou = std::stof(value); + args.iou_set = true; } else if (key == "--warmup") { args.warmup = std::stoi(value); } else if (key == "--runs") { args.runs = std::stoi(value); } else if (key == "--limit") { args.limit = std::stoi(value); + } else if (key == "--min-images") { + args.min_images = std::stoi(value); + } else if (key == "--max-det") { + args.max_det = std::stoi(value); } else if (key == "--threads") { args.threads = std::stoi(value); } else { @@ -120,21 +156,41 @@ static Args parse_args(int argc, char** argv) { std::cerr << "Invalid --backend: " << args.backend << "\n"; std::exit(2); } + std::transform(args.profile.begin(), args.profile.end(), args.profile.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); if (args.profile != "visdrone" && args.profile != "sku110k") { std::cerr << "Invalid --profile: " << args.profile << "\n"; std::exit(2); } - if (args.imgsz <= 0 || args.warmup < 0 || args.runs <= 0 || args.limit < 0 || - args.threads <= 0) { + if (args.imgsz < 0 || args.warmup < 0 || args.runs <= 0 || args.limit < 0 || + args.min_images < 0 || args.max_det <= 0 || args.threads <= 0) { std::cerr << "Invalid numeric argument\n"; std::exit(2); } - if (args.conf < 0.0f) { - args.conf = args.profile == "visdrone" ? 0.20f : 0.25f; + if (!args.imgsz_set) { + args.imgsz = args.profile == "visdrone" ? 640 : 1280; + } + if (args.imgsz <= 0) { + std::cerr << "imgsz must be positive\n"; + std::exit(2); + } + + if (!args.conf_set) { + args.conf = args.profile == "visdrone" ? 0.001f : 0.25f; + } + if (!args.iou_set) { + args.iou = args.profile == "visdrone" ? 0.70f : 0.60f; + } + if (!std::isfinite(args.conf) || args.conf < 0.0f || args.conf > 1.0f || + !std::isfinite(args.iou) || args.iou < 0.0f || args.iou > 1.0f) { + std::cerr << "conf and iou must be finite values in [0,1]\n"; + std::exit(2); } - if (args.iou < 0.0f) { - args.iou = args.profile == "visdrone" ? 0.55f : 0.60f; + if (!args.multi_label_set && + (args.profile == "visdrone" || args.profile == "sku110k")) { + args.multi_label = true; } return args; } @@ -151,22 +207,80 @@ static bool is_image_file(const fs::path& path) { return ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".bmp"; } +static std::string trim_copy(const std::string& value) { + const auto first = value.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) { + return {}; + } + const auto last = value.find_last_not_of(" \t\r\n"); + return value.substr(first, last - first + 1); +} + +static std::string image_stem_key(const fs::path& path) { + std::string key = path.stem().string(); + std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return key; +} + +static void validate_unique_stems(const std::vector& images) { + std::set stems; + for (const auto& image : images) { + const std::string key = image_stem_key(fs::path(image)); + if (key.empty() || !stems.insert(key).second) { + throw std::runtime_error( + "image stems are not unique; duplicate output stem: " + key); + } + } +} + static std::vector read_image_list_file(const std::string& path) { std::ifstream file(path); if (!file) { throw std::runtime_error("failed to open image list: " + path); } + const fs::path list_path = fs::absolute(fs::path(path)); + const fs::path base = list_path.parent_path(); std::vector images; std::string line; + size_t line_number = 0; while (std::getline(file, line)) { - if (!line.empty()) { - images.push_back(line); + ++line_number; + line = trim_copy(line); + if (line_number == 1 && line.size() >= 3 && + static_cast(line[0]) == 0xef && + static_cast(line[1]) == 0xbb && + static_cast(line[2]) == 0xbf) { + line.erase(0, 3); + line = trim_copy(line); + } + if (line.empty() || line.front() == '#') { + continue; + } + if (line.size() >= 2 && line.front() == line.back() && + (line.front() == '"' || line.front() == '\'')) { + line = trim_copy(line.substr(1, line.size() - 2)); + } + fs::path image_path(line); + if (image_path.is_relative()) { + image_path = base / image_path; } + images.push_back(image_path.lexically_normal().string()); } if (images.empty()) { throw std::runtime_error("image list is empty: " + path); } + for (const auto& image : images) { + if (!fs::is_regular_file(fs::path(image))) { + throw std::runtime_error("image path does not exist: " + image); + } + if (!is_image_file(fs::path(image))) { + throw std::runtime_error("unsupported image extension: " + image); + } + } + validate_unique_stems(images); return images; } @@ -181,6 +295,7 @@ static std::vector read_image_directory(const fs::path& path) { if (images.empty()) { throw std::runtime_error("no image files found in directory: " + path.string()); } + validate_unique_stems(images); return images; } @@ -195,6 +310,9 @@ static std::vector collect_images(const std::string& path, int limi if (limit > 0 && static_cast(limit) < images.size()) { images.resize(static_cast(limit)); } + if (limit > 0) { + validate_unique_stems(images); + } return images; } @@ -204,19 +322,39 @@ static double elapsed_ms( return std::chrono::duration(end - start).count(); } +static std::string csv_quote(const std::string& value) { + if (value.find_first_of(",\"\r\n") == std::string::npos) { + return value; + } + std::string escaped = "\""; + for (const char c : value) { + if (c == '\"') escaped += "\"\""; + else escaped += c; + } + escaped += '"'; + return escaped; +} + static void write_csv(const std::string& path, const std::vector& rows) { + const fs::path output(path); + if (!output.parent_path().empty()) { + std::error_code ec; + fs::create_directories(output.parent_path(), ec); + if (ec) throw std::runtime_error("failed to create benchmark output directory: " + ec.message()); + } std::ofstream out(path); if (!out) { throw std::runtime_error("failed to write benchmark CSV: " + path); } - out << "image,preprocess_ms,inference_ms,postprocess_ms,total_ms,detections\n"; + out << "image,preprocess_ms,inference_ms,postprocess_ms,total_ms,detections,run\n"; for (const auto& row : rows) { - out << row.image << "," + out << csv_quote(row.image) << "," << row.preprocess_ms << "," << row.inference_ms << "," << row.postprocess_ms << "," << row.total_ms << "," - << row.detections << "\n"; + << row.detections << "," + << row.run << "\n"; } } @@ -225,9 +363,13 @@ static double percentile(std::vector values, double pct) { return 0.0; } std::sort(values.begin(), values.end()); - const size_t idx = std::min( - values.size() - 1, - static_cast((pct / 100.0) * static_cast(values.size() - 1))); + if (!std::isfinite(pct) || pct < 0.0 || pct > 100.0) { + throw std::invalid_argument("percentile must be in [0,100]"); + } + // Nearest-rank semantics match edge_utils.summarize_latency_ms and make + // small smoke runs deterministic (P95 of five values is the maximum). + const size_t rank = std::max(1, static_cast(std::ceil(pct * values.size() / 100.0))); + const size_t idx = std::min(values.size() - 1, rank - 1); return values[idx]; } @@ -251,10 +393,130 @@ static void print_summary(const std::vector& rows) { << fps << "\n"; } +static std::string json_escape(const std::string& value) { + std::ostringstream escaped; + for (const unsigned char c : value) { + switch (c) { + case '\\': escaped << "\\\\"; break; + case '"': escaped << "\\\""; break; + case '\b': escaped << "\\b"; break; + case '\f': escaped << "\\f"; break; + case '\n': escaped << "\\n"; break; + case '\r': escaped << "\\r"; break; + case '\t': escaped << "\\t"; break; + default: + if (c < 0x20) escaped << "\\u" << std::hex << std::setw(4) + << std::setfill('0') << static_cast(c) << std::dec; + else escaped << static_cast(c); + } + } + return escaped.str(); +} + +static std::string host_cpu_model() { +#if defined(__linux__) + std::ifstream cpu("/proc/cpuinfo"); + std::string line; + while (std::getline(cpu, line)) { + if (line.rfind("model name", 0) == 0) { + const auto colon = line.find(':'); + if (colon != std::string::npos) return trim_copy(line.substr(colon + 1)); + } + } +#endif + return {}; +} + +static std::string host_platform() { +#if defined(_WIN32) + return "windows"; +#elif defined(__APPLE__) + return "macos"; +#elif defined(__linux__) + return "linux"; +#else + return "unknown"; +#endif +} + +static std::string host_compiler() { +#if defined(_MSC_VER) + return "MSVC " + std::to_string(_MSC_VER); +#elif defined(__clang_version__) + return __clang_version__; +#elif defined(__VERSION__) + return __VERSION__; +#else + return "unknown"; +#endif +} + +static void write_json( + const std::string& path, + const Args& args, + const std::vector& images, + const std::vector& rows) { + const fs::path output(path); + if (!output.parent_path().empty()) { + std::error_code ec; + fs::create_directories(output.parent_path(), ec); + if (ec) throw std::runtime_error("failed to create benchmark JSON directory: " + ec.message()); + } + std::vector totals, prep, infer, post; + totals.reserve(rows.size()); prep.reserve(rows.size()); infer.reserve(rows.size()); post.reserve(rows.size()); + for (const auto& row : rows) { + totals.push_back(row.total_ms); prep.push_back(row.preprocess_ms); + infer.push_back(row.inference_ms); post.push_back(row.postprocess_ms); + } + auto summary_object = [](const std::vector& values) { + const double sum = std::accumulate(values.begin(), values.end(), 0.0); + const double avg = values.empty() ? 0.0 : sum / static_cast(values.size()); + std::ostringstream s; + s << std::setprecision(10) + << "{\"count\":" << values.size() + << ",\"mean_ms\":" << avg + << ",\"p50_ms\":" << percentile(values, 50.0) + << ",\"p95_ms\":" << percentile(values, 95.0) + << ",\"p99_ms\":" << percentile(values, 99.0) + << ",\"fps\":" << (avg > 0.0 ? 1000.0 / avg : 0.0) << "}"; + return s.str(); + }; + std::ofstream out(path); + if (!out) throw std::runtime_error("failed to write benchmark JSON: " + path); + out << std::setprecision(10) + << "{\n \"schema_version\": 1,\n" + << " \"backend\": \"" << json_escape(args.backend) << "\",\n" + << " \"model\": \"" << json_escape(args.model) << "\",\n" + << " \"images_source\": \"" << json_escape(args.images) << "\",\n" + << " \"image_count\": " << images.size() << ",\n" + << " \"profile\": \"" << json_escape(args.profile) << "\",\n" + << " \"protocol\": {\"imgsz\": " << args.imgsz + << ", \"conf\": " << args.conf << ", \"iou\": " << args.iou + << ", \"max_det\": " << args.max_det + << ", \"multi_label\": " << (args.multi_label ? "true" : "false") + << ", \"letterbox\": true},\n" + << " \"benchmark\": {\"warmup\": " << args.warmup + << ", \"runs\": " << args.runs << ", \"threads\": " << args.threads + << ", \"rows\": " << rows.size() << "},\n" + << " \"environment\": {\"platform\": \"" << host_platform() + << "\", \"cpu_model\": \"" << json_escape(host_cpu_model()) + << "\", \"compiler\": \"" << json_escape(host_compiler()) + << "\", \"build_date\": \"" << __DATE__ << "\"},\n" + << " \"timing_ms\": {\"preprocess\": " << summary_object(prep) + << ", \"inference\": " << summary_object(infer) + << ", \"postprocess\": " << summary_object(post) + << ", \"total\": " << summary_object(totals) << "}\n}\n"; +} + int main(int argc, char** argv) { try { const Args args = parse_args(argc, argv); const auto images = collect_images(args.images, args.limit); + if (args.min_images > 0 && static_cast(images.size()) < args.min_images) { + throw std::runtime_error( + "resolved image count " + std::to_string(images.size()) + + " is below --min-images " + std::to_string(args.min_images)); + } auto backend = create_backend(args.backend); backend->set_num_threads(args.threads); backend->load(args.model); @@ -280,12 +542,14 @@ int main(int argc, char** argv) { const auto inference_end = std::chrono::steady_clock::now(); const auto postprocess_start = std::chrono::steady_clock::now(); - const auto detections = postprocess_yolo_output(output, 0, args.conf, args.iou, prep); + const auto detections = postprocess_yolo_output( + output, 0, args.conf, args.iou, prep, args.multi_label, args.max_det); const auto postprocess_end = std::chrono::steady_clock::now(); const auto total_end = std::chrono::steady_clock::now(); TimingRow row; + row.run = run; row.image = image; row.preprocess_ms = elapsed_ms(preprocess_start, preprocess_end); row.inference_ms = elapsed_ms(inference_start, inference_end); @@ -304,8 +568,16 @@ int main(int argc, char** argv) { << " threads=" << args.threads << " conf=" << args.conf << " iou=" << args.iou + << " max_det=" << args.max_det + << " multi_label=" << (args.multi_label ? "true" : "false") + << " warmup=" << args.warmup + << " runs=" << args.runs << " output=" << args.output << "\n"; print_summary(rows); + if (!args.json_output.empty()) { + write_json(args.json_output, args, images, rows); + std::cout << "json=" << args.json_output << "\n"; + } return 0; } catch (const std::exception& e) { std::cerr << "error: " << e.what() << "\n"; diff --git a/examples/YOLO-Master-Edge-Deployment/cpp/postprocess.cpp b/examples/YOLO-Master-Edge-Deployment/cpp/postprocess.cpp index 95b5642e5..f38cb0420 100644 --- a/examples/YOLO-Master-Edge-Deployment/cpp/postprocess.cpp +++ b/examples/YOLO-Master-Edge-Deployment/cpp/postprocess.cpp @@ -1,8 +1,73 @@ #include "postprocess.h" #include +#include +#include +#include #include +namespace { + +struct OutputView { + int64_t channels = 0; + int64_t anchors = 0; + bool channel_first = true; + + size_t index(int64_t channel, int64_t anchor) const { + if (channel_first) { + return static_cast(channel * anchors + anchor); + } + return static_cast(anchor * channels + channel); + } +}; + +OutputView make_output_view(const Tensor& output, int num_classes) { + if (output.shape.size() != 3 || output.shape[0] != 1) { + throw std::invalid_argument("expected YOLO output shape [1, channels, anchors] or [1, anchors, channels]"); + } + const int64_t dim1 = output.shape[1]; + const int64_t dim2 = output.shape[2]; + if (dim1 <= 0 || dim2 <= 0) { + throw std::invalid_argument("YOLO output dimensions must be positive"); + } + + OutputView view; + if (num_classes > 0) { + const int64_t expected_channels = static_cast(4 + num_classes); + if (dim1 == expected_channels) { + view.channels = dim1; view.anchors = dim2; view.channel_first = true; + } else if (dim2 == expected_channels) { + view.channels = dim2; view.anchors = dim1; view.channel_first = false; + } else { + throw std::invalid_argument("YOLO output does not contain the requested class dimension"); + } + } else if (dim1 < 5 && dim2 >= 5) { + view.channels = dim2; view.anchors = dim1; view.channel_first = false; + } else if (dim2 < 5 && dim1 >= 5) { + view.channels = dim1; view.anchors = dim2; view.channel_first = true; + } else if (dim1 <= 256 && dim2 > dim1) { + // Typical exported YOLO heads are [1, 4+nc, anchors]. + view.channels = dim1; view.anchors = dim2; view.channel_first = true; + } else if (dim2 <= 256 && dim1 > dim2) { + // ONNX exporters commonly transpose the same head to [1, anchors, 4+nc]. + view.channels = dim2; view.anchors = dim1; view.channel_first = false; + } else { + // Ambiguous small synthetic tensors retain the historical channel-first + // interpretation; callers with a known class count take the exact path above. + view.channels = dim1; view.anchors = dim2; view.channel_first = true; + } + if (view.channels < 5 || view.anchors <= 0) { + throw std::invalid_argument("YOLO output must have at least four box channels and one class"); + } + const size_t expected = static_cast(view.channels) * static_cast(view.anchors); + if (output.data.size() != expected) { + throw std::invalid_argument("YOLO output data size does not match shape"); + } + return view; +} + +} // namespace + static float clamp(float value, float low, float high) { return std::max(low, std::min(value, high)); } @@ -24,7 +89,12 @@ static float box_iou(const Detection& a, const Detection& b) { static std::vector nms(std::vector detections, float iou_threshold) { std::sort(detections.begin(), detections.end(), [](const Detection& a, const Detection& b) { - return a.confidence > b.confidence; + if (a.confidence != b.confidence) return a.confidence > b.confidence; + if (a.class_id != b.class_id) return a.class_id < b.class_id; + if (a.x1 != b.x1) return a.x1 < b.x1; + if (a.y1 != b.y1) return a.y1 < b.y1; + if (a.x2 != b.x2) return a.x2 < b.x2; + return a.y2 < b.y2; }); std::vector kept; @@ -49,59 +119,79 @@ std::vector postprocess_yolo_output( int num_classes, float conf_threshold, float iou_threshold, - const PreprocessResult& prep) { - if (output.shape.size() != 3) { - throw std::invalid_argument("expected YOLO output shape [1, channels, anchors]"); + const PreprocessResult& prep, + bool multi_label, + int max_det) { + if (!std::isfinite(conf_threshold) || conf_threshold < 0.0f || conf_threshold > 1.0f || + !std::isfinite(iou_threshold) || iou_threshold < 0.0f || iou_threshold > 1.0f) { + throw std::invalid_argument("confidence and IoU thresholds must be finite values in [0,1]"); } - const int64_t batch = output.shape[0]; - const int64_t channels = output.shape[1]; - const int64_t anchors = output.shape[2]; - if (batch != 1 || channels < 5 || anchors <= 0) { - throw std::invalid_argument("invalid YOLO output shape"); + if (max_det <= 0) { + throw std::invalid_argument("max_det must be positive"); } - + if (!std::isfinite(prep.ratio) || prep.ratio <= 0.0f || + prep.original_w <= 0 || prep.original_h <= 0) { + throw std::invalid_argument("invalid preprocessing geometry"); + } + const OutputView view = make_output_view(output, num_classes); + const int64_t channels = view.channels; + const int64_t anchors = view.anchors; const int inferred_classes = static_cast(channels) - 4; const int classes = num_classes > 0 ? num_classes : inferred_classes; if (classes <= 0 || channels < 4 + classes) { throw std::invalid_argument("invalid class count for YOLO output"); } - if (output.data.size() < static_cast(channels * anchors)) { - throw std::invalid_argument("YOLO output data is smaller than shape"); - } - std::vector detections; for (int64_t anchor = 0; anchor < anchors; ++anchor) { float best_score = 0.0f; int best_class = -1; for (int cls = 0; cls < classes; ++cls) { - const float score = output.data[static_cast((4 + cls) * anchors + anchor)]; + const float score = output.data[view.index(4 + cls, anchor)]; + if (!std::isfinite(score)) continue; if (score > best_score) { best_score = score; best_class = cls; } } - if (best_score < conf_threshold) { + const float cx = output.data[view.index(0, anchor)]; + const float cy = output.data[view.index(1, anchor)]; + const float w = output.data[view.index(2, anchor)]; + const float h = output.data[view.index(3, anchor)]; + if (!std::isfinite(cx) || !std::isfinite(cy) || !std::isfinite(w) || !std::isfinite(h) || + w <= 0.0f || h <= 0.0f) { continue; } - const float cx = output.data[static_cast(0 * anchors + anchor)]; - const float cy = output.data[static_cast(1 * anchors + anchor)]; - const float w = output.data[static_cast(2 * anchors + anchor)]; - const float h = output.data[static_cast(3 * anchors + anchor)]; + auto append_detection = [&](int cls, float score) { + if (cls < 0 || !std::isfinite(score) || score < conf_threshold) return; + Detection det; + det.class_id = cls; + det.confidence = score; + det.x1 = (cx - w * 0.5f - static_cast(prep.pad_w)) / prep.ratio; + det.y1 = (cy - h * 0.5f - static_cast(prep.pad_h)) / prep.ratio; + det.x2 = (cx + w * 0.5f - static_cast(prep.pad_w)) / prep.ratio; + det.y2 = (cy + h * 0.5f - static_cast(prep.pad_h)) / prep.ratio; + if (!std::isfinite(det.x1) || !std::isfinite(det.y1) || + !std::isfinite(det.x2) || !std::isfinite(det.y2)) return; + det.x1 = clamp(det.x1, 0.0f, static_cast(prep.original_w)); + det.x2 = clamp(det.x2, 0.0f, static_cast(prep.original_w)); + det.y1 = clamp(det.y1, 0.0f, static_cast(prep.original_h)); + det.y2 = clamp(det.y2, 0.0f, static_cast(prep.original_h)); + if (det.x2 > det.x1 && det.y2 > det.y1) detections.push_back(det); + }; - Detection det; - det.class_id = best_class; - det.confidence = best_score; - det.x1 = (cx - w * 0.5f - static_cast(prep.pad_w)) / prep.ratio; - det.y1 = (cy - h * 0.5f - static_cast(prep.pad_h)) / prep.ratio; - det.x2 = (cx + w * 0.5f - static_cast(prep.pad_w)) / prep.ratio; - det.y2 = (cy + h * 0.5f - static_cast(prep.pad_h)) / prep.ratio; - det.x1 = clamp(det.x1, 0.0f, static_cast(prep.original_w)); - det.x2 = clamp(det.x2, 0.0f, static_cast(prep.original_w)); - det.y1 = clamp(det.y1, 0.0f, static_cast(prep.original_h)); - det.y2 = clamp(det.y2, 0.0f, static_cast(prep.original_h)); - detections.push_back(det); + if (multi_label) { + for (int cls = 0; cls < classes; ++cls) { + const float score = output.data[view.index(4 + cls, anchor)]; + append_detection(cls, score); + } + } else if (best_score >= conf_threshold) { + append_detection(best_class, best_score); + } } - - return nms(std::move(detections), iou_threshold); + auto kept = nms(std::move(detections), iou_threshold); + if (static_cast(kept.size()) > max_det) { + kept.resize(static_cast(max_det)); + } + return kept; } diff --git a/examples/YOLO-Master-Edge-Deployment/cpp/postprocess.h b/examples/YOLO-Master-Edge-Deployment/cpp/postprocess.h index 423bba55e..02a625ea8 100644 --- a/examples/YOLO-Master-Edge-Deployment/cpp/postprocess.h +++ b/examples/YOLO-Master-Edge-Deployment/cpp/postprocess.h @@ -19,4 +19,6 @@ std::vector postprocess_yolo_output( int num_classes, float conf_threshold, float iou_threshold, - const PreprocessResult& prep); + const PreprocessResult& prep, + bool multi_label = true, + int max_det = 300); diff --git a/examples/YOLO-Master-Edge-Deployment/cpp/preprocess.cpp b/examples/YOLO-Master-Edge-Deployment/cpp/preprocess.cpp index 99acd12b3..feb92d553 100644 --- a/examples/YOLO-Master-Edge-Deployment/cpp/preprocess.cpp +++ b/examples/YOLO-Master-Edge-Deployment/cpp/preprocess.cpp @@ -13,12 +13,15 @@ static cv::Mat letterbox( int& pad_h) { const int original_h = image.rows; const int original_w = image.cols; + if (original_h <= 0 || original_w <= 0 || target_h <= 0 || target_w <= 0) { + throw std::invalid_argument("image and target dimensions must be positive"); + } ratio = std::min( static_cast(target_h) / static_cast(original_h), static_cast(target_w) / static_cast(original_w)); - const int resized_w = static_cast(std::round(static_cast(original_w) * ratio)); - const int resized_h = static_cast(std::round(static_cast(original_h) * ratio)); + const int resized_w = std::max(1, static_cast(std::round(static_cast(original_w) * ratio))); + const int resized_h = std::max(1, static_cast(std::round(static_cast(original_h) * ratio))); pad_w = target_w - resized_w; pad_h = target_h - resized_h; diff --git a/examples/YOLO-Master-Edge-Deployment/edge_utils.py b/examples/YOLO-Master-Edge-Deployment/edge_utils.py index 204f48c30..91847f4b8 100644 --- a/examples/YOLO-Master-Edge-Deployment/edge_utils.py +++ b/examples/YOLO-Master-Edge-Deployment/edge_utils.py @@ -20,13 +20,21 @@ class EdgeProfile: conf_threshold: float iou_threshold: float keep_aspect_ratio: bool = True + max_det: int = 300 + multi_label: bool = False PROFILES = { - # Match the production runner's Issue #51 validation defaults for dense - # small-object scenes; callers can override thresholds explicitly. - "visdrone": EdgeProfile("visdrone", (960, 544), 0.001, 0.70), - "sku110k": EdgeProfile("sku110k", (1280, 768), 0.25, 0.60), + # The acceptance recipe is deliberately identical to the production + # runner: square 640 input, aspect-preserving letterbox, low confidence + # floor, class-aware NMS at IoU 0.70, and Ultralytics-style multi-label + # decoding. Callers can override values explicitly, but the resolved + # values must be recorded with the resulting metrics. + "visdrone": EdgeProfile("visdrone", (640, 640), 0.001, 0.70, True, 300, True), + # Keep SKU-110K identical to the full runner and both evaluators. A square + # static input is required for cross-backend parity; callers that need a + # rectangular deployment must make that protocol override explicit. + "sku110k": EdgeProfile("sku110k", (1280, 1280), 0.25, 0.60, True, 300, True), } @@ -170,3 +178,48 @@ def add_profile_args(parser: argparse.ArgumentParser) -> None: parser.add_argument("--profile", type=profile_arg, default=get_profile("visdrone"), help="Vertical profile") parser.add_argument("--conf", type=float, default=None, help="Override profile confidence threshold") parser.add_argument("--iou", type=float, default=None, help="Override profile NMS IoU threshold") + + +def resolve_profile_options( + profile: EdgeProfile, + *, + imgsz: int | tuple[int, int] | None = None, + conf: float | None = None, + iou: float | None = None, + max_det: int | None = None, + multi_label: bool | None = None, +) -> dict[str, object]: + """Resolve optional overrides into one serialisable protocol dictionary. + + Keeping this resolution in the dependency-light module prevents exporters, + benchmark wrappers, and validation scripts from silently drifting apart. + ``imgsz`` may be a scalar (square input) or an explicit ``(height,width)``. + """ + if imgsz is None: + image_size: int | tuple[int, int] = profile.image_size + elif isinstance(imgsz, tuple): + if len(imgsz) != 2 or any(int(value) <= 0 for value in imgsz): + raise ValueError("imgsz tuple must contain two positive dimensions") + image_size = (int(imgsz[0]), int(imgsz[1])) + else: + image_size = int(imgsz) + if image_size <= 0: + raise ValueError("imgsz must be positive") + resolved_conf = profile.conf_threshold if conf is None else float(conf) + resolved_iou = profile.iou_threshold if iou is None else float(iou) + resolved_max_det = profile.max_det if max_det is None else int(max_det) + resolved_multi = profile.multi_label if multi_label is None else bool(multi_label) + if not math.isfinite(resolved_conf) or not 0.0 <= resolved_conf <= 1.0: + raise ValueError("conf must be finite and in [0,1]") + if not math.isfinite(resolved_iou) or not 0.0 <= resolved_iou <= 1.0: + raise ValueError("iou must be finite and in [0,1]") + if resolved_max_det <= 0: + raise ValueError("max_det must be positive") + return { + "imgsz": image_size, + "conf": resolved_conf, + "iou": resolved_iou, + "max_det": resolved_max_det, + "multi_label": resolved_multi, + "letterbox": bool(profile.keep_aspect_ratio), + } diff --git a/examples/YOLO-Master-Edge-Deployment/export_edge_models.py b/examples/YOLO-Master-Edge-Deployment/export_edge_models.py index 88c21b7d0..2cb5aface 100644 --- a/examples/YOLO-Master-Edge-Deployment/export_edge_models.py +++ b/examples/YOLO-Master-Edge-Deployment/export_edge_models.py @@ -13,7 +13,7 @@ if str(path) not in sys.path: sys.path.insert(0, str(path)) -from edge_utils import add_profile_args +from edge_utils import add_profile_args, resolve_profile_options def parse_args() -> argparse.Namespace: @@ -23,7 +23,12 @@ def parse_args() -> argparse.Namespace: "--formats", nargs="+", default=["onnx", "ncnn"], choices=("onnx", "ncnn", "mnn"), help="export formats; MNN conversion requires ONNX", ) - parser.add_argument("--imgsz", type=int, default=960) + parser.add_argument( + "--imgsz", + type=int, + default=None, + help="square export size; defaults to the selected profile (VisDrone: 640)", + ) parser.add_argument("--opset", type=int, default=12) parser.add_argument("--half", action="store_true") parser.add_argument("--int8", action="store_true") @@ -45,17 +50,25 @@ def main() -> int: args = parse_args() if not args.model.is_file(): raise FileNotFoundError(f"checkpoint not found: {args.model}") - if args.imgsz <= 0 or args.opset < 7: - raise ValueError("imgsz must be positive and opset must be at least 7") + if (args.imgsz is not None and args.imgsz <= 0) or args.opset < 7: + raise ValueError("imgsz must be positive when supplied and opset must be at least 7") if "mnn" in args.formats and "onnx" not in args.formats: raise ValueError("MNN conversion requires ONNX in --formats") from ultralytics import YOLO + protocol = resolve_profile_options(args.profile, imgsz=args.imgsz, conf=args.conf, iou=args.iou) + # Ultralytics accepts either a scalar or a two-element sequence. Preserve + # an explicitly rectangular override instead of silently changing the + # caller's protocol; the built-in profiles are square for parity. + export_imgsz = protocol["imgsz"] + if isinstance(export_imgsz, tuple): + export_imgsz = list(export_imgsz) + model = YOLO(str(args.model)) for fmt in args.formats: export_args = { "format": fmt, - "imgsz": args.imgsz, + "imgsz": export_imgsz, } if fmt == "onnx": export_args.update({"half": args.half, "int8": args.int8}) @@ -64,8 +77,9 @@ def main() -> int: print(f"[export] {args.model} -> {fmt} with {export_args}") model.export(**export_args) print( - f"[profile] {args.profile.name}: conf={args.conf if args.conf is not None else args.profile.conf_threshold}, " - f"iou={args.iou if args.iou is not None else args.profile.iou_threshold}" + f"[profile] {args.profile.name}: imgsz={export_imgsz}, " + f"conf={protocol['conf']}, iou={protocol['iou']}, " + f"max_det={protocol['max_det']}, multi_label={str(protocol['multi_label']).lower()}" ) return 0 diff --git a/tests/cache_test_assets.py b/tests/cache_test_assets.py index b4f513a98..0294020bd 100644 --- a/tests/cache_test_assets.py +++ b/tests/cache_test_assets.py @@ -42,7 +42,11 @@ ] DATASETS = [ - *TASK2DATA.values(), + # The full COCO multi-task index is intentionally user-provided: its YAML + # has no automatic download target and the generic multi-task tests create + # a tiny synthetic fixture instead. Trying to pre-cache it makes every CI + # matrix fail before pytest can start when COCO 2017 is not installed. + *(data for task, data in TASK2DATA.items() if task != "multitask"), *TASK2CALIBRATIONDATA.values(), "coco8-grayscale.yaml", "coco8-multispectral.yaml", diff --git a/tests/conftest.py b/tests/conftest.py index ed90e390a..4af11c44f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -67,7 +67,17 @@ def pytest_collection_modifyitems(config, items): if not export_env: return - from ultralytics.engine.exporter import export_formats + # Keep dependency-light contract tests runnable without the optional + # Ultralytics installation. Export routing is only relevant when the + # caller explicitly requests --export-env, so defer the import until then + # and mark matching tests as skipped if the package is unavailable. + try: + from ultralytics.engine.exporter import export_formats + except ModuleNotFoundError: + for item in items: + if Path(str(item.fspath)).name == "test_exports.py": + item.add_marker(pytest.mark.skip(reason="Ultralytics is required for --export-env")) + return env_by_format = dict(zip(export_formats()["Argument"], export_formats()["Env"])) for item in items: @@ -99,8 +109,13 @@ def pytest_sessionstart(session): Args: session: The pytest session object. """ - from ultralytics.utils.torch_utils import init_seeds - + # Ultralytics is an optional dependency for the edge-runtime contract + # tests. Seed initialization is retained for the full suite, but must not + # prevent dependency-light tests from collecting and running. + try: + from ultralytics.utils.torch_utils import init_seeds + except ModuleNotFoundError: + return init_seeds() @@ -128,7 +143,10 @@ def pytest_sessionfinish(session, exitstatus): if hasattr(session.config, "workerinput"): return - from ultralytics.utils import WEIGHTS_DIR + try: + from ultralytics.utils import WEIGHTS_DIR + except ModuleNotFoundError: + return # Remove files models = [path for x in {"*.onnx", "*.torchscript"} for path in WEIGHTS_DIR.rglob(x)] diff --git a/tests/ddp_moe_smoke.py b/tests/ddp_moe_smoke.py index 258874362..b22c66211 100644 --- a/tests/ddp_moe_smoke.py +++ b/tests/ddp_moe_smoke.py @@ -8,6 +8,8 @@ from torch.nn.parallel import DistributedDataParallel as DDP from ultralytics.nn.modules.moe.modules import OptimizedMOE +from ultralytics.utils import WINDOWS +from ultralytics.utils.torchrun import disable_libuv_rendezvous def main(): @@ -15,21 +17,30 @@ def main(): world = int(os.environ["WORLD_SIZE"]) assert world == 2, f"P0 gate requires exactly two ranks, got {world}" torch.set_num_threads(1) + if WINDOWS: + disable_libuv_rendezvous() dist.init_process_group("gloo", timeout=timedelta(seconds=60)) try: torch.manual_seed(1234) model = OptimizedMOE(8, 8, num_experts=2, top_k=2) ddp = DDP(model, find_unused_parameters=True, broadcast_buffers=False) optimizer = torch.optim.SGD(ddp.parameters(), lr=0.05) + # A constant image is a degenerate normalization case: BatchNorm and + # the experts' GroupNorm can legitimately remove the entire signal. + # Keep the fixture deterministic, but include spatial/channel variation + # so this gate measures real routed gradients on every backend. + pattern = torch.linspace(-1.0, 1.0, steps=4 * 8 * 2 * 2, dtype=torch.float32).reshape(4, 8, 2, 2) + pattern = (pattern - pattern.mean()) / pattern.std() for step in range(2): optimizer.zero_grad(set_to_none=True) - inputs = torch.full((4, 8, 2, 2), 1.0 + rank + step * 0.25) + inputs = pattern + 0.25 * rank + 0.1 * step loss = ddp(inputs).square().mean() loss.backward() - grads = [p.grad for p in ddp.module.parameters() if p.requires_grad and p.grad is not None] - assert grads, "routed module produced no gradients" - assert all(torch.isfinite(grad).all() for grad in grads), "non-finite routed gradient" - assert sum(float(grad.abs().sum()) for grad in grads) > 0.0, "all routed gradients are zero" + routed_params = [p for p in ddp.module.experts.parameters() if p.requires_grad] + routed_grads = [p.grad for p in routed_params if p.grad is not None] + assert len(routed_grads) == len(routed_params), "routed experts produced incomplete gradients" + assert all(torch.isfinite(grad).all() for grad in routed_grads), "non-finite routed gradient" + assert sum(float(grad.abs().sum()) for grad in routed_grads) > 0.0, "all routed gradients are zero" optimizer.step() flat = torch.cat([p.detach().reshape(-1) for p in ddp.module.parameters()]) gathered = [torch.empty_like(flat) for _ in range(world)] diff --git a/tests/ddp_telemetry_smoke.py b/tests/ddp_telemetry_smoke.py index 7c86aed3c..aeb3ac6f6 100644 --- a/tests/ddp_telemetry_smoke.py +++ b/tests/ddp_telemetry_smoke.py @@ -12,6 +12,8 @@ from ultralytics.engine.telemetry import TrainingTelemetry from ultralytics.nn.modules.mot import MoTBlock +from ultralytics.utils import WINDOWS +from ultralytics.utils.torchrun import disable_libuv_rendezvous def main(): @@ -20,6 +22,8 @@ def main(): out_dir = Path(os.environ["TELEMETRY_SMOKE_DIR"]) assert world == 2, f"telemetry gate requires exactly two ranks, got {world}" torch.set_num_threads(1) + if WINDOWS: + disable_libuv_rendezvous() dist.init_process_group("gloo", timeout=timedelta(seconds=60)) try: torch.manual_seed(9000 + rank) diff --git a/tests/test_edge_deployment_contract.py b/tests/test_edge_deployment_contract.py index 2f58a2498..81e75a122 100644 --- a/tests/test_edge_deployment_contract.py +++ b/tests/test_edge_deployment_contract.py @@ -39,6 +39,17 @@ def test_profiles_are_explicit_and_case_insensitive(): assert 0.0 < profile.conf_threshold < 1.0 assert 0.0 < profile.iou_threshold < 1.0 assert profile.keep_aspect_ratio is True + assert profile.max_det == 300 + vis = edge_utils.get_profile("visdrone") + assert vis.image_size == (640, 640) + assert vis.conf_threshold == pytest.approx(0.001) + assert vis.iou_threshold == pytest.approx(0.70) + assert vis.multi_label is True + sku = edge_utils.get_profile("sku110k") + assert sku.image_size == (1280, 1280) + assert sku.conf_threshold == pytest.approx(0.25) + assert sku.iou_threshold == pytest.approx(0.60) + assert sku.multi_label is True def test_unknown_profile_error_lists_supported_profiles(): @@ -137,6 +148,48 @@ def test_profile_arg_parser_applies_explicit_threshold_overrides(): assert args.iou == pytest.approx(0.71) +def test_profile_resolution_produces_canonical_visdrone_protocol(): + profile = edge_utils.get_profile("visdrone") + resolved = edge_utils.resolve_profile_options(profile) + assert resolved == { + "imgsz": (640, 640), + "conf": pytest.approx(0.001), + "iou": pytest.approx(0.70), + "max_det": 300, + "multi_label": True, + "letterbox": True, + } + overridden = edge_utils.resolve_profile_options( + profile, imgsz=512, conf=0.2, iou=0.5, max_det=50, multi_label=False + ) + assert overridden == { + "imgsz": 512, + "conf": pytest.approx(0.2), + "iou": pytest.approx(0.5), + "max_det": 50, + "multi_label": False, + "letterbox": True, + } + with pytest.raises(ValueError, match="finite"): + edge_utils.resolve_profile_options(profile, conf=float("nan")) + + +def test_cpp_benchmark_declares_canonical_profile_and_evidence_sidecar(): + source = (EDGE_DIR / "cpp" / "edge_benchmark.cpp").read_text(encoding="utf-8") + assert 'args.profile == "visdrone" ? 640 : 1280' in source + assert 'args.profile == "visdrone" ? 0.001f : 0.25f' in source + assert 'args.profile == "visdrone" ? 0.70f : 0.60f' in source + assert 'args.profile == "sku110k"' in source + assert 'args.profile == "visdrone" || args.profile == "sku110k"' in source + assert "--multi-label" in source and "--single-label" in source + assert "--max-det" in source and "--min-images" in source + assert "write_json(args.json_output" in source + assert "validate_unique_stems" in source + assert "static_cast(line[0]) == 0xef" in source + assert "line.front() == line.back()" in source + assert "line.front() == '\"' || line.front() == '\\''" in source + + @pytest.mark.skipif(shutil.which("cmake") is None, reason="cmake is required for the C++ smoke test") def test_cmake_stub_cli_contract(tmp_path): """The C++ target keeps a stable usage contract in both repository variants. diff --git a/tests/test_edge_deployment_utils.py b/tests/test_edge_deployment_utils.py index 2c6557dbf..910124007 100644 --- a/tests/test_edge_deployment_utils.py +++ b/tests/test_edge_deployment_utils.py @@ -15,10 +15,13 @@ def test_letterbox_profile_keeps_aspect_ratio(): - ratio, new_unpad, pad = edge_utils.letterbox_shape((540, 960), edge_utils.get_profile("visdrone").image_size) + profile = edge_utils.get_profile("visdrone") + assert profile.image_size == (640, 640) + assert profile.max_det == 300 and profile.multi_label is True + ratio, new_unpad, pad = edge_utils.letterbox_shape((540, 960), profile.image_size) assert ratio > 0 assert new_unpad[0] <= 960 - assert new_unpad[1] <= 544 + assert new_unpad[1] <= 640 assert pad[0] >= 0 and pad[1] >= 0 diff --git a/tests/test_issue51_runtime_contract.py b/tests/test_issue51_runtime_contract.py index 82993c033..48bda8712 100644 --- a/tests/test_issue51_runtime_contract.py +++ b/tests/test_issue51_runtime_contract.py @@ -9,6 +9,8 @@ import argparse import importlib.util +import json +import re import sys from pathlib import Path @@ -29,6 +31,95 @@ def load_module(name: str, path: Path): return module +def load_prediction_diff(): + """Load the dataclass-based prediction diagnostic as an importable script.""" + name = "issue51_prediction_diff" + spec = importlib.util.spec_from_file_location(name, EDGE / "scripts" / "prediction_diff.py") + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def load_environment_collector(): + """Load the dependency-free host evidence collector.""" + name = "issue51_collect_environment" + spec = importlib.util.spec_from_file_location( + name, EDGE / "scripts" / "collect_environment.py" + ) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +def test_environment_collector_emits_auditable_schema(tmp_path): + """Environment evidence is machine-readable and records missing tools explicitly.""" + collector = load_environment_collector() + args = collector._parser().parse_args( + [ + "--repo-root", str(ROOT), + "--backend", "onnx", + "--execution-provider", "cpu", + "--threads", "4", + "--warmup", "2", + "--runs", "3", + ] + ) + payload = collector.collect_environment(args) + assert payload["schema_version"] == "issue51-environment/v1" + assert payload["host"]["logical_cpus"] is None or payload["host"]["logical_cpus"] >= 1 + assert payload["runtime_protocol"] == { + "backend": "onnx", + "execution_provider": "cpu", + "threads": 4, + "warmup": 2, + "runs": 3, + } + assert set(payload["sdk_roots"]) == {"onnxruntime", "ncnn", "mnn", "tensorrt"} + assert "available" in payload["gpu"] + json.dumps(payload, ensure_ascii=False) + + output = tmp_path / "environment.json" + assert collector.main(["--repo-root", str(ROOT), "--output", str(output)]) == 0 + written = json.loads(output.read_text(encoding="utf-8")) + assert written["schema_version"] == "issue51-environment/v1" + + +def test_environment_collector_rejects_invalid_benchmark_protocol(): + collector = load_environment_collector() + assert collector.main(["--threads", "0"]) == 2 + assert collector.main(["--runs", "-1"]) == 2 + assert collector.main(["--warmup", "-1"]) == 2 + + +def test_cpp_runner_exposes_auditable_benchmark_sidecar(): + """The documented host/protocol sidecar must be wired into the C++ CLI.""" + source = (EDGE / "cpp" / "src" / "main.cpp").read_text(encoding="utf-8") + assert "--benchmark-json" in source + assert "write_benchmark_json" in source + for field in ("schema_version", "status", "protocol", "execution_provider", "host", + "architecture", "compiler", "cpu", "logical_cpus", "build_date", + "summary", "timed_images", "timing_csv", "letterbox", "nms_mode", + "cw_sigma", "class_count", "timing_ms"): + assert f'\\"{field}\\"' in source + assert "benchmark_requested" in source + + +def test_environment_collector_is_dependency_free_and_schema_versioned(): + """Environment evidence must be machine-readable without edge SDKs.""" + collector = EDGE / "scripts" / "collect_environment.py" + schema = json.loads((EDGE / "environment.schema.json").read_text(encoding="utf-8")) + source = collector.read_text(encoding="utf-8") + assert "subprocess" in source and "SCHEMA_VERSION = \"issue51-environment/v1\"" in source + assert schema["properties"]["schema_version"]["const"] == "issue51-environment/v1" + assert set(schema["required"]) >= { + "captured_at_utc", "host", "tools", "sdk_roots", "runtime_protocol", "repository" + } + + def test_calibration_selection_enforces_issue_floor(tmp_path): """Calibration selection is deterministic and requires at least 300 images.""" quant = load_module("issue51_quant", EDGE / "scripts" / "quantize_int8.py") @@ -60,6 +151,17 @@ def test_export_layout_normalizers_accept_common_shapes(): parity.normalize_output(np.zeros((2, 5, 14), dtype=np.float32), 14) +def test_mnn_directory_order_matches_case_folded_protocol(tmp_path): + """MNN helpers must enumerate a directory in the canonical order.""" + for name in ("Z.jpg", "a.jpg", "B.jpg"): + (tmp_path / name).write_bytes(b"image") + mnn_val = load_module("issue51_mnn_val_order", EDGE / "scripts" / "mnn_val.py") + mnn_parity = load_module("issue51_mnn_parity_order", EDGE / "scripts" / "mnn_parity.py") + expected = ["a.jpg", "B.jpg", "Z.jpg"] + assert [path.name for path in mnn_val.image_list(tmp_path, 0)] == expected + assert [path.name for path in mnn_parity.image_list(tmp_path, 0)] == expected + + def test_nms_is_class_offset_friendly_and_handles_empty(): """The MNN decoder handles empty candidates and suppresses overlaps.""" mnn_val = load_module("issue51_mnn_val_nms", EDGE / "scripts" / "mnn_val.py") @@ -83,6 +185,41 @@ def test_parity_image_list_rejects_duplicate_stems(tmp_path): parity.image_list(tmp_path, 0) +@pytest.mark.parametrize("script", ["mnn_val.py", "mnn_parity.py"]) +def test_mnn_tools_preserve_frozen_image_list_order(tmp_path, script): + """MNN validation must consume the same ordered list as the mAP tools.""" + module = load_module("issue51_mnn_list_" + script, EDGE / "scripts" / script) + image_dir = tmp_path / "images with spaces" + image_dir.mkdir() + first = image_dir / "B frame.png" + second = image_dir / "a frame.jpg" + first.write_bytes(b"first") + second.write_bytes(b"second") + image_list = tmp_path / "validation.list" + image_list.write_text( + '\ufeff# frozen validation order\n"images with spaces/B frame.png"\n' + "'images with spaces/a frame.jpg'\n", + encoding="utf-8", + ) + + assert module.image_list(image_list, 0) == [first.resolve(), second.resolve()] + assert module.image_list(image_list, 1) == [first.resolve()] + + +@pytest.mark.parametrize("script", ["mnn_val.py", "mnn_parity.py"]) +def test_mnn_tools_reject_casefolded_duplicate_list_stems(tmp_path, script): + module = load_module("issue51_mnn_duplicate_" + script, EDGE / "scripts" / script) + first = tmp_path / "Frame.jpg" + second = tmp_path / "frame.png" + first.touch() + second.touch() + image_list = tmp_path / "validation.txt" + image_list.write_text(f"{first}\n{second}\n", encoding="utf-8") + + with pytest.raises(RuntimeError, match="stems are not unique"): + module.image_list(image_list, 0) + + def test_map_delta_gate_is_optional_and_inclusive(): """A declared mAP budget is inclusive and requires a reference value.""" evaluator = load_module("issue51_eval_map_gate", EDGE / "scripts" / "eval_map.py") @@ -106,6 +243,48 @@ def test_map_delta_gate_is_optional_and_inclusive(): assert evaluator.extract_reference_map({"results_dict": {"mAP50-95": 0.2036}}) == pytest.approx(0.2036) +def test_map_delta_reports_and_gates_absolute_percentage_points(): + evaluator = load_module("issue51_eval_pp_gate", EDGE / "scripts" / "eval_map.py") + assert evaluator.delta_gate_passes_pp(0.5, 0.5) is True + assert evaluator.delta_gate_passes_pp(0.500001, 0.5) is False + result = { + "mAP50-95": 0.199, + "reference_mAP50-95": 0.2, + "delta_mAP50-95_abs": -0.001, + "delta_mAP50-95_pp": -0.1, + "abs_delta_mAP50-95_pp": 0.1, + "delta_mAP50-95_pct": -0.5, + "abs_delta_mAP50-95_pct": 0.5, + } + assert evaluator.apply_delta_gate(result, None, 0.5) == 0 + assert result["mAP50-95_absolute_delta_gate_passed"] is True + assert result["mAP50-95_delta_gate_passed"] is True + with pytest.raises(ValueError, match="either"): + evaluator.validate_delta_budget(0.5, Path("reference.json"), 0.5) + + +def test_prediction_class_range_is_strict_only_for_formal_runs(tmp_path): + """Diagnostic parsing skips stale class IDs; formal parsing rejects them.""" + evaluator = load_module("issue51_eval_prediction_class", EDGE / "scripts" / "eval_map.py") + class TorchStub: + float32 = object() + int64 = object() + + @staticmethod + def tensor(values, dtype=None): + return np.asarray(values) + + prediction = tmp_path / "frame.txt" + prediction.write_text("99 0.8 0 0 10 10\n0 0.7 1 1 8 8\n", encoding="utf-8") + + boxes, scores, classes = evaluator.load_predictions(prediction, TorchStub, 10, strict=False) + assert boxes.shape == (1, 4) + assert scores.tolist() == pytest.approx([0.7]) + assert classes.tolist() == [0] + with pytest.raises(ValueError, match="class 99 outside"): + evaluator.load_predictions(prediction, TorchStub, 10, strict=True) + + def test_map_gate_rejects_ambiguous_cli_combinations(monkeypatch): """Smoke subsets cannot claim the full image-count or delta gates.""" evaluator = load_module("issue51_eval_map_args", EDGE / "scripts" / "eval_map.py") @@ -180,6 +359,65 @@ def test_exporter_reports_mnn_as_pending_parity(): assert '"runtime_smoke_checked": False' in export assert '"acceptance_ready": False' in export assert '"parity_required": True' in export + assert '"routing_semantics"' in export + + +def test_ncnn_status_normalization_handles_boolean_bindings(): + """Python ncnn bindings may expose success as ``True`` instead of zero.""" + export = load_module("issue51_export_ncnn_status", EDGE / "scripts" / "export_models.py") + assert export._ncnn_code(True) == 0 + assert export._ncnn_code(False) != 0 + assert export._ncnn_code(0) == 0 + assert export._ncnn_code(1) != 0 + + +def test_exporter_dense_routing_state_is_reversible(): + """Export preflight records and restores the model's routing flags.""" + export = load_module("issue51_export_routing", EDGE / "scripts" / "export_models.py") + + class RoutingLayer: + use_top_k = True + + class MoELayer: + use_sparse_inference = True + + class ModuleList: + def modules(self): + return [self, RoutingLayer(), MoELayer()] + + class Model: + model = ModuleList() + + state, router_count, esmoe_count = export._force_ncnn_dense(Model()) + assert (router_count, esmoe_count, export._routing_overlap(state)) == (1, 1, 0) + assert state[0][0].use_top_k is False + assert state[1][0].use_sparse_inference is False + for module, attribute, value in reversed(state): + setattr(module, attribute, value) + assert state[0][0].use_top_k is True + assert state[1][0].use_sparse_inference is True + assert export._routing_record(1, 1)["routing_semantics"] == "dense_fallback" + assert export._routing_record(0, 0)["routing_semantics"] == "not_applicable" + + class CombinedMoERoutingLayer: + use_top_k = True + use_sparse_inference = True + + class CombinedModel: + class Modules: + def modules(self): + return [self, CombinedMoERoutingLayer()] + + model = Modules() + + combined_state, routers, esmoe = export._force_ncnn_dense(CombinedModel()) + overlap = export._routing_overlap(combined_state) + assert (routers, esmoe, overlap) == (1, 1, 1) + assert export._routing_record(routers, esmoe, overlap)["routing_layers"]["total"] == 1 + for module, attribute, value in reversed(combined_state): + setattr(module, attribute, value) + assert combined_state[0][0].use_top_k is True + assert combined_state[1][0].use_sparse_inference is True def test_repository_contains_no_legacy_issue51_path(): @@ -190,3 +428,869 @@ def test_repository_contains_no_legacy_issue51_path(): encoding="utf-8" ) assert "YOLO-Master-EsMoE-N-ONNX-NCNN-MNN-CPP" not in validation + + +def test_runtime_sources_expose_profile_and_portability_guards(): + """The C++ runner records the canonical profile and validates SDK/runtime assumptions.""" + main = (EDGE / "cpp" / "src" / "main.cpp").read_text(encoding="utf-8") + assert '"--profile"' in main + assert 'profile == "visdrone"' in main + assert "imgsz = 640" in main and "conf = 0.001f" in main and "iou = 0.70f" in main + assert "multi_label = true" in main and "profile=" in main + + +def test_ncnn_graph_endpoint_resolution_is_metadata_first_and_fail_closed(): + """NCNN exports must not silently decode an arbitrary terminal tensor.""" + source = (EDGE / "cpp" / "src" / "ncnn_backend.cpp").read_text(encoding="utf-8") + header = (EDGE / "cpp" / "include" / "ncnn_backend.hpp").read_text(encoding="utf-8") + assert "inspect_param_graph" in source + assert "multiple input blobs; provide metadata.yaml input_blob" in source + assert "multiple terminal blobs; provide metadata.yaml output_blob" in source + assert "metadata output_blob '" in source + assert "proto_required_" in source and "required prototype blob" in source + assert "std::filesystem::u8path(path)" in source + assert "per_model_metadata.replace_extension(\".metadata.yaml\")" in source + assert "metadata input_blob and output_blob must differ" in source + assert "std::string out_proto_" in header + exporter = (EDGE / "scripts" / "export_models.py").read_text(encoding="utf-8") + assert "metadata_per_model" in exporter + assert "stem-specific file precedence" in exporter + + +def test_cpp_sku_profile_records_multi_label_protocol(): + """The SKU-110K profile must match the evaluator's explicit decode metadata.""" + main = (EDGE / "cpp" / "src" / "main.cpp").read_text(encoding="utf-8") + sku_block = main.split('} else if (profile == "sku110k") {', 1)[1].split( + '} else if (profile != "default")', 1 + )[0] + assert "multilabel_opt->count() == 0" in sku_block + assert "multilabel = true" in sku_block + assert "validate_unique_stems" in main + + common_source = (EDGE / "cpp" / "src" / "common.cpp").read_text(encoding="utf-8") + assert 'ext == ".txt" || ext == ".list"' in common_source + assert ".txt or .list image list" in main + assert "yaml_scalar" in common_source + assert 'extension == ".txt" || extension == ".list"' in common_source + assert "return resolve_image_list(c.string())" in common_source + + cmake = (EDGE / "cpp" / "CMakeLists.txt").read_text(encoding="utf-8") + assert "REQUIRE_ORT" in cmake and "REQUIRE_NCNN" in cmake and "REQUIRE_MNN" in cmake + assert "ALLOW_NO_BACKENDS" in cmake + + package = (EDGE / "scripts" / "package_linux.sh").read_text(encoding="utf-8") + assert "NCNN_AVAILABLE" in package and "MNN_AVAILABLE" in package + assert "neither NCNN nor MNN SDK is available" in package + assert 'MNN SDK not found at "$MNN_ROOT"' not in package + + ncnn = (EDGE / "cpp" / "src" / "ncnn_backend.cpp").read_text(encoding="utf-8") + assert "metadata_input" in ncnn and "failed to set input blob" in ncnn + ort = (EDGE / "cpp" / "src" / "ort_backend.cpp").read_text(encoding="utf-8") + assert "MultiByteToWideChar" in ort and "no FP32 rank-3 detection output" in ort + assert "detection output is ambiguous" in ort + assert "round-to-nearest" in ort and "remainder == halfway" in ort + mnn = (EDGE / "cpp" / "src" / "mnn_backend.cpp").read_text(encoding="utf-8") + assert "checked_mnn_call" in mnn and "detection output is ambiguous" in mnn + assert "#include " in mnn + + common = (EDGE / "cpp" / "src" / "common.cpp").read_text(encoding="utf-8") + assert "std::string* input_blob" in common and "input_blob:" in common + assert "small_conf_thresh" in common and "candidate_conf_threshold" in common + assert "--small-conf" in main and "--small-area" in main + assert "small_conf=" in main and "small_area=" in main + + quant = (EDGE / "scripts" / "quantize_int8.py").read_text(encoding="utf-8") + assert "--validation-images" in quant + assert "calibration and validation sets overlap by SHA256" in quant + assert '"acceptance_ready": False' in quant + + standalone = (EDGE / "scripts" / "eval_map_standalone.py").read_text(encoding="utf-8") + evaluator = (EDGE / "scripts" / "eval_map.py").read_text(encoding="utf-8") + assert "PROFILE_PROTOCOLS" in evaluator and '"sku110k"' in evaluator + assert "--max-abs-delta-pp" in standalone + assert "--profile" in standalone and "--nc" in standalone + assert "PROFILE_PROTOCOLS" in standalone and '"sku110k"' in standalone + assert "--imgsz" in standalone and "--max-det" in standalone + assert "--max-abs-delta-pct" in standalone + assert "validation image stems are not unique" in standalone + assert '"image_manifest_sha256"' in standalone + + +def test_model_name_metadata_parser_accepts_python_and_json_forms(): + """Metadata serialization must not shift class indices across exporters.""" + # The parser is implemented in C++; keep the source-level contract explicit + # here because this test suite does not require an OpenCV/NCNN toolchain. + source = (EDGE / "cpp" / "src" / "common.cpp").read_text(encoding="utf-8") + assert "JSON object" in source and "JSON list" in source + assert "std::map keyed" in source + + +def test_standalone_map_uses_fixed_class_profile(): + """Absent classes contribute zero instead of being dropped from mAP.""" + standalone = load_module( + "issue51_standalone_metrics", EDGE / "scripts" / "eval_map_standalone.py" + ) + tp = np.ones((1, 10), dtype=bool) + ap = standalone.ap_per_class( + tp, np.array([0.9]), np.array([0]), np.array([0]), num_classes=2 + ) + assert ap.shape == (2, 10) + # The 101-point trapezoidal integration used by Ultralytics assigns a + # 0.995 area to a single perfect detection (the final endpoint is zero). + assert ap[0, 0] == pytest.approx(0.995) + assert np.all(ap[1] == 0.0) + assert ap.mean() == pytest.approx(0.4975) + + +def test_standalone_profile_protocol_defaults_match_runner_profiles(): + standalone = load_module( + "issue51_standalone_profile_protocol", EDGE / "scripts" / "eval_map_standalone.py" + ) + assert standalone.PROFILE_PROTOCOLS["visdrone"]["imgsz"] == 640 + assert standalone.PROFILE_PROTOCOLS["visdrone"]["conf"] == pytest.approx(0.001) + assert standalone.PROFILE_PROTOCOLS["visdrone"]["iou"] == pytest.approx(0.70) + assert standalone.PROFILE_PROTOCOLS["sku110k"]["imgsz"] == 1280 + assert standalone.PROFILE_PROTOCOLS["sku110k"]["conf"] == pytest.approx(0.25) + assert standalone.PROFILE_PROTOCOLS["sku110k"]["iou"] == pytest.approx(0.60) + + +def test_ultralytics_evaluator_profile_protocol_defaults(monkeypatch): + evaluator = load_module( + "issue51_eval_profile_protocol", EDGE / "scripts" / "eval_map.py" + ) + monkeypatch.setattr( + sys, "argv", ["eval_map.py", "--preds", "preds", "--classes", "sku110k", "--smoke"] + ) + args = evaluator.parse_args() + assert args.imgsz == 1280 + assert args.conf == pytest.approx(0.25) + assert args.iou == pytest.approx(0.60) + assert args.max_det == 300 + + +@pytest.mark.parametrize( + ("kind", "content", "pattern"), + [ + ("gt", "0 0.5 0.5 0.2\n", "exactly 5 columns"), + ("gt", "0 0.5 nan 0.2 0.2\n", "NaN or Inf"), + ("gt", "0 0.5 0.5 -0.2 0.2\n", "width and height"), + ("pred", "0 0.9 0 0 1\n", "exactly 6 columns"), + ("pred", "0 0.9 2 2 1 3\n", "x2>x1 and y2>y1"), + ], +) +def test_standalone_parsers_report_malformed_rows(tmp_path, kind, content, pattern): + """Malformed evidence must fail with a path/line-specific diagnostic.""" + standalone = load_module( + "issue51_standalone_parser_" + kind + pattern[:2].replace(" ", "_"), + EDGE / "scripts" / "eval_map_standalone.py", + ) + path = tmp_path / (kind + ".txt") + path.write_text(content, encoding="utf-8") + with pytest.raises(ValueError, match=pattern): + if kind == "gt": + standalone.load_gt(path, 100, 100, num_classes=10) + else: + standalone.load_pred(path, num_classes=10) + + +def test_standalone_visdrone_parser_keeps_valid_classes_and_ignores_reserved_rows(tmp_path): + standalone = load_module( + "issue51_standalone_visdrone", EDGE / "scripts" / "eval_map_standalone.py" + ) + path = tmp_path / "annotations.txt" + path.write_text( + "0,0,10,10,1,1,0,0\n" + "10,10,10,10,0,2,0,0\n" + "20,20,10,10,1,11,0,0\n", + encoding="utf-8", + ) + boxes, classes = standalone.load_gt(path, 100, 100, "visdrone") + assert boxes.shape == (1, 4) + assert classes.tolist() == [0] + + +def test_standalone_visdrone_parser_accepts_whitespace_rows_in_auto_mode(tmp_path): + """Native VisDrone exports may use spaces instead of commas.""" + standalone = load_module( + "issue51_standalone_visdrone_whitespace", EDGE / "scripts" / "eval_map_standalone.py" + ) + path = tmp_path / "annotations.txt" + path.write_text("10 20 30 40 1 2 0 0\n", encoding="utf-8") + boxes, classes = standalone.load_gt(path, 100, 100, "auto") + assert boxes.tolist() == [[10.0, 20.0, 40.0, 60.0]] + assert classes.tolist() == [1] + + +def test_evidence_manifest_cli_template_and_validation(tmp_path): + manifest_module = load_module( + "issue51_evidence_manifest", + EDGE / "scripts" / "evidence_manifest.py", + ) + output = tmp_path / "template.json" + assert manifest_module.main(["create", "--template", "--output", str(output)]) == 0 + payload = json.loads(output.read_text(encoding="utf-8")) + assert payload["status"] == "template" + assert payload["artifacts"]["labels"] is None + assert payload["artifacts"]["predictions"] is None + assert manifest_module.validate_manifest(payload) == [] + assert manifest_module.validate_manifest(payload, acceptance=True) + + +def test_evidence_manifest_records_single_label_protocol(tmp_path): + """The manifest CLI must preserve an explicitly requested decoder mode.""" + manifest_module = load_module( + "issue51_evidence_manifest_label_mode", + EDGE / "scripts" / "evidence_manifest.py", + ) + output = tmp_path / "single-label.json" + assert manifest_module.main( + ["create", "--template", "--single-label", "--output", str(output)] + ) == 0 + payload = json.loads(output.read_text(encoding="utf-8")) + assert payload["protocol"]["multi_label"] is False + + output_default = tmp_path / "multi-label.json" + assert manifest_module.main( + ["create", "--template", "--multi-label", "--output", str(output_default)] + ) == 0 + default_payload = json.loads(output_default.read_text(encoding="utf-8")) + assert default_payload["protocol"]["multi_label"] is True + + +def test_evidence_manifest_acceptance_requires_auditable_digests_and_format_keys(): + manifest_module = load_module( + "issue51_evidence_manifest_audit", EDGE / "scripts" / "evidence_manifest.py" + ) + record = lambda path, digest: {"path": path, "bytes": 1, "sha256": digest} + images = [record(f"{index:04d}.jpg", f"{index:064x}") for index in range(500)] + calibration = [record(f"cal-{index:04d}.jpg", f"{1000 + index:064x}") for index in range(300)] + models = { + "onnx_fp32": {"files": [record("model.onnx", "d" * 64)]}, + "mnn_int8": {"files": [record("model.mnn", "e" * 64)]}, + } + for model in models.values(): + model["sha256"] = manifest_module._list_digest(model["files"]) + report_files = [record("metrics.json", "f" * 64)] + payload = { + "schema_version": manifest_module.SCHEMA_VERSION, + "status": "acceptance-candidate", + "dataset": { + "image_count": len(images), "images": images, + "image_list_sha256": manifest_module._list_digest(images), + }, + "protocol": {"imgsz": 640, "conf": 0.001, "iou": 0.7, "max_det": 300, + "multi_label": True, "letterbox": True, + "small_conf": -1.0, "small_area": 1024.0, + "routing_semantics": "dense_fallback"}, + "training": { + "base_model": "esmoe-n.yaml", "dataset_version": "VisDrone2019-DET", + "epochs": 120, "seed": 0, "command": "yolo train ...", + }, + "artifacts": { + "checkpoint": record("best.pt", "c" * 64), "models": models, + "reports": {"metrics": { + "files": report_files, "sha256": manifest_module._list_digest(report_files), + }}, + "labels": {"count": len(images), "files": images}, + "predictions": {"count": len(images), "files": images}, + }, + "calibration": { + "enabled": True, "image_count": len(calibration), "images": calibration, + "image_list_sha256": manifest_module._list_digest(calibration), + "disjoint_from_validation": True, + }, + "environment": { + "python": "3.10.12", "platform": "Ubuntu-22.04", "machine": "x86_64", + "git_commit": "a" * 40, + }, + "run": {"command": "./yolomaster_edge --profile visdrone ..."}, + } + assert manifest_module.validate_manifest(payload, acceptance=True) == [] + training = payload.pop("training") + assert any("training provenance" in error + for error in manifest_module.validate_manifest(payload, acceptance=True)) + payload["training"] = training + payload["artifacts"]["models"]["onnx_fp32"].pop("sha256") + errors = manifest_module.validate_manifest(payload, acceptance=True) + assert any("onnx_fp32.sha256" in error for error in errors) + payload["artifacts"]["models"]["onnx_fp32"]["sha256"] = manifest_module._list_digest( + payload["artifacts"]["models"]["onnx_fp32"]["files"] + ) + payload["calibration"]["disjoint_from_validation"] = None + assert any("disjoint_from_validation=true" in error + for error in manifest_module.validate_manifest(payload, acceptance=True)) + assert manifest_module._model_format("onnx_fp16") == "onnx" + assert manifest_module._model_format("artifact", {"files": [{"path": "artifact.mnn"}]}) == "mnn" + + +def test_evidence_schema_keeps_acceptance_floors_and_portable_paths(): + """The standalone JSON Schema must enforce the Python validator's core floors.""" + def reject_duplicate_keys(pairs): + result = {} + for key, value in pairs: + assert key not in result, f"duplicate JSON key: {key}" + result[key] = value + return result + + schema = json.loads( + (EDGE / "evidence-manifest.schema.json").read_text(encoding="utf-8"), + object_pairs_hook=reject_duplicate_keys, + ) + acceptance = schema["allOf"][0]["then"] + dataset = acceptance["properties"]["dataset"]["properties"] + artifacts = acceptance["properties"]["artifacts"]["properties"] + calibration = acceptance["allOf"][0]["then"]["properties"]["calibration"]["properties"] + + assert dataset["image_count"]["minimum"] == 500 + assert dataset["images"]["minItems"] == 500 + assert artifacts["models"]["minProperties"] == 1 + assert artifacts["reports"]["minProperties"] == 1 + assert calibration["image_count"]["minimum"] == 300 + assert calibration["images"]["minItems"] == 300 + assert "routing_semantics" in acceptance["properties"]["protocol"]["required"] + assert "small_conf" in acceptance["properties"]["protocol"]["required"] + assert "small_area" in acceptance["properties"]["protocol"]["required"] + assert acceptance["properties"]["protocol"]["properties"]["small_conf"]["minimum"] == -1 + assert acceptance["properties"]["protocol"]["properties"]["small_area"]["minimum"] == 0 + + pattern = re.compile(schema["$defs"]["file_record"]["properties"]["path"]["pattern"]) + assert pattern.fullmatch("images/0001.jpg") + for unsafe in ("../outside.jpg", "images/../outside.jpg", "/absolute.jpg", "C:/absolute.jpg", "a\\b.jpg"): + assert pattern.fullmatch(unsafe) is None + + +def test_evidence_manifest_records_and_validates_small_object_protocol(tmp_path): + manifest_module = load_module( + "issue51_evidence_manifest_small_protocol", EDGE / "scripts" / "evidence_manifest.py" + ) + output = tmp_path / "small-protocol-template.json" + assert manifest_module.main([ + "create", "--template", "--small-conf", "0.05", "--small-area", "1024", + "--output", str(output), + ]) == 0 + payload = json.loads(output.read_text(encoding="utf-8")) + assert payload["protocol"]["small_conf"] == pytest.approx(0.05) + assert payload["protocol"]["small_area"] == pytest.approx(1024.0) + payload["protocol"]["small_conf"] = 1.5 + assert any("small_conf" in error for error in manifest_module.validate_manifest(payload)) + payload["protocol"]["small_conf"] = -1.0 + payload["protocol"]["small_area"] = -0.1 + assert any("small_area" in error for error in manifest_module.validate_manifest(payload)) + + +def test_evidence_manifest_enforces_image_and_calibration_gates(): + manifest_module = load_module( + "issue51_evidence_manifest_gates", + EDGE / "scripts" / "evidence_manifest.py", + ) + + def record(path, digest): + return {"path": path, "bytes": 1, "sha256": digest} + + images = [record("{:04d}.jpg".format(i), "{:064x}".format(i)) for i in range(500)] + calibration = [ + record("cal-{:04d}.jpg".format(i), "{:064x}".format(1000 + i)) + for i in range(300) + ] + model_onnx = [{"path": "model.onnx", "bytes": 1, "sha256": "d" * 64}] + model_mnn = [{"path": "model.mnn", "bytes": 1, "sha256": "e" * 64}] + report_files = [{"path": "metrics.json", "bytes": 1, "sha256": "f" * 64}] + payload = { + "schema_version": manifest_module.SCHEMA_VERSION, + "status": "acceptance-candidate", + "dataset": { + "image_count": len(images), + "images": images, + "image_list_sha256": manifest_module._list_digest(images), + }, + "protocol": { + "imgsz": 640, + "conf": 0.001, + "iou": 0.7, + "max_det": 300, + "multi_label": True, + "letterbox": True, + "small_conf": -1.0, + "small_area": 1024.0, + "routing_semantics": "dense_fallback", + }, + "training": { + "base_model": "esmoe-n.yaml", + "dataset_version": "VisDrone2019-DET", + "epochs": 120, + "seed": 0, + "command": "yolo train ...", + }, + "artifacts": { + "checkpoint": {"path": "best.pt", "bytes": 1, "sha256": "c" * 64}, + "models": { + "onnx": {"files": model_onnx, "sha256": manifest_module._list_digest(model_onnx)}, + "mnn": {"files": model_mnn, "sha256": manifest_module._list_digest(model_mnn)}, + }, + "reports": { + "metrics": {"files": report_files, "sha256": manifest_module._list_digest(report_files)}, + }, + "labels": {"count": len(images), "files": images}, + "predictions": {"count": len(images), "files": images}, + }, + "calibration": { + "enabled": True, + "image_count": len(calibration), + "images": calibration, + "image_list_sha256": manifest_module._list_digest(calibration), + "disjoint_from_validation": True, + }, + "environment": { + "python": "3.10.12", + "platform": "Ubuntu-22.04", + "machine": "x86_64", + "git_commit": "a" * 40, + }, + "run": {"command": "./yolomaster_edge --profile visdrone ..."}, + } + assert manifest_module.validate_manifest(payload, acceptance=True) == [] + payload["calibration"]["images"] = calibration[:299] + payload["calibration"]["image_count"] = 299 + assert any("300" in error for error in manifest_module.validate_manifest(payload, acceptance=True)) + + +def test_evidence_manifest_requires_explicit_routing_semantics_for_acceptance(): + manifest_module = load_module( + "issue51_evidence_manifest_routing", EDGE / "scripts" / "evidence_manifest.py" + ) + # Use a minimal payload that already satisfies all other acceptance floors; + # the route field should be the only newly introduced violation. + record = lambda path, digest: {"path": path, "bytes": 1, "sha256": digest} + images = [record("{:04d}.jpg".format(i), "{:064x}".format(i)) for i in range(500)] + model_onnx = [record("model.onnx", "d" * 64)] + model_mnn = [record("model.mnn", "e" * 64)] + reports = [record("metrics.json", "f" * 64)] + payload = { + "schema_version": manifest_module.SCHEMA_VERSION, + "status": "acceptance-candidate", + "dataset": {"image_count": 500, "images": images, + "image_list_sha256": manifest_module._list_digest(images)}, + "protocol": {"imgsz": 640, "conf": 0.001, "iou": 0.7, "max_det": 300, + "multi_label": True, "letterbox": True, + "small_conf": -1.0, "small_area": 1024.0}, + "training": {"base_model": "esmoe", "dataset_version": "v1", "epochs": 1, + "seed": 0, "command": "train"}, + "artifacts": { + "checkpoint": record("best.pt", "c" * 64), + "models": { + "onnx": {"files": model_onnx, "sha256": manifest_module._list_digest(model_onnx)}, + "mnn": {"files": model_mnn, "sha256": manifest_module._list_digest(model_mnn)}, + }, + "reports": {"metrics": {"files": reports, "sha256": manifest_module._list_digest(reports)}}, + "labels": {"count": 500, "files": images}, + "predictions": {"count": 500, "files": images}, + }, + "calibration": {"enabled": False, "image_count": 0, "images": [], + "image_list_sha256": None, "disjoint_from_validation": None}, + "environment": {"python": "3.10", "platform": "linux", "machine": "x86_64", + "git_commit": "a" * 40}, + "run": {"command": "run"}, + } + errors = manifest_module.validate_manifest(payload, acceptance=True) + assert any("routing_semantics" in error for error in errors) + payload["protocol"]["routing_semantics"] = "dense_fallback" + assert not any("routing_semantics" in error for error in manifest_module.validate_manifest( + payload, acceptance=True + )) + + +def test_evidence_manifest_rejects_duplicate_image_stems(tmp_path): + manifest_module = load_module( + "issue51_evidence_manifest_stems", + EDGE / "scripts" / "evidence_manifest.py", + ) + (tmp_path / "a.jpg").write_bytes(b"a") + (tmp_path / "nested").mkdir() + (tmp_path / "nested" / "a.png").write_bytes(b"b") + base, paths = manifest_module.resolve_image_list(tmp_path) + records = manifest_module._image_records(paths, base) + assert any("duplicate image stems" in error for error in manifest_module.validate_manifest( + {"schema_version": manifest_module.SCHEMA_VERSION, "dataset": {"image_count": 2, "images": records}}, + acceptance=False, + )) + + +def test_evidence_manifest_verify_detects_hash_changes(tmp_path): + manifest_module = load_module( + "issue51_evidence_manifest_verify", + EDGE / "scripts" / "evidence_manifest.py", + ) + root = tmp_path / "images" + root.mkdir() + image = root / "a.jpg" + image.write_bytes(b"original") + _, paths = manifest_module.resolve_image_list(root) + records = manifest_module._image_records(paths, root) + payload = { + "schema_version": manifest_module.SCHEMA_VERSION, + "status": "diagnostic", + "dataset": {"image_count": 1, "images": records}, + "protocol": {}, + "artifacts": {"checkpoint": None, "models": {}, "labels": None, "predictions": None}, + "calibration": {"enabled": False, "images": []}, + } + assert manifest_module._verify_records(records, root, "images") == [] + image.write_bytes(b"changed") + assert any("mismatch" in error for error in manifest_module._verify_records(records, root, "images")) + + +def test_evidence_manifest_records_and_verifies_report_collections(tmp_path): + manifest_module = load_module( + "issue51_evidence_manifest_reports", + EDGE / "scripts" / "evidence_manifest.py", + ) + report_root = tmp_path / "reports" + report_root.mkdir() + report = report_root / "map.json" + report.write_text("{}\n", encoding="utf-8") + output = tmp_path / "manifest.json" + assert manifest_module.main([ + "create", "--template", "--output", str(output), + ]) == 0 + payload = json.loads(output.read_text(encoding="utf-8")) + records = manifest_module.collect_records(report) + payload["artifacts"]["reports"] = { + "map": {"files": records, "sha256": manifest_module._list_digest(records)}, + } + assert manifest_module.validate_manifest(payload) == [] + assert manifest_module._verify_records(records, report_root, "report.map") == [] + report.write_text("changed\n", encoding="utf-8") + assert any("SHA256 mismatch" in error for error in manifest_module._verify_records( + records, report_root, "report.map" + )) + + +def test_evidence_manifest_create_accepts_external_image_list_and_report(tmp_path): + manifest_module = load_module( + "issue51_evidence_manifest_cli_roots", + EDGE / "scripts" / "evidence_manifest.py", + ) + image_root = tmp_path / "images" + image_root.mkdir() + image = image_root / "frame.jpg" + image.write_bytes(b"image") + list_file = tmp_path / "ordered.list" + list_file.write_text(str(image) + "\n", encoding="utf-8") + report = tmp_path / "timing.csv" + report.write_text("tag,total_ms\nframe.jpg,1\n", encoding="utf-8") + output = tmp_path / "manifest.json" + assert manifest_module.main([ + "create", "--images", str(list_file), "--image-root", str(tmp_path), + "--report", "timing=" + str(report), "--output", str(output), + ]) == 0 + payload = json.loads(output.read_text(encoding="utf-8")) + assert payload["dataset"]["images"][0]["path"] == "images/frame.jpg" + assert payload["artifacts"]["reports"]["timing"]["files"][0]["path"] == "timing.csv" + + +def test_all_image_list_consumers_accept_bom_and_quoted_paths(tmp_path): + """A frozen list must have identical semantics in every evaluator.""" + image_root = tmp_path / "images with spaces" + image_root.mkdir() + image = image_root / "frame 01.jpg" + image.write_bytes(b"image") + list_file = tmp_path / "ordered.list" + list_file.write_text("\ufeff# generated on Windows\n\"images with spaces/frame 01.jpg\"\n", encoding="utf-8") + + manifest = load_module("issue51_manifest_list_syntax", EDGE / "scripts" / "evidence_manifest.py") + base, paths = manifest.resolve_image_list(list_file) + assert paths == [image.resolve()] + assert base == tmp_path.resolve() + + evaluator = load_module("issue51_eval_list_syntax", EDGE / "scripts" / "eval_map.py") + eval_base, eval_paths = evaluator._resolve_images(list_file) + assert eval_paths == [image.resolve()] + assert eval_base == tmp_path.resolve() + + standalone = load_module( + "issue51_standalone_list_syntax", EDGE / "scripts" / "eval_map_standalone.py" + ) + standalone_base, standalone_paths = standalone._resolve_images(list_file) + assert standalone_paths == [str(image.resolve())] + assert standalone_base == str(tmp_path.resolve()) + + +def test_eval_map_formal_loaders_reject_malformed_rows(tmp_path): + """The Ultralytics-backed evaluator must not silently alter formal labels.""" + evaluator = load_module("issue51_eval_map_strict", EDGE / "scripts" / "eval_map.py") + + class TorchStub: + float32 = object() + int64 = object() + + @staticmethod + def tensor(values, dtype=None): + return np.asarray(values) + + labels = tmp_path / "bad-labels.txt" + labels.write_text("0 0.5 0.5 0.2\n", encoding="utf-8") + with pytest.raises(ValueError, match="exactly 5 columns"): + evaluator.load_gt(labels, 100, 100, TorchStub, "yolo", 10, strict=True) + preds = tmp_path / "bad-preds.txt" + preds.write_text("0 0.9 0 0 1 1 stale\n", encoding="utf-8") + with pytest.raises(ValueError, match="exactly 6 columns"): + evaluator.load_predictions(preds, TorchStub, 10, strict=True) + + +def test_formal_evaluators_require_reference_protocol_metadata(): + """A delta gate cannot compare a scalar-only reference report.""" + evaluator = load_module("issue51_eval_map_reference", EDGE / "scripts" / "eval_map.py") + current = { + "images": 500, + "classes": 10, + "class_profile": "visdrone", + "label_format": "yolo", + "image_manifest_sha256": "a" * 64, + "image_content_manifest_sha256": "b" * 64, + "protocol": { + "imgsz": 640, "conf": 0.001, "iou": 0.70, "max_det": 300, + "multi_label": True, "letterbox": True, "color": "RGB", "layout": "NCHW", + }, + } + with pytest.raises(ValueError, match="image_manifest_sha256"): + evaluator.validate_reference_metadata({"mAP50-95": 0.2}, current, strict=True) + + +def test_formal_evaluators_reject_mismatched_routing_semantics(): + evaluator = load_module("issue51_eval_map_routing", EDGE / "scripts" / "eval_map.py") + current = { + "images": 500, + "classes": 10, + "class_profile": "visdrone", + "label_format": "yolo", + "image_manifest_sha256": "a" * 64, + "image_list_sha256": "b" * 64, + "protocol": { + "imgsz": 640, "conf": 0.001, "iou": 0.70, "max_det": 300, + "multi_label": True, "letterbox": True, "color": "RGB", "layout": "NCHW", + "routing_semantics": "dense_fallback", + }, + } + reference = dict(current) + reference["protocol"] = dict(current["protocol"], routing_semantics="native_sparse") + with pytest.raises(ValueError, match="routing_semantics"): + evaluator.validate_reference_metadata(reference, current, strict=True) + + +def test_metric_reports_hash_ordered_image_contents(tmp_path): + evaluator = load_module("issue51_eval_content_hash", EDGE / "scripts" / "eval_map.py") + root = tmp_path / "images" + root.mkdir() + image = root / "a.jpg" + image.write_bytes(b"first") + first = evaluator.image_content_manifest([image], root) + image.write_bytes(b"second") + second = evaluator.image_content_manifest([image], root) + assert first != second + + standalone = load_module( + "issue51_standalone_content_hash", EDGE / "scripts" / "eval_map_standalone.py" + ) + assert standalone._image_content_manifest([str(image)], str(root)) == second + + +def test_manifest_verifier_rejects_paths_that_escape_root(): + """Evidence verification must never follow an absolute or parent path.""" + manifest = load_module( + "issue51_manifest_path_safety", EDGE / "scripts" / "evidence_manifest.py" + ) + record = {"path": "../outside.bin", "bytes": 1, "sha256": "0" * 64} + assert any("relative POSIX" in error for error in manifest._validate_records([record], "artifact")) + + +def test_calibration_and_validation_stems_are_unambiguous(tmp_path): + """Calibration manifests must be deterministic on case-sensitive hosts.""" + quant = load_module("issue51_quant_stems", EDGE / "scripts" / "quantize_int8.py") + first = tmp_path / "A.jpg" + second = tmp_path / "a.png" + first.touch() + second.touch() + with pytest.raises(ValueError, match="stems are not unique"): + quant._validate_unique_stems([first, second], "calibration") + + +def test_evaluators_match_evidence_manifest_content_digest(tmp_path): + """All evidence tools must identify the same ordered image bytes.""" + image_root = tmp_path / "dataset" / "images" + image_root.mkdir(parents=True) + first = image_root / "A.jpg" + second = image_root / "b.png" + first.write_bytes(b"first image") + second.write_bytes(b"second image") + list_file = tmp_path / "artifacts" / "val.list" + list_file.parent.mkdir() + list_file.write_text(f"{second}\n{first}\n", encoding="utf-8") + + evaluator = load_module("issue51_eval_digest", EDGE / "scripts" / "eval_map.py") + standalone = load_module("issue51_standalone_digest", EDGE / "scripts" / "eval_map_standalone.py") + manifest = load_module("issue51_manifest_digest", EDGE / "scripts" / "evidence_manifest.py") + + eval_root, eval_images = evaluator._resolve_images(list_file, image_root) + standalone_root, standalone_images = standalone._resolve_images(list_file, image_root) + manifest_root, manifest_images = manifest.resolve_image_list(list_file, image_root) + + expected = manifest._list_digest(manifest._image_records(manifest_images, manifest_root)) + assert evaluator.image_content_manifest(eval_images, eval_root) == expected + assert standalone._image_content_manifest(standalone_images, standalone_root) == expected + assert [path.name for path in eval_images] == ["b.png", "A.jpg"] + + +@pytest.mark.parametrize("standalone", [False, True]) +def test_evaluator_rejects_list_entry_outside_image_root(tmp_path, standalone): + image_root = tmp_path / "images" + image_root.mkdir() + outside = tmp_path / "outside.jpg" + outside.write_bytes(b"outside") + list_file = tmp_path / "val.list" + list_file.write_text(str(outside) + "\n", encoding="utf-8") + + script = "eval_map_standalone.py" if standalone else "eval_map.py" + module = load_module("issue51_root_" + str(standalone), EDGE / "scripts" / script) + with pytest.raises(ValueError, match="outside evaluation root"): + module._resolve_images(list_file, image_root) + + +def test_content_digest_changes_when_image_bytes_change(tmp_path): + image = tmp_path / "frame.jpg" + image.write_bytes(b"version one") + evaluator = load_module("issue51_eval_content_change", EDGE / "scripts" / "eval_map.py") + + first = evaluator.image_content_manifest([image], tmp_path) + image.write_bytes(b"version two") + second = evaluator.image_content_manifest([image], tmp_path) + + assert first != second + + +def test_prediction_matching_is_class_aware_and_reports_coordinate_delta(): + diff = load_prediction_diff() + reference = [ + diff.Prediction(0, 0.80, 0, 0, 10, 10), + diff.Prediction(1, 0.90, 20, 20, 30, 30), + ] + candidate = [ + diff.Prediction(0, 0.75, 1, 0, 11, 10), + diff.Prediction(2, 0.90, 20, 20, 30, 30), + ] + matches = diff.match_predictions(reference, candidate, 0.5) + assert len(matches) == 1 + assert matches[0].reference_index == 0 and matches[0].candidate_index == 0 + assert matches[0].iou > 0.7 + assert matches[0].confidence_abs_delta == pytest.approx(0.05) + report = diff.compare_image(reference, candidate, 0.5) + assert report["matched"] == 1 + assert report["unmatched_reference"] == 1 + assert report["unmatched_candidate"] == 1 + + +def test_prediction_parser_rejects_invalid_geometry(tmp_path): + diff = load_prediction_diff() + path = tmp_path / "bad.txt" + path.write_text("0 0.5 4 4 3 8\n", encoding="utf-8") + with pytest.raises(ValueError, match="positive width"): + diff.read_predictions(path) + + +def test_prediction_directory_compare_requires_matching_sets(tmp_path): + diff = load_prediction_diff() + reference, candidate = tmp_path / "ref", tmp_path / "cand" + reference.mkdir() + candidate.mkdir() + (reference / "a.txt").write_text("0 0.9 0 0 10 10\n", encoding="utf-8") + (candidate / "b.txt").write_text("0 0.9 0 0 10 10\n", encoding="utf-8") + with pytest.raises(ValueError, match="file sets differ"): + diff.compare_directories(reference, candidate) + + +def test_prediction_directory_compare_reports_machine_readable_deltas(tmp_path): + diff = load_prediction_diff() + reference, candidate = tmp_path / "ref", tmp_path / "cand" + reference.mkdir() + candidate.mkdir() + (reference / "a.txt").write_text("0 0.9 0 0 10 10\n", encoding="utf-8") + (candidate / "a.txt").write_text("0 0.8 0 0 10 10\n", encoding="utf-8") + report = diff.compare_directories(reference, candidate) + assert report["summary"]["matched_detections"] == 1 + assert report["images"][0]["max_confidence_abs_delta"] == pytest.approx(0.1) + + +def test_prediction_diff_image_lists_match_evaluator_syntax_and_root_guard(tmp_path): + """Prediction diagnostics must consume the same frozen list as mAP tools.""" + diff = load_prediction_diff() + image_root = tmp_path / "images with spaces" + image_root.mkdir() + image = image_root / "frame 01.jpg" + image.write_bytes(b"image") + list_file = tmp_path / "ordered.list" + list_file.write_text('\ufeff# exported by Windows\n"images with spaces/frame 01.jpg"\n', encoding="utf-8") + + indexed = diff.image_files(list_file, image_root) + assert indexed == {"frame 01": image.resolve()} + + outside = tmp_path / "outside.jpg" + outside.write_bytes(b"outside") + list_file.write_text(str(outside) + "\n", encoding="utf-8") + with pytest.raises(ValueError, match="outside evaluation root"): + diff.image_files(list_file, image_root) + + +def test_prediction_diff_reports_image_and_summary_iou_statistics(tmp_path): + diff = load_prediction_diff() + reference, candidate = tmp_path / "ref", tmp_path / "cand" + reference.mkdir() + candidate.mkdir() + (reference / "a.txt").write_text( + "0 0.9 0 0 10 10\n0 0.8 20 20 30 30\n", encoding="utf-8" + ) + (candidate / "a.txt").write_text( + "0 0.9 0 0 9 10\n0 0.8 20 20 30 30\n", encoding="utf-8" + ) + report = diff.compare_directories(reference, candidate, iou_threshold=0.1) + row = report["images"][0] + summary = report["summary"] + assert row["matched_iou_count"] == 2 + assert 0.0 < row["p05_iou"] <= row["p50_iou"] <= row["p95_iou"] <= 1.0 + assert summary["matched_iou_count"] == 2 + assert summary["min_iou"] == pytest.approx(row["min_iou"]) + assert summary["p99_iou"] >= summary["p05_iou"] + + +def test_prediction_diff_min_iou_gate_fails_closed(tmp_path, monkeypatch): + diff = load_prediction_diff() + reference, candidate = tmp_path / "ref", tmp_path / "cand" + reference.mkdir() + candidate.mkdir() + (reference / "a.txt").write_text("0 0.9 0 0 10 10\n", encoding="utf-8") + (candidate / "a.txt").write_text("0 0.9 2 0 12 10\n", encoding="utf-8") + monkeypatch.setattr( + diff.sys, + "argv", + [ + "prediction_diff.py", + "--reference", + str(reference), + "--candidate", + str(candidate), + "--iou", + "0.1", + "--min-iou", + "0.9", + ], + ) + assert diff.main() == 1 + + +def test_prediction_diff_rejects_invalid_matching_iou(tmp_path): + diff = load_prediction_diff() + reference, candidate = tmp_path / "ref", tmp_path / "cand" + reference.mkdir() + candidate.mkdir() + (reference / "a.txt").write_text("0 0.9 0 0 10 10\n", encoding="utf-8") + (candidate / "a.txt").write_text("0 0.9 0 0 10 10\n", encoding="utf-8") + with pytest.raises(ValueError, match="matching IoU threshold"): + diff.compare_directories(reference, candidate, iou_threshold=1.1) diff --git a/tests/test_p0_system_gates.py b/tests/test_p0_system_gates.py index fcd40f9eb..6d5b91f00 100644 --- a/tests/test_p0_system_gates.py +++ b/tests/test_p0_system_gates.py @@ -1,13 +1,16 @@ """Executable P0 gates for distributed, adapter, and export lifecycles.""" import io +import os import subprocess from pathlib import Path from types import SimpleNamespace +import pytest import torch import torch.nn as nn +from ultralytics.utils import MACOS from ultralytics.nn.modules.moa import MoABlock from ultralytics.nn.modules.moe.modules import OptimizedMOE from ultralytics.nn.modules.mot import MoTBlock @@ -83,6 +86,9 @@ def _tiny_multitask_batch(): def test_cpu_gloo_two_rank_routed_continuous_training(): + if MACOS and os.environ.get("PYTEST_XDIST_WORKER"): + pytest.skip("Nested torchrun under macOS xdist is unreliable; run this gate serially") + command = [ *ddp_launch_prefix(), "--master_addr=127.0.0.1", @@ -90,8 +96,11 @@ def test_cpu_gloo_two_rank_routed_continuous_training(): "--nproc_per_node=2", str(ROOT / "tests/ddp_moe_smoke.py"), ] - env = {**ddp_launch_env(), "OMP_NUM_THREADS": "1"} - completed = subprocess.run(command, cwd=ROOT, env=env, text=True, capture_output=True, timeout=90) + env = { + **ddp_launch_env(), + "OMP_NUM_THREADS": "1", + } + completed = subprocess.run(command, cwd=ROOT, env=env, text=True, capture_output=True, timeout=180) assert completed.returncode == 0, completed.stdout + completed.stderr assert "P0 routed DDP gate passed" in completed.stdout diff --git a/tests/test_p2_fixes.py b/tests/test_p2_fixes.py index 054b9fbd5..b7e924291 100644 --- a/tests/test_p2_fixes.py +++ b/tests/test_p2_fixes.py @@ -173,7 +173,9 @@ def test_mot_torchscript_trace(): Note: MoTBlock.forward returns (out, aux_loss) tuple. Tracing should capture the full forward path. """ - block = MoTBlock(32, num_heads=4, top_k=2, window_size=4, n_points=2).eval() + # Exercise the legacy dense tracing contract used by the reference below; + # the default masked export path is covered by test_export_roundtrip.py. + block = MoTBlock(32, num_heads=4, top_k=2, window_size=4, n_points=2, export_masked=False).eval() x = torch.randn(1, 32, 8, 8) with torch.no_grad(): diff --git a/tests/test_python.py b/tests/test_python.py index f6d7c1436..9faa44a51 100644 --- a/tests/test_python.py +++ b/tests/test_python.py @@ -870,7 +870,7 @@ def test_results(model: str, tmp_path): f"'{model}' semantic_mask should match the original image shape!" ) assert r.semantic_mask.data.dtype == torch.uint8, f"'{model}' semantic_mask should use compact class IDs!" - else: + elif Path(model).suffix.lower() not in {".yaml", ".yml"}: assert len(r), f"'{model}' results should not be empty!" r = r.cpu().numpy() print(r, len(r), r.path) # print numpy attributes diff --git a/tests/test_training_telemetry.py b/tests/test_training_telemetry.py index 2aeb0958c..aaed0f9af 100644 --- a/tests/test_training_telemetry.py +++ b/tests/test_training_telemetry.py @@ -8,6 +8,7 @@ import pytest import torch +from ultralytics.utils import MACOS from ultralytics.engine.telemetry import TrainingTelemetry, aggregate_rank_records, device_memory_sample from ultralytics.utils.dist import ddp_launch_env, ddp_launch_prefix, find_free_network_port @@ -112,6 +113,9 @@ def test_training_telemetry_records_cpu_step_contract(tmp_path, monkeypatch): def test_cpu_gloo_two_rank_telemetry_artifact_gate(tmp_path): + if MACOS and os.environ.get("PYTEST_XDIST_WORKER"): + pytest.skip("Nested torchrun under macOS xdist is unreliable; run this gate serially") + command = [ *ddp_launch_prefix(), "--master_addr=127.0.0.1", @@ -125,7 +129,7 @@ def test_cpu_gloo_two_rank_telemetry_artifact_gate(tmp_path): "PYTHONPATH": os.pathsep.join(filter(None, (str(ROOT), os.environ.get("PYTHONPATH")))), "TELEMETRY_SMOKE_DIR": str(tmp_path), } - completed = subprocess.run(command, cwd=ROOT, env=env, text=True, capture_output=True, timeout=90) + completed = subprocess.run(command, cwd=ROOT, env=env, text=True, capture_output=True, timeout=180) assert completed.returncode == 0, completed.stdout + completed.stderr assert "P1 telemetry DDP gate passed" in completed.stdout diff --git a/tests/test_windows_torchrun.py b/tests/test_windows_torchrun.py index 8cb202499..9c7968735 100644 --- a/tests/test_windows_torchrun.py +++ b/tests/test_windows_torchrun.py @@ -1,6 +1,9 @@ +import os +import sys +import types from types import SimpleNamespace -from ultralytics.utils.torchrun import disable_static_tcpstore_libuv +from ultralytics.utils.torchrun import disable_libuv_rendezvous, disable_static_tcpstore_libuv def test_disable_static_tcpstore_libuv_binds_legacy_backend(): @@ -12,3 +15,34 @@ def tcp_store(*args, **kwargs): _, kwargs = rendezvous.TCPStore("127.0.0.1", 12345) assert kwargs["use_libuv"] is False + + +def test_disable_libuv_rendezvous_patches_env_store(monkeypatch): + calls = [] + + def create_store(*args, **kwargs): + calls.append((args, kwargs)) + return object() + + static = SimpleNamespace(TCPStore=lambda *args, **kwargs: (args, kwargs)) + elastic = types.ModuleType("torch.distributed.elastic.rendezvous") + elastic.static_tcp_rendezvous = static + rendezvous = types.ModuleType("torch.distributed.rendezvous") + rendezvous.TCPStore = lambda *args, **kwargs: (args, kwargs) + rendezvous._create_c10d_store = create_store + for name, module in { + "torch.distributed.elastic.rendezvous": elastic, + "torch.distributed.rendezvous": rendezvous, + }.items(): + monkeypatch.setitem(sys.modules, name, module) + monkeypatch.setenv("USE_LIBUV", "1") + + disable_libuv_rendezvous() + rendezvous._create_c10d_store("127.0.0.1", 12345, 0, 2, None, True) + + assert sys.modules["torch.distributed.rendezvous"] is rendezvous + _, tcp_kwargs = rendezvous.TCPStore("127.0.0.1", 12345) + assert tcp_kwargs["use_libuv"] is False + assert calls[0][0][-1] is False + assert calls[0][1] == {} + assert os.environ["USE_LIBUV"] == "0" diff --git a/ultralytics/data/multitask_sampler.py b/ultralytics/data/multitask_sampler.py index 5b20134d5..bac332acc 100644 --- a/ultralytics/data/multitask_sampler.py +++ b/ultralytics/data/multitask_sampler.py @@ -9,7 +9,7 @@ from torch.utils.data import Dataset, Sampler -class MultiTaskBatchSampler(Sampler[list[tuple[str, int]]]): +class MultiTaskBatchSampler(Sampler): """Schedule task/source samples with a resumable deterministic state.""" schema_version = 1 diff --git a/ultralytics/models/yolo/multitask/train.py b/ultralytics/models/yolo/multitask/train.py index 0a59cde22..ae320d29b 100644 --- a/ultralytics/models/yolo/multitask/train.py +++ b/ultralytics/models/yolo/multitask/train.py @@ -5,6 +5,8 @@ TaskRouter-contextualized features and combined multi-task loss. """ +from __future__ import annotations + from copy import copy from pathlib import Path from typing import Any diff --git a/ultralytics/nn/modules/_numeric.py b/ultralytics/nn/modules/_numeric.py index 9d83eb174..2601fa6fc 100644 --- a/ultralytics/nn/modules/_numeric.py +++ b/ultralytics/nn/modules/_numeric.py @@ -25,8 +25,18 @@ def _autocast_is_available(device_type: str) -> bool: def disabled_autocast(device_type: str): """Disable autocast when supported, otherwise return a no-op context.""" - if _autocast_is_available(device_type): - return torch.autocast(device_type=device_type, enabled=False) + if not _autocast_is_available(device_type): + return nullcontext() + + # ``torch.autocast`` was introduced after the oldest supported PyTorch + # release. Keep the legacy CUDA context available while treating CPU + # autocast as a no-op on those builds. + autocast = getattr(torch, "autocast", None) + if callable(autocast): + return autocast(device_type=device_type, enabled=False) + legacy_autocast = getattr(getattr(torch.cuda, "amp", None), "autocast", None) + if device_type == "cuda" and callable(legacy_autocast): + return legacy_autocast(enabled=False) return nullcontext() diff --git a/ultralytics/nn/modules/mot/block.py b/ultralytics/nn/modules/mot/block.py index 967462f59..53ca55551 100644 --- a/ultralytics/nn/modules/mot/block.py +++ b/ultralytics/nn/modules/mot/block.py @@ -349,7 +349,9 @@ def _blend_experts( route_mask.scatter_(1, route_ids, True) token_mask_sparsity = 1.0 - float(route_mask.float().mean()) experts_per_sample = route_mask.reshape(B, self.NUM_EXPERTS, -1).any(dim=2).sum(dim=1) - batch_expert_union = int(route_mask.any(dim=(0, 2, 3)).sum()) + # Torch 1.8 does not accept a tuple for ``Tensor.any(dim=...)``. + # Flatten batch and spatial axes while retaining one expert axis. + batch_expert_union = int(route_mask.permute(1, 0, 2, 3).reshape(self.NUM_EXPERTS, -1).any(dim=1).sum()) if use_sparse: expert_calls = 0 for e_idx, expert in enumerate(self.experts): diff --git a/ultralytics/nn/modules/multitask/head.py b/ultralytics/nn/modules/multitask/head.py index 422c89497..71df1ecc3 100644 --- a/ultralytics/nn/modules/multitask/head.py +++ b/ultralytics/nn/modules/multitask/head.py @@ -449,6 +449,11 @@ def forward(self, x: list[torch.Tensor]): preds["one2one"]["candidate_indices"] = candidate_indices else: y = self.postprocess(y.permute(0, 2, 1)) + elif self.has_task("segment") or self.has_task("pose"): + # Keep dense-anchor auxiliary outputs aligned when validation + # explicitly disables the one-to-one detection branch. + candidate_indices = torch.arange(y.shape[-1], device=y.device).view(1, -1).expand(y.shape[0], -1) + preds["candidate_indices"] = candidate_indices return y if self.export else (y, preds) def _export_outputs( diff --git a/ultralytics/nn/tasks.py b/ultralytics/nn/tasks.py index 7970d8407..f6b005c57 100644 --- a/ultralytics/nn/tasks.py +++ b/ultralytics/nn/tasks.py @@ -2283,6 +2283,15 @@ def yaml_model_load(path): (dict): Model dictionary. """ path = Path(path) + # Test matrices may pass a cached absolute path for a repository YAML + # (for example ``~/.ultralytics/weights/yolo26-master-mt-n.yaml``). + # Resolve that basename against the source tree before applying the + # scale-unification rule below; YAML configs are repository assets, not + # downloadable model weights. + if path.is_absolute() and not path.exists(): + local_path = check_yaml(path.name, hard=False) + if local_path: + path = Path(local_path) if path.stem in (f"yolov{d}{x}6" for x in "nsmlx" for d in (5, 8)): new_stem = re.sub(r"(\d+)([nslmx])6(.+)?$", r"\1\2-p6\3", path.stem) LOGGER.warning(f"Ultralytics YOLO P6 models now use -p6 suffix. Renaming {path.stem} to {new_stem}.") diff --git a/ultralytics/utils/downloads.py b/ultralytics/utils/downloads.py index ab26f58aa..27b146690 100644 --- a/ultralytics/utils/downloads.py +++ b/ultralytics/utils/downloads.py @@ -496,31 +496,39 @@ def attempt_download_asset( file = Path(file.strip().replace("'", "")) if file.exists(): return str(file) - elif (SETTINGS["weights_dir"] / file).exists(): + if (SETTINGS["weights_dir"] / file).exists(): return str(SETTINGS["weights_dir"] / file) - else: - # URL specified - name = Path(parse.unquote(str(file))).name # decode '%2F' to '/' etc. - download_url = f"https://github.com/{repo}/releases/download" - if str(file).startswith(("http:/", "https:/")): # download - url = str(file).replace(":/", "://") # Pathlib turns :// -> :/ - file = url2file(name) # parse authentication query strings - if Path(file).is_file(): - LOGGER.info(f"Found {clean_url(url)} locally at {file}") # file already exists - else: - safe_download(url=url, file=file, min_bytes=1e5, **kwargs) - - elif repo == GITHUB_ASSETS_REPO and name in GITHUB_ASSETS_NAMES: - safe_download(url=f"{download_url}/{release}/{name}", file=file, min_bytes=1e5, **kwargs) - + if file.suffix.lower() in {".yaml", ".yml"} and "://" not in str(file): + # YAML model definitions live in the repository and are not release + # assets. Resolve a missing absolute/cache path by basename before + # attempting any network lookup. + local_file = checks.check_yaml(file.name, hard=False) + if local_file: + return str(local_file) + + # URL or release asset specified. YAML files that were not found locally + # continue through the existing behavior and return the unresolved path. + name = Path(parse.unquote(str(file))).name # decode '%2F' to '/' etc. + download_url = f"https://github.com/{repo}/releases/download" + if str(file).startswith(("http:/", "https:/")): # download + url = str(file).replace(":/", "://") # Pathlib turns :// -> :/ + file = url2file(name) # parse authentication query strings + if Path(file).is_file(): + LOGGER.info(f"Found {clean_url(url)} locally at {file}") # file already exists else: - tag, assets = get_github_assets(repo, release) - if not assets: - tag, assets = get_github_assets(repo) # latest release - if name in assets: - safe_download(url=f"{download_url}/{tag}/{name}", file=file, min_bytes=1e5, **kwargs) + safe_download(url=url, file=file, min_bytes=1e5, **kwargs) - return str(file) + elif repo == GITHUB_ASSETS_REPO and name in GITHUB_ASSETS_NAMES: + safe_download(url=f"{download_url}/{release}/{name}", file=file, min_bytes=1e5, **kwargs) + + else: + tag, assets = get_github_assets(repo, release) + if not assets: + tag, assets = get_github_assets(repo) # latest release + if name in assets: + safe_download(url=f"{download_url}/{tag}/{name}", file=file, min_bytes=1e5, **kwargs) + + return str(file) def download( diff --git a/ultralytics/utils/torchrun.py b/ultralytics/utils/torchrun.py index 415f0ee6f..42c2c697c 100644 --- a/ultralytics/utils/torchrun.py +++ b/ultralytics/utils/torchrun.py @@ -1,19 +1,59 @@ """Windows-compatible entry point for ``torch.distributed.run``.""" +import os from functools import partial def disable_static_tcpstore_libuv(rendezvous_module) -> None: """Force the legacy TCPStore backend when the Windows torch wheel omits libuv.""" - rendezvous_module.TCPStore = partial(rendezvous_module.TCPStore, use_libuv=False) + tcp_store = rendezvous_module.TCPStore + if getattr(tcp_store, "_ultralytics_libuv_disabled", False): + return + + patched_tcp_store = partial(tcp_store, use_libuv=False) + patched_tcp_store._ultralytics_libuv_disabled = True + rendezvous_module.TCPStore = patched_tcp_store + + +def disable_libuv_rendezvous() -> None: + """Disable libuv in every rendezvous path used by the installed PyTorch version.""" + os.environ["USE_LIBUV"] = "0" + + from torch.distributed.elastic.rendezvous import static_tcp_rendezvous + + disable_static_tcpstore_libuv(static_tcp_rendezvous) + + # ``env://`` resolves through this private helper, which is not affected by + # replacing ``static_tcp_rendezvous.TCPStore`` alone on recent PyTorch builds. + import importlib + + rendezvous = importlib.import_module("torch.distributed.rendezvous") + + # ``_create_c10d_store`` may select the agent-store branch, where the + # module-level TCPStore reference is called without an explicit flag. + # Patch that reference as well as the static rendezvous implementation. + disable_static_tcpstore_libuv(rendezvous) + + create_store = getattr(rendezvous, "_create_c10d_store", None) + if create_store is None or getattr(create_store, "_ultralytics_libuv_disabled", False): + return + + def create_store_without_libuv(*args, **kwargs): + if len(args) >= 6: + args = (*args[:5], False, *args[6:]) + else: + kwargs["use_libuv"] = False + return create_store(*args, **kwargs) + + create_store_without_libuv._ultralytics_libuv_disabled = True + rendezvous._create_c10d_store = create_store_without_libuv def main() -> None: """Patch the upstream static rendezvous backend, then delegate to torchrun.""" - from torch.distributed.elastic.rendezvous import static_tcp_rendezvous from torch.distributed.run import main as torchrun_main - disable_static_tcpstore_libuv(static_tcp_rendezvous) + disable_libuv_rendezvous() torchrun_main()