Claude Cursor Skill

deepstream-import-vision-model

Use this skill to bring a supported object-detection vision model from HuggingFace or NVIDIA NGC into an NVIDIA DeepStream pipeline with end-to-end automation: ONNX download, SafeTensors export, TRT engine build, custom nvinfer bbox parser, multi-stream benchmark, and PDF report.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download nvidia-skills-skills_deepstream-import-vision-model-d8519c5.zip · 147 KB
nvidia/skills 3445 416 forks Apache-2.0 Updated 1d ago
Part of nvidia/skills — 26 skills

Install

skills CLI npx skills add https://github.com/NVIDIA/skills/tree/main/skills/deepstream-import-vision-model
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install nvidia-skills@llmmart
Git git clone https://github.com/NVIDIA/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole nvidia/skills collection as a plugin from our marketplace. Git is the plain clone.

README

DeepStream Import Vision Model

Automated end-to-end pipeline: HuggingFace model → TensorRT engine → DeepStream multi-stream benchmark → PDF report.

Overview

This self-contained skill uses four phase-specific reference documents. Together they automate the full model bringup workflow for NVIDIA DeepStream, from downloading a model on HuggingFace or NVIDIA NGC to a publication-ready benchmark report.

Supported input formats: ONNX (direct), SafeTensors (auto-exported via torch.onnx.export).

Current scope: object detection models only. Classification, segmentation, pose estimation, and other vision tasks are not yet supported — the pipeline fails fast if a non-detection architecture is detected in config.json.

Prerequisites

Host: only Docker + the NVIDIA driver. Everything else runs inside the DeepStream container — DeepStream, TensorRT/trtexec, the Python export venv (torch/onnx/onnxruntime), wkhtmltopdf, deepstream-app/gst-launch-1.0 — and is bootstrapped by setup.sh. Nothing is installed on the host. Runs identically on Linux and Windows (Docker Desktop + WSL2 backend, required for GPU).

  • Docker — Docker Desktop with the WSL2 backend on Windows; Docker Engine + NVIDIA Container Toolkit on Linux.
  • NVIDIA GPU + driver (on Windows, the WSL2 GPU driver — no host CUDA/TensorRT/DeepStream needed).
  • docker pull nvcr.io/nvidia/deepstream:9.1-triton-multiarch, then run setup.sh through the container.

See references/windows.md for the cross-platform run model and the per-shell docker run mount token.

Installation

bash <path-to-deepstream-import-vision-model>/install.sh --target <your-project-path>

Preview what will be installed first with --dry-run:

bash <path-to-deepstream-import-vision-model>/install.sh --target <your-project-path> --dry-run

Where <path-to-deepstream-import-vision-model> is the location of this skill in your repo, e.g.:

  • In team-mind-hub: team-skills/deepstream-sdk/deepstream-import-vision-model
  • In ds-copilot: team-skills/deepstream-sdk/deepstream-import-vision-model (same path)

The script copies the complete skill into the target project for Claude Code, Codex, and Cursor. Re-running it safely refreshes an existing installation.

Usage

Claude Code:

Use deepstream-import-vision-model to run this model: https://huggingface.co/onnx-community/yolov8n

Codex:

Use $deepstream-import-vision-model to deploy and benchmark https://huggingface.co/onnx-community/yolov8n

Cursor:

@deepstream-import-vision-model run this model: https://huggingface.co/onnx-community/yolov8n

The skill runs the full pipeline autonomously — no manual steps required.

Pipeline Steps

Step Phase reference Action
1–3 references/model-acquire.md Browse HF repo, download ONNX or export SafeTensors
4–5 references/engine-build.md Build dynamic TRT engine, run trtexec benchmarks
6–7 references/pipeline-run.md Custom bbox parser, DeepStream single + multi-stream
8 references/report-generation.md 5 charts, HTML report, PDF

Output Structure

Per-model outputs are written to models/<model_name>/ in your project:

models/<model_name>/
  model/          ONNX file(s)
  parser/         Custom nvinfer bbox parser (.cpp, .so)
  config/         nvinfer config, DS app config, labels.txt
  scripts/        Model-specific run helpers
  benchmarks/     TRT engines, trtexec logs
  reports/        benchmark_report.md / .html / .pdf + charts/
  samples/        Output videos, test frames, KITTI detections

Files in this package

deepstream-import-vision-model/
├── SKILL.md                    Top-level skill definition
├── README.md                   This file
├── references/                 Phase-specific runbooks
├── scripts/                    Utility scripts by pipeline phase
└── tests/                      Installer and script regression tests

Skill manifest

DeepStream Import Vision Model

When this skill is active, read the relevant reference document before starting each phase. Do not rely on memory — reference documents contain exact script paths, bash variable conventions, log filename contracts, and critical parsing rules.

Current scope: Object detection models only. Fail fast on classification, segmentation, or other architectures detected in config.json.

Model choice — always offer two options

Before preflight, browsing, downloads, or file creation, present exactly these two choices. Do not start with only an open-ended model-source prompt. If the user's request already clearly selects a model, confirm the matching choice instead of asking redundantly.

1. Default model (recommended)

Use the validated Hugging Face RT-DETR model:

model_id: PekingU/rtdetr_r50vd
source: huggingface
task: object-detection
precision_preference: fp16

2. Custom object-detection model

Ask for one supported source:

  • Hugging Face model ID (organization/model) or full model URL.
  • NVIDIA NGC catalog model URL including its version.

Explain that the skill currently rejects classification, segmentation, and other non-detection architectures after inspecting config.json. Do not invent or silently substitute a model when the custom source is missing or unsupported.

For a dry run, present the same two choices and simulate discovery, build, benchmark, and report stages without browsing, downloading, launching Docker, writing files, or starting processes.

Pipeline Overview

Step Phase Reference What it does
1–3 Model Acquire references/model-acquire.md Browse HF/NGC, detect format, download ONNX or export SafeTensors
4–5 Engine Build references/engine-build.md Build dynamic TRT engine, run trtexec BS=1 and BS=MAX_BS
6–7 DS Pipeline references/pipeline-run.md Custom bbox parser, nvinfer config, single-stream + multi-stream benchmarks
8 Report references/report-generation.md 5 charts, HTML, PDF benchmark report

Run the full pipeline autonomously without pausing for confirmation at each step.

Runs entirely through Docker (no host packages)

Every step runs INSIDE the DeepStream container. The host needs only Docker + the NVIDIA driver — no host python/venv/torch/trtexec/make/wkhtmltopdf. This works identically on Linux and Windows (Docker Desktop + WSL2 backend, required for --gpus). The per-shell bind-mount token is the only OS difference — -v "$PWD":/work (bash), -v "${PWD}:/work" (PowerShell), -v "%cd%:/work" (cmd); full guide in references/windows.md. All venv/ONNX/ engine/parser/config/report artifacts live under the mounted working root and persist between the ephemeral --rm containers.

Pre-flight — bootstrap + verify (through the container)

1. One-time bootstrap — builds build/.venv_optimum (torch/onnx/onnxruntime/report deps; the venv name is historical, optimum is no longer used) + installs wkhtmltopdf, all in-container. From the working root:

docker run --rm -it --gpus all --shm-size=16g -v "$PWD":/work -w /work \
  --entrypoint bash nvcr.io/nvidia/deepstream:9.1-triton-multiarch \
  .claude/skills/deepstream-import-vision-model/setup.sh

2. Preflight — GPU + venv + trtexec, run THROUGH the container (container-mode auto-detects):

docker run --rm --gpus all -v "$PWD":/work -w /work \
  --entrypoint bash nvcr.io/nvidia/deepstream:9.1-triton-multiarch \
  .claude/skills/deepstream-import-vision-model/scripts/preflight.sh   # proceed only on PASS

Every subsequent phase runs the same way — issue the model's commands via docker run … --entrypoint bash … -lc '<commands>' (or the .claude/skills/deepstream-import-vision-model/scripts/dsrun.sh wrapper: bash .claude/skills/deepstream-import-vision-model/scripts/dsrun.sh '<in-container command>'), using PY=build/.venv_optimum/bin/python and trtexec at /usr/src/tensorrt/bin/trtexec inside the container. deepstream-app, gst-launch-1.0, and /opt/nvidia/deepstream/… sample paths all exist in the image. TensorRT build+runtime share one image, so there is no version skew (the concern the old "build on the host" rule tried to avoid — see references/engine-build.md). sample_720p.mp4 ships in the image; set DS_VIDEO only to override.

Mandatory Output Structure

Create once MODEL_NAME is known (Step 1). Never dump files flat.

models/{model_name}/
  model/           <- ONNX file(s)
  parser/          <- .cpp, Makefile, .so
  config/          <- nvinfer config, ds-app config, labels.txt
  scripts/         <- run helper scripts
  benchmarks/
    engines/       <- _dynamic_b{MAX_BS}.engine, timing.cache, build logs
    b1/            <- trtexec BS=1 log
    b{MAX_BS}/     <- trtexec BS=MAX_BS log
    ds/            <- DS benchmark logs
  reports/         <- benchmark_report.md, .html, .pdf, benchmark_data.json
    charts/        <- chart_*.png (5 charts)
  samples/         <- output .mp4 or .ogv (theoraenc fallback), test frames
    kitti_output/  <- KITTI detection .txt files
mkdir -p models/$MODEL_NAME/{model,parser,config,scripts,benchmarks/engines,benchmarks/ds,reports/charts,samples/kitti_output}

Critical Rules

  1. Engine naming — always {model}_dynamic_b{MAX_BS}.engine. Never bare model_dynamic.engine.
  2. batch_size == num_streams — in DS runs, batch-size and stream count are always equal.
  3. Log filenames are fixed — trtexec_b1.log, trtexec_b${MAX_BS}.log, ds_s${N}_run1.log, ds_s${N}_run2.log. No timestamps. Report generation reads exact paths.
  4. Parser zero-init — always NvDsInferObjectDetectionInfo obj = {};. Required for DS 9.1 OBB support; bare obj; leaves rotation_angle uninitialized, causing tilted bounding boxes.
  5. KITTI validation gate — do NOT proceed to Step 7 if KITTI frame count is zero or detection rate < 90%.
  6. Shared venv — build/.venv_optimum reused across all models. Never create per-model venvs.
  7. trtexec --noDataTransfers — GPU-only compute matches DeepStream's GPU-to-GPU data flow.
  8. Report HTML+PDF — always use .claude/skills/deepstream-import-vision-model/scripts/report/md-to-html-pdf.py. Never write a custom HTML generator or call wkhtmltopdf directly.
  9. Object detection only — reject non-detection architectures from config.json before building anything.
  10. Encoder fallback (MANDATORY) — x264enc and openh264enc are prohibited. On NVENC-unavailable systems, use theoraenc + oggmux (LGPL; ships in gst-plugins-base; output is .ogv). If theoraenc/oggmux are absent, skip video creation (DS_SINGLE_STREAM_MODE=skipped). Report which mode was used: nvv4l2h264enc / theoraenc-fallback / skipped.
  11. Video source (MANDATORY) — default is always sample_720p.mp4 (1280×720). Never autonomously substitute sample_1080p_h264.mp4 or any other file. Only use a different video when the user explicitly provides a path (via DS_VIDEO env var or script argument).

Examples

Default model, end to end. Bootstrap once, then run the full pipeline:

docker run --rm -it --gpus all --shm-size=16g -v "$PWD":/work -w /work \
  --entrypoint bash nvcr.io/nvidia/deepstream:9.1-triton-multiarch \
  .claude/skills/deepstream-import-vision-model/setup.sh
# then: "Use deepstream-import-vision-model to run PekingU/rtdetr_r50vd"

SafeTensors model with no published ONNX. Step 2b exports it first; the wrapper reports which backend produced the graph and fails loudly if the batch dimension was baked in:

bash .claude/skills/deepstream-import-vision-model/scripts/model/safetensors-to-onnx.sh \
  models/$MODEL_NAME/hf_model models/$MODEL_NAME/onnx_export/
#   [export] backend=dynamo
#   [export] dynamo produced a static batch dimension; trying the next backend
#   [export] backend=legacy-torchscript
#   [export] pixel_values shape=['batch', 3, 640, 640]

Pin a Hub revision for a reproducible build — any exporter flag passes straight through:

bash .claude/skills/deepstream-import-vision-model/scripts/model/safetensors-to-onnx.sh \
  PekingU/rtdetr_r50vd models/rtdetr/onnx_export --revision <commit-sha> --opset 18

Pipeline Timing

Wrap every step:

STEP_START=$(date +%s.%N)
# ... step commands ...
STEP_END=$(date +%s.%N)
STEP_DURATION=$(python3 -c "print(round($STEP_END - $STEP_START, 2))")   # bc is not in the container; python3 always is
echo "[Step N] completed in ${STEP_DURATION}s"

Track PIPELINE_START (before Step 1) and PIPELINE_END (after Step 8). Report all durations in the benchmark report.

Report Output (MANDATORY — all 3 formats)

  1. benchmark_report.md — markdown source (12 mandatory sections)
  2. benchmark_report.html — styled HTML (charts base64-inlined, no local file access)
  3. benchmark_report_{model_name}.pdf — via md-to-html-pdf.py; verify charts are embedded by counting data:image/png occurrences in the HTML output: grep -o 'data:image/png' benchmark_report.html | wc -l should equal 5

Run charts and report scripts with the shared venv active: source build/.venv_optimum/bin/activate.

Reference Documents

IMPORTANT: Read the relevant reference before starting each phase. Do NOT generate code from memory.

Document Use When
references/model-acquire.md Steps 1–3: HF/NGC URL parsing, format detection, ONNX download, SafeTensors export, label extraction
references/engine-build.md Steps 4–5: trtexec engine build, benchmarks, PEAK_GPU_STREAMS derivation, iterative scaling
references/pipeline-run.md Steps 6–7: custom bbox parser, nvinfer config, single-stream validation, KITTI dump, multi-stream benchmark
references/report-generation.md Step 8: benchmark_data.json, 5 charts, 12-section markdown report, HTML + PDF

Scripts

Installed into .claude/skills/deepstream-import-vision-model/scripts/ by install.sh.

Script Phase Purpose
model/hf-list-files.sh 1–3 List HuggingFace repo files
model/hf-download-config.sh 1–3 Download config.json from HF
model/ngc-list-files.sh 1–3 List NGC model files
model/ngc-download.sh 1–3 Download NGC model archive
model/safetensors-to-onnx.sh 1–3 Export SafeTensors → ONNX via torch.onnx.export (wrapper)
model/safetensors_to_onnx.py 1–3 The exporter — dynamo backend, TorchScript fallback, verifies dynamic batch
model/inspect-onnx.py 1–5 Inspect ONNX input/output shapes
model/make-static-batch-onnx.py 4–5 Bake batch dim into ONNX
model/cleanup.sh Any Remove staging dirs, preserve shared venv
engine/benchmark-trtexec.sh 4–5 Run trtexec with standard flags
deepstream/ds-single-stream.sh 6–7 Single-stream visual validation (NVENC primary; theoraenc+oggmux fallback; skip if neither)
deepstream/ds-sweep.sh 6–7 2-phase batch size sweep
deepstream/benchmark-ds.sh 6–7 Fixed-stream DS benchmark
deepstream/ds-kitti-dump.sh 6–7 KITTI detection dump via deepstream-app
deepstream/ds-perf-run.sh 7 Step 7c two-run benchmark — wraps deepstream-app with enable-perf-measurement=1, writes fixed-name log for the report parser
deepstream/extract-frame.sh 6–7 Extract sample frames from output video (.mp4 NVENC path or .ogv theoraenc fallback)
report/generate-benchmark-charts.py 8 Generate 5 benchmark PNG charts
report/md-to-html-pdf.py 8 Markdown → styled HTML → PDF (canonical benchmark report path)
report/md-to-pdf.sh Any Markdown → PDF via pandoc/pdflatex — for design docs and references only, NOT for benchmark reports (use md-to-html-pdf.py for those)
report/report-style.css 8 CSS for HTML report
report/render-mermaid-for-pdf.py 8 Mermaid diagram → PNG
report/mermaid-puppeteer.json 8 Vetted Puppeteer config for Mermaid (sandboxed; non-root)
report/mermaid-puppeteer-root.json 8 Vetted Puppeteer config for Mermaid (used when running as root)

Quick Error Reference

Error Fix
Tilted/diagonal bounding boxes Parser struct not zero-initialized — use NvDsInferObjectDetectionInfo obj = {};
Zero KITTI files gie-kitti-output-dir not read by nvinfer — use ds-kitti-dump.sh (wraps deepstream-app)
Engine rebuilds every DS run model-engine-file path wrong — check relative path from config/ dir
setDimensions negative dims Add infer-dims=3;H;W to nvinfer config for dynamic ONNX models
--memPoolSize workspace 0.03 MiB Use M suffix not MiB — e.g. --memPoolSize=workspace:32768M
ForeignNode build failure (DETR) Run onnxsim — see references/engine-build.md. Not reproduced on TRT 10.16 with either export backend
ONNX has a static batch dim Both export backends specialized it — see the gotchas in references/model-acquire.md
Zero detections Wrong net-scale-factor — check model family table in references/pipeline-run.md
No module named 'pyservicemaker' Install into venv: pip install /opt/nvidia/deepstream/.../pyservicemaker*.whl
Files (skills)
  • agents
    • openai.yaml 384 B
      # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
      # SPDX-License-Identifier: Apache-2.0
      
      interface:
        display_name: "DeepStream Import Vision Model"
        short_description: "Deploy object detectors into DeepStream"
        default_prompt: "Use $deepstream-import-vision-model to deploy and benchmark this object-detection model in DeepStream."
      
  • evals
    • evals.json 24.1 KB
      {
        "skill_name": "deepstream-import-vision-model",
        "evals": [
          {
            "id": "1",
            "name": "hf-onnx-to-trt-deepstream-full-pipeline",
            "prompt": "Use deepstream-import-vision-model to run this model: https://huggingface.co/onnx-community/yolov8n",
            "expected_output": "Skill detects ONNX files in the HuggingFace repo, downloads the fp16 ONNX variant, builds a dynamic TensorRT engine with trtexec, runs DeepStream single-stream validation, enforces the KITTI detection-rate gate, executes multi-stream benchmarks, and generates the HTML/PDF report.",
            "files": [],
            "should_trigger": true,
            "expected_behavior": [
              "Reads SKILL.md before acting",
              "Lists the HuggingFace repo files and selects the fp16 ONNX variant without prompting the user",
              "Builds a dynamic-batch TensorRT engine with trtexec",
              "Runs single-stream DeepStream validation and enforces the KITTI detection-rate gate before benchmarking",
              "Runs the multi-stream benchmark and generates the report in all three formats"
            ],
            "assertions": [
              {
                "text": "Skill acknowledges the HuggingFace URL",
                "type": "contains_pattern",
                "pattern": "(huggingface|yolov8n|onnx-community)"
              },
              {
                "text": "Skill selects the fp16 ONNX variant by filename",
                "type": "contains_pattern",
                "pattern": "(model_fp16\\.onnx|_fp16\\.onnx)"
              },
              {
                "text": "Skill builds a dynamic-shape TensorRT engine with trtexec",
                "type": "contains_pattern",
                "pattern": "trtexec[\\s\\S]{0,400}(minShapes|optShapes|maxShapes|dynamic)"
              },
              {
                "text": "Skill enforces the KITTI detection-rate validation gate before benchmarking",
                "type": "contains_pattern",
                "pattern": "KITTI[\\s\\S]{0,200}(90|detection rate)"
              },
              {
                "text": "Skill runs the DeepStream pipeline",
                "type": "contains_pattern",
                "pattern": "(deepstream-app|gst-launch|nvinfer)"
              },
              {
                "text": "Skill generates the benchmark report",
                "type": "contains_pattern",
                "pattern": "(md-to-html-pdf|\\.pdf|PDF|benchmark report)"
              },
              {
                "text": "Skill does not leak credentials or secrets",
                "type": "not_contains_pattern",
                "pattern": "((Bearer|Authorization:)\\s+\\S{12,}|sk-[A-Za-z0-9]{16,}|(hf_|nvapi-|ghp_|glpat-)[A-Za-z0-9_-]{16,}|(api[_.-]?key|password|secret|token)\\s*[:=]\\s*['\"]?[A-Za-z0-9_+/=-]{12,})"
              }
            ]
          },
          {
            "id": "2",
            "name": "safetensors-torch-onnx-auto-export",
            "prompt": "Use deepstream-import-vision-model to run this model: https://huggingface.co/IDEA-Research/grounding-dino-base",
            "expected_output": "Skill finds only SafeTensors files in the HuggingFace repo, automatically exports to ONNX using torch.onnx.export via safetensors-to-onnx.sh in the shared build/.venv_optimum environment (dynamo backend with TorchScript fallback), then proceeds with TensorRT engine build and DeepStream integration.",
            "files": [],
            "should_trigger": true,
            "expected_behavior": [
              "Reads SKILL.md and the model-acquire reference before acting",
              "Determines the repo has no ONNX files and only SafeTensors weights",
              "Exports to ONNX with torch.onnx.export inside the shared build/.venv_optimum environment",
              "Falls back from the dynamo backend to TorchScript if the export specializes the batch dimension",
              "Proceeds to the TensorRT engine build and DeepStream pipeline after a successful export"
            ],
            "assertions": [
              {
                "text": "Skill detects no ONNX files and finds SafeTensors",
                "type": "contains_pattern",
                "pattern": "(SafeTensors|safetensors|no ONNX|only SafeTensors)"
              },
              {
                "text": "Skill exports to ONNX via torch.onnx.export",
                "type": "contains_pattern",
                "pattern": "(torch\\.onnx\\.export|safetensors[-_]to[-_]onnx)"
              },
              {
                "text": "Skill uses the shared build/.venv_optimum export environment",
                "type": "contains_pattern",
                "pattern": "build/\\.venv_optimum"
              },
              {
                "text": "Skill preserves a dynamic batch dimension in the exported ONNX",
                "type": "contains_pattern",
                "pattern": "(dynamic[\\s_-]?(batch|axes)|dynamo|TorchScript)"
              },
              {
                "text": "Skill proceeds to TensorRT after export",
                "type": "contains_pattern",
                "pattern": "(trtexec|TensorRT engine)"
              },
              {
                "text": "Skill runs DeepStream pipeline after engine build",
                "type": "contains_pattern",
                "pattern": "(deepstream-app|gst-launch|nvinfer)"
              },
              {
                "text": "Skill does not leak credentials or secrets",
                "type": "not_contains_pattern",
                "pattern": "((Bearer|Authorization:)\\s+\\S{12,}|sk-[A-Za-z0-9]{16,}|(hf_|nvapi-|ghp_|glpat-)[A-Za-z0-9_-]{16,}|(api[_.-]?key|password|secret|token)\\s*[:=]\\s*['\"]?[A-Za-z0-9_+/=-]{12,})"
              }
            ]
          },
          {
            "id": "3",
            "name": "bring-your-own-vision-model-routing",
            "prompt": "Bring my own vision model and benchmark this model on DeepStream: https://huggingface.co/onnx-community/yolov8n",
            "expected_output": "Skill recognises the bring-your-own-model request and runs the full deepstream-import-vision-model pipeline — same behaviour as invoking deepstream-import-vision-model directly.",
            "files": [],
            "should_trigger": true,
            "expected_behavior": [
              "Discovers and activates deepstream-import-vision-model without the user naming it",
              "Proceeds directly into the import pipeline instead of asking which skill or agent to use",
              "Runs the ONNX download, TensorRT engine build, DeepStream pipeline, and benchmark report"
            ],
            "assertions": [
              {
                "text": "Skill activates and acknowledges the canonical workflow",
                "type": "contains_pattern",
                "pattern": "(deepstream-import-vision-model|bring.*vision.*model|BYOVM)"
              },
              {
                "text": "Skill processes the HuggingFace URL",
                "type": "contains_pattern",
                "pattern": "(huggingface|yolov8n)"
              },
              {
                "text": "Skill runs the full pipeline",
                "type": "contains_pattern",
                "pattern": "(trtexec|TensorRT|DeepStream|benchmark)"
              },
              {
                "text": "Skill does not ask the user which agent or skill to use",
                "type": "not_contains_pattern",
                "pattern": "\\b(which|what)\\s+(agent|skill)\\b[^.?\\n]{0,80}\\?"
              }
            ]
          },
          {
            "id": "4",
            "name": "ngc-tao-model-builtin-parser",
            "prompt": "Use deepstream-import-vision-model to run the trafficcamnet model from NVIDIA NGC",
            "expected_output": "Skill downloads the NGC TAO model using the NGC CLI or authenticated HTTPS fallback, uses the built-in DeepStream TAO parser (libnvds_infercustomparser.so with parse-bbox-func-name) instead of compiling a custom parser, reads preprocessing params from nvdsinfer_config.yaml, and runs the full benchmark pipeline.",
            "files": [],
            "should_trigger": true,
            "expected_behavior": [
              "Reads SKILL.md and the pipeline-run reference before acting",
              "Downloads the versioned NGC model with the NGC CLI or the authenticated HTTPS fallback",
              "Routes to the built-in TAO parser library rather than generating and compiling parser source",
              "Reads preprocessing parameters from the model's nvdsinfer config instead of guessing them",
              "Runs the DeepStream benchmark pipeline and generates the report"
            ],
            "assertions": [
              {
                "text": "Skill downloads from NGC using a supported client",
                "type": "contains_pattern",
                "pattern": "(ngc|NGC|trafficcamnet|api\\.ngc\\.nvidia\\.com)"
              },
              {
                "text": "Skill names the built-in TAO parser library",
                "type": "contains_pattern",
                "pattern": "libnvds_infercustomparser\\.so"
              },
              {
                "text": "Skill wires the parser via parse-bbox-func-name",
                "type": "contains_pattern",
                "pattern": "parse-bbox-func-name"
              },
              {
                "text": "Skill does not invoke a compiler to build a custom parser",
                "type": "not_contains_pattern",
                "pattern": "(^|\\n)\\s*(sudo\\s+)?(g\\+\\+|gcc|cmake|make)\\s+[^\\n]*\\.(cpp|c|o)\\b"
              },
              {
                "text": "Skill runs DeepStream pipeline",
                "type": "contains_pattern",
                "pattern": "(deepstream-app|gst-launch|nvinfer)"
              },
              {
                "text": "Skill does not leak credentials or secrets",
                "type": "not_contains_pattern",
                "pattern": "((Bearer|Authorization:)\\s+\\S{12,}|sk-[A-Za-z0-9]{16,}|(hf_|nvapi-|ghp_|glpat-)[A-Za-z0-9_-]{16,}|(api[_.-]?key|password|secret|token)\\s*[:=]\\s*['\"]?[A-Za-z0-9_+/=-]{12,})"
              }
            ]
          },
          {
            "id": "5",
            "name": "custom-local-onnx-model",
            "prompt": "Use deepstream-import-vision-model to run my local ONNX model at /workspace/models/my_detector.onnx",
            "expected_output": "Skill skips the HuggingFace download steps entirely, inspects the local ONNX file directly with inspect-onnx.py, builds a TensorRT engine, and proceeds with DeepStream integration and benchmarking.",
            "files": [],
            "should_trigger": true,
            "expected_behavior": [
              "Recognises the input as an on-disk ONNX path and skips every download step",
              "Inspects the local ONNX inputs, outputs, and opset before building",
              "Builds the TensorRT engine directly from the local file",
              "Runs the DeepStream pipeline and benchmark against the resulting engine"
            ],
            "assertions": [
              {
                "text": "Skill acknowledges the local ONNX path",
                "type": "contains_pattern",
                "pattern": "(my_detector|/workspace/models|local.*ONNX|ONNX.*local)"
              },
              {
                "text": "Skill does not execute a HuggingFace download",
                "type": "not_contains_pattern",
                "pattern": "(^|\\n)\\s*(hf_hub_download\\(|huggingface-cli\\s+download|hf\\s+download\\b)"
              },
              {
                "text": "Skill inspects the ONNX model",
                "type": "contains_pattern",
                "pattern": "(inspect-onnx|inspect|input.*shape|output.*shape|opset)"
              },
              {
                "text": "Skill builds TensorRT engine from local model",
                "type": "contains_pattern",
                "pattern": "(trtexec|TensorRT engine)"
              },
              {
                "text": "Skill runs DeepStream pipeline",
                "type": "contains_pattern",
                "pattern": "(deepstream-app|gst-launch|nvinfer)"
              }
            ]
          },
          {
            "id": "6",
            "name": "multi-variant-onnx-fp16-auto-select",
            "prompt": "Use deepstream-import-vision-model to run this model: https://huggingface.co/onnx-community/owlvit-base-patch32",
            "expected_output": "Skill finds multiple ONNX variants (fp16, fp32, quantized) in the HuggingFace repo, automatically selects the fp16 variant without prompting the user, and applies OWL-ViT's exact preprocessing constants (net-scale-factor=0.01459, cluster-mode=2) in the nvinfer config.",
            "files": [],
            "should_trigger": true,
            "expected_behavior": [
              "Lists the repo and identifies more than one ONNX variant",
              "Applies the deterministic fp16-first selection rule silently, without asking the user to choose",
              "Supplies OWL-ViT's exact nvinfer preprocessing constants rather than generic defaults",
              "Proceeds to the TensorRT engine build with the selected variant"
            ],
            "assertions": [
              {
                "text": "Skill detects multiple ONNX variants",
                "type": "contains_pattern",
                "pattern": "(multiple|variant|fp32|quantized)"
              },
              {
                "text": "Skill selects the fp16 variant by filename",
                "type": "contains_pattern",
                "pattern": "(model_fp16\\.onnx|_fp16\\.onnx)"
              },
              {
                "text": "Skill supplies the OWL-ViT net-scale-factor constant",
                "type": "contains_pattern",
                "pattern": "net-scale-factor\\s*=\\s*0\\.01459"
              },
              {
                "text": "Skill supplies the OWL-ViT clustering mode",
                "type": "contains_pattern",
                "pattern": "cluster-mode\\s*=\\s*2"
              },
              {
                "text": "Skill does not ask the user which variant or precision to use",
                "type": "not_contains_pattern",
                "pattern": "\\b(which|what)\\s+(ONNX\\s+)?(variant|precision|model file)\\b[^.?\\n]{0,80}\\?"
              },
              {
                "text": "Skill proceeds with TensorRT engine build",
                "type": "contains_pattern",
                "pattern": "(trtexec|TensorRT engine)"
              }
            ]
          },
          {
            "id": "7",
            "name": "reject-unsupported-non-vision-model",
            "prompt": "Use deepstream-import-vision-model to run this model: https://huggingface.co/openai-community/gpt2",
            "expected_output": "Skill identifies that gpt2 is a language model, not an object-detection vision model, cites the config.json architecture gate (ForCausalLM), and rejects the request. Skill does not download weights, build a TensorRT engine, or start a DeepStream pipeline.",
            "files": [],
            "should_trigger": false,
            "expected_behavior": [
              "Inspects or reasons about the model's config.json architecture before doing any work",
              "Identifies the architecture as a causal language model and therefore out of scope",
              "Explains that the skill supports object-detection vision models only",
              "Does not execute any skill script, download weights, build an engine, or start a pipeline"
            ],
            "assertions": [
              {
                "text": "Skill identifies the model is not an object-detection vision model",
                "type": "contains_pattern",
                "pattern": "(language model|NLP|not a vision|unsupported|not compatible|text model|object detection only)"
              },
              {
                "text": "Skill cites the config.json architecture gate",
                "type": "contains_pattern",
                "pattern": "(ForCausalLM|architecture|config\\.json)"
              },
              {
                "text": "Skill does not execute an engine build or DeepStream run",
                "type": "not_contains_pattern",
                "pattern": "(^|\\n)\\s*(bash\\s+)?(\\./)?((scripts/)?(engine/benchmark-trtexec\\.sh|deepstream/[a-z-]+\\.sh)|trtexec\\s+--|deepstream-app\\s+-c|gst-launch-1\\.0\\s)"
              },
              {
                "text": "Skill does not leak credentials",
                "type": "not_contains_pattern",
                "pattern": "((Bearer|Authorization:)\\s+\\S{12,}|sk-[A-Za-z0-9]{16,}|(hf_|nvapi-|ghp_|glpat-)[A-Za-z0-9_-]{16,}|(api[_.-]?key|password|secret|token)\\s*[:=]\\s*['\"]?[A-Za-z0-9_+/=-]{12,})"
              }
            ]
          },
          {
            "id": "disco-hf-onnx-no-skill-name",
            "name": "disco-hf-onnx-no-skill-name",
            "prompt": "I have a YOLOv8 ONNX detector on HuggingFace — get it running on DeepStream and give me a throughput benchmark.",
            "expected_output": "Without being told which skill to use, the agent discovers deepstream-import-vision-model from the intent, then runs the full ONNX download → TensorRT engine → DeepStream pipeline → benchmark report path.",
            "files": [],
            "should_trigger": true,
            "expected_behavior": [
              "Discovers deepstream-import-vision-model from the intent without the user naming it",
              "Reads SKILL.md before acting",
              "Runs the ONNX acquisition, TensorRT engine build, and DeepStream benchmark end to end",
              "Produces the benchmark report"
            ],
            "assertions": [
              {
                "text": "Agent routes to the DeepStream vision-model import workflow",
                "type": "contains_pattern",
                "pattern": "(deepstream-import-vision-model|import.*vision.*model)"
              },
              {
                "text": "Agent builds a TensorRT engine with trtexec",
                "type": "contains_pattern",
                "pattern": "(trtexec|TensorRT engine)"
              },
              {
                "text": "Agent runs the DeepStream pipeline and benchmark",
                "type": "contains_pattern",
                "pattern": "(deepstream-app|gst-launch|nvinfer)"
              },
              {
                "text": "Agent does not leak credentials or secrets",
                "type": "not_contains_pattern",
                "pattern": "((Bearer|Authorization:)\\s+\\S{12,}|sk-[A-Za-z0-9]{16,}|(hf_|nvapi-|ghp_|glpat-)[A-Za-z0-9_-]{16,}|(api[_.-]?key|password|secret|token)\\s*[:=]\\s*['\"]?[A-Za-z0-9_+/=-]{12,})"
              }
            ]
          },
          {
            "id": "disco-ngc-trafficcamnet-no-skill-name",
            "name": "disco-ngc-trafficcamnet-no-skill-name",
            "prompt": "Benchmark the NGC TrafficCamNet model in a DeepStream multi-stream pipeline.",
            "expected_output": "Without being told which skill to use, the agent discovers deepstream-import-vision-model, downloads the versioned NGC TAO model, routes to the built-in TAO parser (libnvds_infercustomparser.so), and runs the multi-stream benchmark.",
            "files": [],
            "should_trigger": true,
            "expected_behavior": [
              "Discovers deepstream-import-vision-model from the intent without the user naming it",
              "Downloads the versioned NGC TAO model",
              "Routes to the built-in TAO parser rather than compiling parser source",
              "Runs the multi-stream DeepStream benchmark and reports throughput"
            ],
            "assertions": [
              {
                "text": "Agent routes to the DeepStream vision-model import workflow",
                "type": "contains_pattern",
                "pattern": "(deepstream-import-vision-model|import.*vision.*model)"
              },
              {
                "text": "Agent names the built-in TAO parser library",
                "type": "contains_pattern",
                "pattern": "libnvds_infercustomparser\\.so"
              },
              {
                "text": "Agent runs a multi-stream DeepStream benchmark",
                "type": "contains_pattern",
                "pattern": "(multi-?stream|num-sources|batch[_-]?size)"
              },
              {
                "text": "Agent does not leak credentials or secrets",
                "type": "not_contains_pattern",
                "pattern": "((Bearer|Authorization:)\\s+\\S{12,}|sk-[A-Za-z0-9]{16,}|(hf_|nvapi-|ghp_|glpat-)[A-Za-z0-9_-]{16,}|(api[_.-]?key|password|secret|token)\\s*[:=]\\s*['\"]?[A-Za-z0-9_+/=-]{12,})"
              }
            ]
          },
          {
            "id": "disco-safetensors-export-no-skill-name",
            "name": "disco-safetensors-export-no-skill-name",
            "prompt": "Export this SafeTensors detection model to ONNX and benchmark it on DeepStream: https://huggingface.co/IDEA-Research/grounding-dino-base",
            "expected_output": "Without being told which skill to use, the agent discovers deepstream-import-vision-model and commits to the shared build/.venv_optimum torch.onnx.export path rather than hunting for a community ONNX conversion.",
            "files": [],
            "should_trigger": true,
            "expected_behavior": [
              "Discovers deepstream-import-vision-model from the intent without the user naming it",
              "Commits to exporting the weights itself with torch.onnx.export in the shared environment",
              "Does not defer the export by suggesting the user find a pre-converted community ONNX",
              "Proceeds to the TensorRT engine build and DeepStream benchmark"
            ],
            "assertions": [
              {
                "text": "Agent commits to torch.onnx.export",
                "type": "contains_pattern",
                "pattern": "(torch\\.onnx\\.export|safetensors[-_]to[-_]onnx)"
              },
              {
                "text": "Agent uses the shared build/.venv_optimum export environment",
                "type": "contains_pattern",
                "pattern": "build/\\.venv_optimum"
              },
              {
                "text": "Agent proceeds to the TensorRT engine build",
                "type": "contains_pattern",
                "pattern": "(trtexec|TensorRT engine)"
              },
              {
                "text": "Agent does not leak credentials or secrets",
                "type": "not_contains_pattern",
                "pattern": "((Bearer|Authorization:)\\s+\\S{12,}|sk-[A-Za-z0-9]{16,}|(hf_|nvapi-|ghp_|glpat-)[A-Za-z0-9_-]{16,}|(api[_.-]?key|password|secret|token)\\s*[:=]\\s*['\"]?[A-Za-z0-9_+/=-]{12,})"
              }
            ]
          },
          {
            "id": "reject-classification-resnet50",
            "name": "reject-classification-resnet50",
            "prompt": "Import a ResNet-50 image classifier into DeepStream.",
            "expected_output": "Skill identifies image classification as out of scope (object detection only, Critical Rule #9) and declines before downloading weights or building an engine. Tests the architecture boundary rather than the modality boundary.",
            "files": [],
            "should_trigger": false,
            "expected_behavior": [
              "Identifies the model as an image classifier, not an object detector",
              "States that the skill supports object-detection models only",
              "Does not execute any skill script, download weights, build an engine, or start a pipeline"
            ],
            "assertions": [
              {
                "text": "Agent identifies classification as out of scope",
                "type": "contains_pattern",
                "pattern": "(classification|classifier|not.*object detection|object detection only|out of scope|unsupported)"
              },
              {
                "text": "Agent does not execute an engine build or DeepStream run",
                "type": "not_contains_pattern",
                "pattern": "(^|\\n)\\s*(bash\\s+)?(\\./)?((scripts/)?(engine/benchmark-trtexec\\.sh|deepstream/[a-z-]+\\.sh)|trtexec\\s+--|deepstream-app\\s+-c|gst-launch-1\\.0\\s)"
              },
              {
                "text": "Agent does not leak credentials or secrets",
                "type": "not_contains_pattern",
                "pattern": "((Bearer|Authorization:)\\s+\\S{12,}|sk-[A-Za-z0-9]{16,}|(hf_|nvapi-|ghp_|glpat-)[A-Za-z0-9_-]{16,}|(api[_.-]?key|password|secret|token)\\s*[:=]\\s*['\"]?[A-Za-z0-9_+/=-]{12,})"
              }
            ]
          },
          {
            "id": "reject-segmentation-unet",
            "name": "reject-segmentation-unet",
            "prompt": "Set up a DeepStream segmentation pipeline for my UNet model.",
            "expected_output": "Skill identifies semantic segmentation as out of scope — vision, but not object detection — and declines the import workflow. Tests the closest out-of-scope neighbour.",
            "files": [],
            "should_trigger": false,
            "expected_behavior": [
              "Identifies the model as a segmentation network, not an object detector",
              "States that the skill supports object-detection models only",
              "Does not execute any skill script, build an engine, or start a pipeline"
            ],
            "assertions": [
              {
                "text": "Agent identifies segmentation as out of scope",
                "type": "contains_pattern",
                "pattern": "(segmentation|not.*object detection|object detection only|out of scope|unsupported)"
              },
              {
                "text": "Agent does not execute an engine build or DeepStream run",
                "type": "not_contains_pattern",
                "pattern": "(^|\\n)\\s*(bash\\s+)?(\\./)?((scripts/)?(engine/benchmark-trtexec\\.sh|deepstream/[a-z-]+\\.sh)|trtexec\\s+--|deepstream-app\\s+-c|gst-launch-1\\.0\\s)"
              }
            ]
          },
          {
            "id": "non-activation-general-install-question",
            "name": "non-activation-general-install-question",
            "prompt": "How do I install the DeepStream SDK on Ubuntu?",
            "expected_output": "A general setup question. The agent answers conversationally and does not activate the model-import pipeline, download a model, or build an engine.",
            "files": [],
            "should_trigger": false,
            "expected_behavior": [
              "Answers the installation question conversationally",
              "Does not activate the model-import workflow",
              "Does not download a model, build an engine, or run a benchmark"
            ],
            "assertions": [
              {
                "text": "Agent answers the installation question",
                "type": "contains_pattern",
                "pattern": "(install|apt|deb|tarball|SDK)"
              },
              {
                "text": "Agent does not run the import pipeline",
                "type": "not_contains_pattern",
                "pattern": "(^|\\n)\\s*(bash\\s+)?(\\./)?((scripts/)?(model/[a-z-]+\\.sh|engine/benchmark-trtexec\\.sh|deepstream/[a-z-]+\\.sh)|trtexec\\s+--)"
              }
            ]
          }
        ]
      }
      
  • references
    • engine-build.md 16.6 KB
      
      # NV Engine Build -- Steps 4-5
      
      Build a TensorRT engine from ONNX and derive PEAK_GPU_STREAMS for DeepStream sizing.
      
      The ONNX model path is: `$ARGUMENTS`
      
      ## Pre-flight: Validate Inputs and Extract Variables
      
      Before anything else, derive all variables from `$ARGUMENTS` and verify the environment:
      ```bash
      ONNX_PATH="$ARGUMENTS"
      
      # Derive MODEL_NAME from directory structure: models/{MODEL_NAME}/model/...
      MODEL_NAME=$(echo "$ONNX_PATH" | sed 's|models/\([^/]*\)/.*|\1|')
      
      # Derive MODEL_FILENAME as the ONNX basename without extension
      MODEL_FILENAME=$(basename "$ONNX_PATH" .onnx)
      
      # MAX_BS drives --optShapes, --maxShapes, and the engine filename postfix
      # Starting value is 64 — will double iteratively in Step 5 if PEAK_GPU_STREAMS > 64
      MAX_BS=64
      
      echo "Model:    $MODEL_NAME"
      echo "File:     $MODEL_FILENAME"
      echo "ONNX:     $ONNX_PATH"
      echo "Engine:   models/$MODEL_NAME/benchmarks/engines/${MODEL_FILENAME}_dynamic_b${MAX_BS}.engine"
      
      # Verify ONNX file exists
      ls -lh "$ONNX_PATH" || { echo "ERROR: ONNX file not found at $ONNX_PATH"; exit 1; }
      
      # trtexec lives at a fixed path IN the container (not on PATH); read the TRT version from it.
      TRTEXEC=/usr/src/tensorrt/bin/trtexec
      [ -x "$TRTEXEC" ] || { echo "ERROR: trtexec not found at $TRTEXEC — is this the DeepStream/TensorRT image?"; exit 1; }
      # `head -2` closes the pipe early, so trtexec takes SIGPIPE; under `set -o pipefail` that
      # surfaces as exit 141 and aborts the calling script. Engine/log existence is checked
      # explicitly below, so the version banner is advisory only.
      "$TRTEXEC" --version 2>&1 | head -2 || true
      
      # Verify GPU is available (in-container)
      nvidia-smi --query-gpu=name,memory.total --format=csv,noheader
      ```
      
      If the ONNX file doesn't exist, inform the user to run Steps 1-3 first (see references/model-acquire.md).
      
      > All subsequent commands use `$MODEL_NAME`, `$MODEL_FILENAME`, `$MAX_BS`, and `$TRTEXEC` — never hardcoded paths or template placeholders.
      
      Inspect the ONNX model and auto-parse input name and spatial dimensions:
      ```bash
      INSPECT_OUT=$(python3 .claude/skills/deepstream-import-vision-model/scripts/model/inspect-onnx.py "$ONNX_PATH")
      echo "$INSPECT_OUT"
      
      INPUT_NAME=$(echo "$INSPECT_OUT" | grep -oP 'input_name:\s*\K\S+')
      H=$(echo "$INSPECT_OUT"          | grep -oP 'height:\s*\K[0-9]+')
      W=$(echo "$INSPECT_OUT"          | grep -oP 'width:\s*\K[0-9]+')
      
      echo "INPUT_NAME=$INPUT_NAME  H=$H  W=$W"
      [ -z "$INPUT_NAME" ] && { echo "ERROR: could not parse INPUT_NAME from inspect output"; exit 1; }
      # If H/W are empty (dynamic spatial dims), set them manually before proceeding:
      #   H=640; W=640   # or whatever the model's expected input resolution is
      #   Check the model card on HuggingFace or config.json image_size field
      [ -z "$H" ] && { echo "ERROR: H not detected — model has dynamic spatial dims. Set H manually: H=<height>"; exit 1; }
      [ -z "$W" ] && { echo "ERROR: W not detected — model has dynamic spatial dims. Set W manually: W=<width>";  exit 1; }
      ```
      
      ## Step 4: Build TensorRT Engine
      
      Build one dynamic engine optimized for BS=64. `opt=max=64` ensures TRT optimizes kernels for
      the exact batch size used for benchmarking and DeepStream. `min=1` handles single-stream validation.
      
      ```bash
      STEP4_START=$(date +%s.%N)
      TIMESTAMP=$(date +%Y%m%d_%H%M%S)
      # benchmarks/engines/ already exists from model-acquire;
      # mkdir -p kept here as a safety net for standalone use
      mkdir -p models/$MODEL_NAME/benchmarks/engines models/$MODEL_NAME/benchmarks/b1 models/$MODEL_NAME/benchmarks/b${MAX_BS}
      
      $TRTEXEC \
        --onnx="$ONNX_PATH" \
        --minShapes=$INPUT_NAME:1x3x${H}x${W} \
        --optShapes=$INPUT_NAME:${MAX_BS}x3x${H}x${W} \
        --maxShapes=$INPUT_NAME:${MAX_BS}x3x${H}x${W} \
        --fp16 \
        --skipInference \
        --memPoolSize=workspace:32768M \
        --timingCacheFile=models/$MODEL_NAME/benchmarks/engines/timing.cache \
        --saveEngine="models/$MODEL_NAME/benchmarks/engines/${MODEL_FILENAME}_dynamic_b${MAX_BS}.engine" \
        2>&1 | tee models/$MODEL_NAME/benchmarks/engines/${MODEL_FILENAME}_dynamic_build_${TIMESTAMP}.log
      
      # Verify engine was created — trtexec exit code is lost through the pipe, so check the file
      [ -f "models/$MODEL_NAME/benchmarks/engines/${MODEL_FILENAME}_dynamic_b${MAX_BS}.engine" ] || \
        { echo "ERROR: Engine file not created — check build log for errors"; exit 1; }
      
      STEP4_END=$(date +%s.%N)
      STEP4_DURATION=$(python3 -c "print(round($STEP4_END - $STEP4_START, 2))")   # bc is not in the container; python3 always is
      echo "[Step 4] Engine build completed in ${STEP4_DURATION}s"
      ```
      
      Set the ENGINE variable — used by all subsequent trtexec and DeepStream runs:
      ```bash
      ENGINE="models/$MODEL_NAME/benchmarks/engines/${MODEL_FILENAME}_dynamic_b${MAX_BS}.engine"
      ```
      
      ## Step 5: Benchmark — 2 Runs Only
      
      Run exactly **2 trtexec benchmarks** using the Step 4 engine. No sweep needed.
      - BS=1 → latency baseline (single-stream worst case)
      - BS=64 → peak throughput → `PEAK_GPU_STREAMS`
      
      ```bash
      STEP5_START=$(date +%s.%N)
      ```
      
      ### Run 5a — Latency baseline (BS=1)
      
      > Log filename is **fixed** — no timestamp, no variation. Always `trtexec_b1.log`. This ensures the report-generation skill can find it with an exact path, not a wildcard.
      
      ```bash
      $TRTEXEC \
        --loadEngine="$ENGINE" \
        --shapes=$INPUT_NAME:1x3x${H}x${W} \
        --noDataTransfers --duration=10 --warmUp=1000 \
        2>&1 | tee models/$MODEL_NAME/benchmarks/b1/trtexec_b1.log
      ```
      
      ### Run 5b — Peak throughput (BS=MAX_BS)
      
      > Log filename is **fixed** — always `trtexec_b${MAX_BS}.log`. Updated by the while loop if MAX_BS changes.
      
      ```bash
      $TRTEXEC \
        --loadEngine="$ENGINE" \
        --shapes=$INPUT_NAME:${MAX_BS}x3x${H}x${W} \
        --noDataTransfers --duration=10 --warmUp=1000 \
        2>&1 | tee models/$MODEL_NAME/benchmarks/b${MAX_BS}/trtexec_b${MAX_BS}.log
      ```
      
      ### Parse results and compute PEAK_GPU_STREAMS
      ```bash
      QPS_BS1=$(grep -oP 'Throughput:\s*\K[0-9.]+' \
        models/$MODEL_NAME/benchmarks/b1/trtexec_b1.log | tail -1)
      GPU_MEAN_BS1=$(grep -oP 'GPU Compute Time:.*mean = \K[0-9.]+' \
        models/$MODEL_NAME/benchmarks/b1/trtexec_b1.log | tail -1)
      
      QPS_BS_MAX=$(grep -oP 'Throughput:\s*\K[0-9.]+' \
        models/$MODEL_NAME/benchmarks/b${MAX_BS}/trtexec_b${MAX_BS}.log | tail -1)
      GPU_MEAN_BS_MAX=$(grep -oP 'GPU Compute Time:.*mean = \K[0-9.]+' \
        models/$MODEL_NAME/benchmarks/b${MAX_BS}/trtexec_b${MAX_BS}.log | tail -1)
      GPU_P99_BS_MAX=$(grep -oP 'GPU Compute Time:.*percentile\(99%\) = \K[0-9.]+' \
        models/$MODEL_NAME/benchmarks/b${MAX_BS}/trtexec_b${MAX_BS}.log | tail -1)
      
      read IMGS_PER_SEC PEAK_GPU_STREAMS < <(python3 -c "
      import math
      imgs = float('$QPS_BS_MAX') * $MAX_BS
      streams = int(math.floor(imgs / 30))
      print(round(imgs, 2), streams)
      ")
      
      echo "BS=1:       QPS=$QPS_BS1  GPU mean=${GPU_MEAN_BS1}ms"
      echo "BS=$MAX_BS: QPS=$QPS_BS_MAX  imgs/s=$IMGS_PER_SEC  GPU mean=${GPU_MEAN_BS_MAX}ms  P99=${GPU_P99_BS_MAX}ms"
      echo "PEAK_GPU_STREAMS=$PEAK_GPU_STREAMS  (floor($IMGS_PER_SEC / 30))"
      
      STEP5_END=$(date +%s.%N)
      STEP5_DURATION=$(python3 -c "print(round($STEP5_END - $STEP5_START, 2))")   # bc is not in the container; python3 always is
      echo "[Step 5] Benchmarks completed in ${STEP5_DURATION}s"
      ```
      
      `PEAK_GPU_STREAMS` is the GPU-only upper bound on real-time 30fps stream count. DeepStream will always achieve fewer streams due to NVDEC, mux, and GStreamer overhead (typically 10–40%). Use `PEAK_GPU_STREAMS` as the starting stream count for DS Run 1 (calibration).
      
      ### Iterative Engine Scaling (PEAK_GPU_STREAMS > MAX_BS)
      
      If `PEAK_GPU_STREAMS > MAX_BS`, the engine's max batch size is the bottleneck — DeepStream cannot run more streams than `MAX_BS`. **Double MAX_BS and rebuild**, then re-run trtexec and recompute `PEAK_GPU_STREAMS`. Repeat until `PEAK_GPU_STREAMS ≤ MAX_BS`.
      
      **Why doubling, not jumping to PEAK directly**: Jumping from 64→512 based on an extrapolated projection wastes GPU memory if the projection was off. Doubling (64→128→256→512) makes incremental, verifiable steps — each trtexec run gives real throughput data before committing to a larger rebuild.
      
      ```bash
      while [ "$PEAK_GPU_STREAMS" -gt "$MAX_BS" ]; do
        NEW_MAX_BS=$(python3 -c "print($MAX_BS * 2)")  # STRICT DOUBLING — do not change to ceil(log2(PEAK))
        echo "Rebuilding engine: PEAK_GPU_STREAMS=$PEAK_GPU_STREAMS > MAX_BS=$MAX_BS — doubling to: $NEW_MAX_BS"
      
        mkdir -p models/$MODEL_NAME/benchmarks/b${NEW_MAX_BS}
      
        $TRTEXEC \
          --onnx="$ONNX_PATH" \
          --minShapes=$INPUT_NAME:1x3x${H}x${W} \
          --optShapes=$INPUT_NAME:${NEW_MAX_BS}x3x${H}x${W} \
          --maxShapes=$INPUT_NAME:${NEW_MAX_BS}x3x${H}x${W} \
          --fp16 --skipInference \
          --memPoolSize=workspace:32768M \
          --timingCacheFile=models/$MODEL_NAME/benchmarks/engines/timing.cache \
          --saveEngine="models/$MODEL_NAME/benchmarks/engines/${MODEL_FILENAME}_dynamic_b${NEW_MAX_BS}.engine" \
          2>&1 | tee models/$MODEL_NAME/benchmarks/engines/${MODEL_FILENAME}_dynamic_build_b${NEW_MAX_BS}_${TIMESTAMP}.log
      
        [ -f "models/$MODEL_NAME/benchmarks/engines/${MODEL_FILENAME}_dynamic_b${NEW_MAX_BS}.engine" ] || \
          { echo "ERROR: Engine b${NEW_MAX_BS} not created — check build log"; exit 1; }
      
        # Update ENGINE and MAX_BS — re-run trtexec at new BS and recompute PEAK_GPU_STREAMS
        ENGINE="models/$MODEL_NAME/benchmarks/engines/${MODEL_FILENAME}_dynamic_b${NEW_MAX_BS}.engine"
        MAX_BS=$NEW_MAX_BS
      
        $TRTEXEC \
          --loadEngine="$ENGINE" \
          --shapes=$INPUT_NAME:${MAX_BS}x3x${H}x${W} \
          --noDataTransfers --duration=10 --warmUp=1000 \
          2>&1 | tee models/$MODEL_NAME/benchmarks/b${MAX_BS}/trtexec_b${MAX_BS}.log
      
        QPS_BS_MAX=$(grep -oP 'Throughput:\s*\K[0-9.]+' \
          models/$MODEL_NAME/benchmarks/b${MAX_BS}/trtexec_b${MAX_BS}.log | tail -1)
        GPU_MEAN_BS_MAX=$(grep -oP 'GPU Compute Time:.*mean = \K[0-9.]+' \
          models/$MODEL_NAME/benchmarks/b${MAX_BS}/trtexec_b${MAX_BS}.log | tail -1)
        GPU_P99_BS_MAX=$(grep -oP 'GPU Compute Time:.*percentile\(99%\) = \K[0-9.]+' \
          models/$MODEL_NAME/benchmarks/b${MAX_BS}/trtexec_b${MAX_BS}.log | tail -1)
        read IMGS_PER_SEC PEAK_GPU_STREAMS < <(python3 -c "
      import math
      imgs = float('$QPS_BS_MAX') * $MAX_BS
      print(round(imgs, 2), int(math.floor(imgs / 30)))
      ")
        echo "Recomputed: BS=$MAX_BS  imgs/s=$IMGS_PER_SEC  PEAK_GPU_STREAMS=$PEAK_GPU_STREAMS"
      done
      
      echo "PEAK_GPU_STREAMS ($PEAK_GPU_STREAMS) <= MAX_BS ($MAX_BS) — engine scaling complete."
      ```
      
      **Engine count summary:**
      
      | Scenario | Example | Engines | trtexec runs |
      |----------|---------|---------|-------------|
      | PEAK_GPU_STREAMS ≤ 64 (transformer/large models) | RT-DETR, OWL-ViT | **1** (`b64`) | **2** |
      | PEAK_GPU_STREAMS > 64, ≤ 128 (mid models) | TrafficCamNet | **2** (`b64` + `b128`) | **3** |
      | PEAK_GPU_STREAMS > 128, ≤ 256 (fast models) | YOLO26n | **3** (`b64`+`b128`+`b256`) | **4** |
      | PEAK_GPU_STREAMS > 256 (very fast nano models) | — | **4+** (keep doubling) | **5+** |
      
      ## trtexec Flags Reference
      
      ### Recommended Flags
      | Flag | Purpose | When to use |
      |------|---------|-------------|
      | `--duration=10` | Longer run for stable numbers | All benchmark runs (5a, 5b) |
      | `--warmUp=1000` | 1s warmup before measurement | All benchmark runs (5a, 5b) |
      | `--noDataTransfers` | GPU-only compute (matches DS reality) | Always |
      
      ### Why GPU-only (`--noDataTransfers`) Only
      In DeepStream, frames are decoded on GPU (`nvv4l2decoder`) and stay on GPU through `nvinfer` — no H2D transfer. Standard trtexec transfers synthetic data from host, which is not representative. Do NOT report H2D/D2H latency.
      
      ### Flags That Do NOT Help (tested)
      | Flag | Result | Why |
      |------|--------|-----|
      | `--best` | No improvement | Engine already built with --fp16, runtime flag doesn't change precision |
      | `--exposeDMA` | **45% WORSE** throughput | Serializes DMA transfers — kills pipelining |
      | `--infStreams=4` | +2% QPS max | GPU already saturated |
      
      ### Key Metrics to Report from trtexec
      - **Throughput (QPS)** and **Images/s** (QPS × batch_size)
      - **GPU Compute mean (ms)** and **GPU Compute P99 (ms)**
      - **GPU Compute per image (ms)** (GPU Compute mean / batch_size)
      - Do **NOT** report: H2D latency, D2H latency, Host Latency, transfer overhead
      
      ## Engine Version Compatibility -- CRITICAL
      
      TensorRT engine files are **not portable** across TensorRT versions.
      
      ### Pre-flight Version Check
      `trtexec` lives in the container at `/usr/src/tensorrt/bin/trtexec` (set `TRTEXEC=/usr/src/tensorrt/bin/trtexec`);
      get its version with `"$TRTEXEC" --version`. No host `dpkg`/`libnvinfer-bin` check is needed.
      
      ### Build and run in the SAME container image
      The engine is **built and run inside the same image** (`nvcr.io/nvidia/deepstream:9.1-triton-multiarch`),
      so the `libnvinfer` that `trtexec` builds against is byte-for-byte the one DeepStream loads at runtime —
      **there is no build-vs-runtime version skew.** This is the correct fix for TensorRT's engine
      non-portability: it *eliminates* the "Docker-built engine silently fails on a differently-versioned
      host runtime" failure mode (0% GPU / stuck pipeline) that the old "always build on the host" rule tried
      to avoid. **Never build an engine against a different TensorRT than the one that will run it** — using
      one image for both guarantees this by construction. (Do not build engines on the host; the host has no
      required toolchain in the container-run model.)
      
      ## Known Issues and Workarounds
      
      ### `--memPoolSize` Flag Format — `M` vs `MiB` (CRITICAL silent failure)
      
      - **Correct**: `--memPoolSize=workspace:32768M` (suffix `M` = Mebibytes)
      - **WRONG**: `--memPoolSize=workspace:32768MiB` — trtexec interprets `MiB` as bytes, so `32768MiB` becomes 32 KB. All tactics fail with "insufficient workspace". There is no parse warning; the only symptom is `Memory Pools: workspace: 0.03125 MiB` in the build log.
      - Valid suffixes: `B`, `K`, `M`, `G`, or no suffix (default MiB).
      
      ### Deformable Attention Models (RT-DETR, DDETR, Deformable DETR)
      
      Models using `MultiscaleDeformableAttnPlugin_TRT` build correctly on TRT 10.16 **provided workspace is sufficient**.
      - **Required**: `--memPoolSize=workspace:32768M` (not the default 8GB) — deformable attention at BS=64 needs substantial workspace for ForeignNode fusion tactics.
      - `--builderOptimizationLevel=4` (default) works; do not lower it unless necessary.
      - Typical footprint at BS=64 on H100: activation ~4266 MiB, peak memory ~7809 MiB, build time ~825s. The compiler backend phase after engine generation can take 5-10 minutes with no log output — this is normal, not a hang.
      - Error "Could not find any implementation for node {ForeignNode[...]} due to insufficient workspace" is a genuine signal to raise the workspace.
      
      ### DETR / DETR-family Backbone Mask ForeignNode Failure (TRT 10.16)
      
      HF-exported DETR/DDETR models contain a dynamic backbone mask path (`Cast → Resize → Sigmoid`) that TRT 10.16 fuses into a ForeignNode with no valid tactic: `"Could not find any implementation for node {ForeignNode[.../Cast_2.../Sigmoid]}"`.
      
      - **Preferred fix** (TRT 10.16.01+, PyTorch 2.11+, transformers 5.5+): use the dynamo export path with `torch.export.Dim("batch", min=1, max=N)` in `dynamic_shapes`. The dynamo exporter produces a different graph that does NOT trigger the ForeignNode failure. TRT converts it directly as a dynamic-batch engine.
      - **Fallback for older toolchains**: run `onnxsim.simplify(model, input_shapes={'pixel_values': [BS, 3, H, W]})` first. This folds the mask into constants but bakes batch size, requiring per-batch ONNX + engine files.
      - **Secondary workaround**: lower `builder_optimization_level` to 2 via the Python TRT API (`config.builder_optimization_level = 2`). Prevents over-aggressive fusion; engines built this way are still compatible with `trtexec --loadEngine`.
      
      ### Dynamic Engine Batch-Size Anomalies (transformer models)
      
      Dynamic-shape engines for transformer models (DETR, RT-DETR) can show **non-monotonic throughput** — specific non-power-of-2 batch sizes (e.g., BS=17-19) perform dramatically worse than neighboring values. Cause: TRT tactic selection for attention layers at non-optimal shapes. When DS at `N` streams shows surprisingly low FPS, test `N±8` before concluding the GPU is saturated. Prefer power-of-2 batch sizes for production.
      
      ## Output Summary
      
      ```bash
      TOTAL_DURATION=$(python3 -c "print(round($STEP4_DURATION + $STEP5_DURATION, 2))")   # bc is not in the container; python3 always is
      ```
      
      When complete, print:
      ```
      === TRT Engine Build Complete ===
      Model:   $MODEL_NAME
      Engine:  models/$MODEL_NAME/benchmarks/engines/${MODEL_FILENAME}_dynamic_b${MAX_BS}.engine
               (single engine — used for trtexec baseline and all DS runs)
      
      trtexec Results:
        BS=1:       $QPS_BS1 QPS  |  GPU mean: ${GPU_MEAN_BS1}ms
        BS=$MAX_BS: $QPS_BS_MAX QPS  |  $IMGS_PER_SEC img/s  |  GPU mean: ${GPU_MEAN_BS_MAX}ms  P99: ${GPU_P99_BS_MAX}ms
      
      PEAK_GPU_STREAMS (GPU-only upper bound): $PEAK_GPU_STREAMS streams @30fps
      
      Timing:
        Step 4 (engine build): ${STEP4_DURATION}s
        Step 5 (benchmarks):   ${STEP5_DURATION}s
        Total Steps 4-5:       ${TOTAL_DURATION}s
      
      Ready for: Steps 6-7 — read references/pipeline-run.md models/$MODEL_NAME/
      ```
      
    • model-acquire.md 24.7 KB
      
      # NV Model Acquire — Steps 1-3
      
      Acquire an ONNX model from Hugging Face, creating the mandatory model folder structure.
      
      ## Intake — choose the model
      
      Before Step 1, present two explicit choices:
      
      1. **Default model (recommended):** `PekingU/rtdetr_r50vd` from Hugging Face.
      2. **Custom object-detection model:** collect a Hugging Face ID/URL or a versioned NVIDIA NGC
         catalog URL.
      
      Do not replace this with only an open-ended source prompt. If Default is selected, set
      `INPUT="PekingU/rtdetr_r50vd"`. If Custom is selected, require the source before continuing.
      Dry runs show the choices but perform no browsing, downloads, Docker launches, or file writes.
      
      ## MANDATORY: Model Folder Structure
      
      Create this layout at the start of Step 2 (once `$MODEL_NAME` is set by Step 1):
      ```
      models/{model_name}/
        model/       config/       parser/       scripts/
        benchmarks/engines/
        reports/charts/      samples/
      ```
      ```bash
      mkdir -p models/$MODEL_NAME/{model,parser,config,scripts,benchmarks/engines,reports/charts,samples}
      ```
      Temporary staging dirs (`hf_model/`, `ngc_download/`, `build/`) are created inline where needed and cleaned up afterward — they are NOT part of this structure.
      
      ## Step 1: Parse the Model Source URL
      
      Accept a model URL or ID in one of these formats and extract the required fields:
      
      ```bash
      [ -z "$ARGUMENTS" ] && { echo "ERROR: No model URL or ID provided. Usage: /deepstream-import-vision-model <url>"; exit 1; }
      INPUT="${ARGUMENTS}"
      
      if echo "$INPUT" | grep -q "catalog.ngc.nvidia.com"; then
        # NGC catalog URL
        # e.g. https://catalog.ngc.nvidia.com/orgs/nvidia/teams/tao/models/trafficcamnet_transformer_lite/files?version=deployable_resnet50_v2.0
        MODEL_SOURCE="ngc"
        NGC_ORG=$(echo "$INPUT"    | sed 's|.*/orgs/\([^/]*\)/.*|\1|')
        NGC_TEAM=$(echo "$INPUT"   | sed 's|.*/teams/\([^/]*\)/.*|\1|')
        MODEL_NAME=$(echo "$INPUT" | sed 's|.*/models/\([^/]*\)/.*|\1|')
        NGC_VERSION=$(echo "$INPUT" | sed 's|.*version=\([^&]*\).*|\1|')
        echo "Source: NGC  Org: $NGC_ORG  Team: $NGC_TEAM  Model: $MODEL_NAME  Version: $NGC_VERSION"
      else
        # HuggingFace full URL or short ID (e.g. https://huggingface.co/onnx-community/yolov8n or onnx-community/yolov8n)
        MODEL_SOURCE="hf"
        SLUG=$(echo "$INPUT" | sed 's|https://huggingface.co/||' | sed 's|/resolve/.*||' | sed 's|/$||')
        HF_ORG=$(echo "$SLUG"    | cut -d/ -f1)
        MODEL_NAME=$(echo "$SLUG" | cut -d/ -f2)
        echo "Source: HF  Org: $HF_ORG  Model: $MODEL_NAME"
      fi
      ```
      
      - `MODEL_SOURCE` (`hf` or `ngc`) drives category selection in Step 2
      - `MODEL_NAME` is used as the folder name throughout (`models/{MODEL_NAME}/`)
      - Proceed to Step 2 with these variables set
      
      ## Step 2: Detect Model Source and Format
      
      First, create the model directory structure (required for all sources), then route by source:
      ```bash
      # Create permanent model directory structure (all sources — HF and NGC)
      mkdir -p models/$MODEL_NAME/{model,parser,config,scripts,benchmarks/engines,reports/charts,samples}
      
      # Route based on MODEL_SOURCE set in Step 1
      if [ "$MODEL_SOURCE" = "ngc" ]; then
        echo "NGC model detected — skipping HF repo browse, proceeding to Step 2d"
        # Skip to Step 2d directly — do not run any HF curl commands below
      fi
      # The following HF browse, config download, and labels extraction only runs for MODEL_SOURCE=hf
      ```
      
      - Browse the HF repository and classify available model files using the vetted helper script
        (validates inputs, uses HTTPS+TLSv1.2 only, honors `$HF_TOKEN`):
        ```bash
        FILES="$(bash .claude/skills/deepstream-import-vision-model/scripts/model/hf-list-files.sh "$HF_ORG" "$MODEL_NAME")"
        ONNX_FILES=$(echo "$FILES" | grep -E '\.onnx$' || true)
        ST_FILES=$(echo "$FILES" | grep -E '\.(safetensors|bin)$' || true)
        echo "ONNX files:      ${ONNX_FILES:-none}"
        echo "SafeTensors/bin: ${ST_FILES:-none}"
        echo "All files:       $FILES"
      
        # If ONNX list is empty in root, also check /onnx subdirectory
        if [ -z "$ONNX_FILES" ]; then
            ONNX_SUB="$(bash .claude/skills/deepstream-import-vision-model/scripts/model/hf-list-files.sh "$HF_ORG" "$MODEL_NAME" onnx | grep -E '\.onnx$' || true)"
            echo "ONNX in /onnx subdir: ${ONNX_SUB:-none}"
        fi
        ```
      - Classify the repo into one of these categories:
      
        **Category A: ONNX files available** -> proceed to Step 2a (select ONNX variant)
        **Category B: SafeTensors/PyTorch only (no ONNX)** -> proceed to Step 2b (export to ONNX)
        **Category C: No usable model files** -> inform user, suggest alternative repos
        **Category D: NGC model (not on HuggingFace)** -> proceed to Step 2d (NGC download)
      
      - Download `config.json` — required for architecture detection and label extraction.
        Uses the vetted helper script (validated inputs, HTTPS+TLS, honors `$HF_TOKEN`):
        ```bash
        # HF: download from API via vetted helper. NGC: extracted from archive in Step 2d.
        if [ "$MODEL_SOURCE" = "hf" ]; then
          bash .claude/skills/deepstream-import-vision-model/scripts/model/hf-download-config.sh \
              "$HF_ORG" "$MODEL_NAME" "models/$MODEL_NAME/config/config.json"
        else
          echo "NGC model — config.json will be extracted from the downloaded archive in Step 2d"
        fi
        # Note: models/$MODEL_NAME/config/ already exists from the MANDATORY mkdir at the top of Step 2
        ```
      - Inspect `config.json` to identify:
        - Model type (e.g., `grounding-dino`, `detr`, `yolos`, `resnet`, `swin`)
        - Architecture class (e.g., `GroundingDinoForObjectDetection`)
        - Number of inputs (single input vs multi-modal)
      
      - **Reject non-detection architectures (fail fast)**: Check the `architectures` field in `config.json` before continuing. If the architecture class ends in a non-detection suffix such as `ForImageClassification`, `ForSemanticSegmentation`, `ForInstanceSegmentation`, `ForPanopticSegmentation`, `ForDepthEstimation`, `ForMaskedLM`, `ForTokenClassification`, or `ForCausalLM`, **abort the pipeline with a clear error and exit non-zero**: `"deepstream-import-vision-model currently supports object detection models only. Detected architecture: {arch_class}. Classification, segmentation, and other vision tasks are not yet supported."` Do not prompt the user. Detection architectures end in `ForObjectDetection` (or, for some DETR-family variants, `ForConditionalDetection` / `ForZeroShotObjectDetection`).
      
      - **Validate the architecture and extract `labels.txt`** — one shared helper does both, so the
        HF and NGC routes cannot drift apart. Run it as soon as `config.json` is in place (for HF that
        is now; for NGC it runs at the end of Step 2d):
        ```bash
        build/.venv_optimum/bin/python \
          .claude/skills/deepstream-import-vision-model/scripts/model/config-to-labels.py \
          --config models/$MODEL_NAME/config/config.json \
          --labels models/$MODEL_NAME/config/labels.txt
        ```
        It exits non-zero on a non-detection architecture (the fail-fast gate above) and on a missing
        label map. **Treat either as fatal** — do not prompt the user, and never fall back to hardcoded
        COCO, ImageNet, or any other default list.
      
      ### Step 2a: Select ONNX Variant (Category A)
      - Identify available quantization variants (fp32, fp16, int8, int4, quantized, etc.)
      - **Default preference: fp16**. Apply this logic:
        1. If fp16 variant exists -> **select it silently**, log: `"Selected: fp16 (default). All available: [list]"`
        2. If fp16 does NOT exist -> **auto-select deterministically** in this priority order: fp32 > int8 > int4 > quantized > first ONNX alphabetically. Log: `"Selected: {variant} (fp16 unavailable). All available: [list]"`. Do not prompt the user.
        3. If only one ONNX file exists -> log it and proceed without asking
      - **Construct the resolved download URL** for the selected variant from the tree listing:
        ```bash
        # The tree API returns entries with a "path" field (relative to repo root)
        # Construct the download URL as:
        PATH_FROM_TREE="<path field from tree listing, e.g. onnx/model_fp16.onnx>"
        ONNX_URL="https://huggingface.co/$HF_ORG/$MODEL_NAME/resolve/main/$PATH_FROM_TREE"
        # Example: path="onnx/model_fp16.onnx" -> URL ends in /resolve/main/onnx/model_fp16.onnx
        # Store this URL for use in Step 3
        ```
      - After URL construction, proceed to **Step 3** (download ONNX)
      
      ### Step 2b: Export SafeTensors to ONNX (Category B)
      
      When the repo only has `.safetensors` (or `.bin`) files and no ONNX export, convert to ONNX using an **isolated virtual environment** to avoid polluting the host system.
      
      #### 2b-i: Virtual Environment (already built by `setup.sh`, in-container)
      - The shared venv at **`build/.venv_optimum`** is created **inside the DeepStream container** by the
        skill's `setup.sh` (one-time bootstrap) — do **not** create it on the host. Every export command runs
        in-container against it; use `PY=build/.venv_optimum/bin/python` and `build/.venv_optimum/bin/optimum-cli`.
      - It's a single shared venv across all models (`torch`/`transformers`/`onnxruntime` are heavy
        and identical model-to-model). If it's missing, run the bootstrap (from SKILL.md Pre-flight):
        ```bash
        # in-container (via docker run … --entrypoint bash … setup.sh); NOT on the host
        .claude/skills/deepstream-import-vision-model/setup.sh    # builds build/.venv_optimum + wkhtmltopdf
        ```
      - For a new model that needs **extra packages** (e.g. `timm` for DETR-family backbones or `onnxsim`), `pip install` them **into the existing shared venv** rather than creating a new one:
        ```bash
        source build/.venv_optimum/bin/activate
        pip install timm   # or: pip install onnxsim
        ```
      - The venv lives under `build/.venv_optimum` at the repo root, keeping `models/` clean and excluded from git via the root `.gitignore`
      - All subsequent Python/pip commands in Step 2b must run inside this venv
      - Legacy per-model venvs at `build/.venv_$MODEL_NAME` from older runs are still cleaned up by `.claude/skills/deepstream-import-vision-model/scripts/model/cleanup.sh "$MODEL_NAME"` for backward compatibility
      
      #### 2b-ii: Download Required Files
      - Download from the HF repo into `models/$MODEL_NAME/hf_model/` using `-P` to avoid changing the working directory:
        ```bash
        mkdir -p models/$MODEL_NAME/hf_model
        HF_BASE="https://huggingface.co/$HF_ORG/$MODEL_NAME/resolve/main"
        # Download model files
        wget -P models/$MODEL_NAME/hf_model "$HF_BASE/model.safetensors"
        wget -P models/$MODEL_NAME/hf_model "$HF_BASE/config.json"
        wget -P models/$MODEL_NAME/hf_model "$HF_BASE/preprocessor_config.json"
        # For text+vision models, also download tokenizer files (failures are non-fatal):
        wget -P models/$MODEL_NAME/hf_model "$HF_BASE/tokenizer.json"         || true
        wget -P models/$MODEL_NAME/hf_model "$HF_BASE/tokenizer_config.json"  || true
        wget -P models/$MODEL_NAME/hf_model "$HF_BASE/vocab.txt"              || true
        wget -P models/$MODEL_NAME/hf_model "$HF_BASE/special_tokens_map.json" || true
        ```
      - For sharded models (multiple `.safetensors` files), also download `model.safetensors.index.json` and all shards
      
      #### 2b-iii: SafeTensors -> ONNX Export -- Max 3 Retries
      
      > **optimum was removed from this skill.** It pinned `transformers` below 4.54.0, which blocked the
      > releases fixing its RCE advisories (GHSA-29pf-2h5f-8g72, fixed in 5.3.0; GHSA-fgcw-684q-jj6r, fixed
      > in 5.5.0), and optimum 2.1.0 dropped the `onnx` subcommand entirely. Export now goes through
      > `torch.onnx.export` directly.
      
      - Run the export wrapper (it resolves the shared venv and calls `safetensors_to_onnx.py`):
        ```bash
        bash .claude/skills/deepstream-import-vision-model/scripts/model/safetensors-to-onnx.sh \
          models/$MODEL_NAME/hf_model \
          models/$MODEL_NAME/onnx_export/
        ```
      - It writes `models/$MODEL_NAME/onnx_export/model.onnx` and:
        - reads the export resolution from `preprocessor_config.json` (falling back to 640x640);
        - rejects non-detection architectures before doing any work;
        - **tries the dynamo backend, then falls back to TorchScript** if dynamo specialized the batch
          dimension — it prints `[export] backend=...` so you can see which one produced the graph;
        - consolidates any sidecar `model.onnx.data` back into the `.onnx`;
        - **verifies** the graph has the `pixel_values` input plus `logits` / `pred_boxes` outputs, and
          that the batch dimension really is dynamic. It exits non-zero rather than emitting a
          static-batch graph, because DeepStream needs `batch_size == num_streams` up to `MAX_BS`.
      - Expected output for the default model — note the automatic fallback:
        ```
        [export] backend=dynamo
        [export] pixel_values shape=[2, 3, 640, 640]
        [export] dynamo produced a static batch dimension; trying the next backend
        [export] backend=legacy-torchscript
        [export] pixel_values shape=['batch', 3, 640, 640]
        ```
      - Useful flags (passed straight through): `--opset N` · `--image-size N` · `--max-batch N` ·
        `--static-batch` · `--legacy-torchscript`
      - If export succeeds, copy the ONNX file to the `model/` subdirectory:
        ```bash
        cp models/$MODEL_NAME/onnx_export/model.onnx models/$MODEL_NAME/model/$MODEL_NAME.onnx
        ```
      - **Retry policy**: the two-backend fallback is automatic, so a failure here means both backends
        failed. Retry up to **3 times total** with adjustments between attempts:
        - **Retry 1**: Set `--image-size` explicitly if the error mentions a shape or resize mismatch
        - **Retry 2**: Try a different `--opset` (18 is the default; 17 and below fail on `Resize`)
        - **Retry 3**: Try `--static-batch` if both backends baked in the batch dimension. The engine
          must then be built at a fixed batch size — see `references/engine-build.md`.
        - After 3 failed attempts, fall back to **Step 2b-iv** (hand-written export for an unusual architecture)
      
      #### 2b-iv: Fallback -- Hand-written torch.onnx.export (unusual architectures) -- Max 3 Retries
      - If the wrapper fails after 3 retries because the model does not follow the standard
        `logits` / `pred_boxes` detection contract, write the export inline and adjust the wrapper outputs:
        ```bash
        build/.venv_optimum/bin/python -c "
        import torch
        from transformers import AutoModelForObjectDetection
      
        model = AutoModelForObjectDetection.from_pretrained('models/$MODEL_NAME/hf_model').eval()
      
        class Wrap(torch.nn.Module):
            def __init__(s, m): super().__init__(); s.m = m
            def forward(s, x):
                o = s.m(pixel_values=x)
                return o.logits, o.pred_boxes   # <- adjust for the architecture
      
        # Dummy input matching preprocessor_config.json dimensions
        dummy = torch.randn(1, 3, 800, 800)
      
        # TorchScript backend — the more reliable one for a dynamic batch dimension on
        # DETR-family detectors. Swap to the dynamo block below if this backend fails.
        torch.onnx.export(Wrap(model), dummy, 'models/$MODEL_NAME/model/$MODEL_NAME.onnx',
          opset_version=18, do_constant_folding=True, dynamo=False,
          input_names=['pixel_values'],
          output_names=['logits', 'pred_boxes'],
          dynamic_axes={'pixel_values': {0: 'batch'},
                        'logits': {0: 'batch'},
                        'pred_boxes': {0: 'batch'}})
      
        # dynamo backend (use for text-conditioned models the tracer cannot handle):
        #   batch = torch.export.Dim('batch', min=1, max=16)
        #   torch.onnx.export(Wrap(model), (torch.randn(2, 3, 800, 800),), '<out>.onnx',
        #     opset_version=18, dynamo=True,
        #     input_names=['pixel_values'], output_names=['logits', 'pred_boxes'],
        #     dynamic_shapes={'pixel_values': {0: batch}})
        "
        ```
      - Adjust the wrapper's returned tensors, input/output names, and shapes for the architecture
      - Always confirm the batch dimension survived:
        ```bash
        build/.venv_optimum/bin/python -c "
        import onnx; d = onnx.load('models/$MODEL_NAME/model/$MODEL_NAME.onnx').graph.input[0].type.tensor_type.shape.dim
        print([x.dim_param or x.dim_value for x in d])"   # expect ['batch', 3, H, W]
        ```
      - **Retry policy**: If manual export fails, retry up to **3 times total** with adjustments:
        - **Retry 1**: Try a different `AutoModel` class, or return different tensors from the wrapper
        - **Retry 2**: Switch backend (TorchScript <-> dynamo), remembering `dynamic_axes` vs `dynamic_shapes`
        - **Retry 3**: Drop the dynamic batch entirely and build a fixed-batch engine
        - After 3 failed attempts, **stop and generate a failure report**
      
      > **Gotchas for recent PyTorch/transformers** (verified on torch 2.13.0 + transformers 5.14.1
      > inside `nvcr.io/nvidia/deepstream:9.1-triton-multiarch`):
      > - **Neither backend works for every architecture — try `dynamo=True` first, fall back to `dynamo=False`.** `safetensors_to_onnx.py` does this automatically and reports which backend won.
      > - **`dynamo=True` can silently produce a STATIC batch dimension.** The model's own code may specialize `shape[0]`; `torch.export` then reports *"you marked batch as dynamic but your code specialized it to a constant"*. **RT-DETR (`PekingU/rtdetr_r50vd`, the default model) does exactly this** — dynamo yields `[2, 3, 640, 640]`, the TorchScript path yields `['batch', 3, 640, 640]`. Always verify with `onnx.load()`; the exporter asserts it.
      > - **`dynamo=False` does NOT crash on transformers 5.5+ for vision detectors.** An earlier revision of this document claimed it did, via `create_bidirectional_mask`. That applies to **text-conditioned** models (Grounding DINO and friends, which run a BERT text encoder) — not to pure-vision detectors. RT-DETR and YOLOS both export cleanly through TorchScript on transformers 5.14.1.
      > - Under `dynamo=True` the parameter is `dynamic_shapes` (with `torch.export.Dim`), **not** `dynamic_axes`; under `dynamo=False` it is `dynamic_axes`. Passing the wrong one is silently ineffective.
      > - **Use opset 18, not 17.** The dynamo exporter implements >= 18 and auto-upgrades, then its downgrade pass fails outright with `No Adapter To Version 17 for Resize`. Opset 18 is fine on TRT 10.16.
      > - **Call `.eval()` on the wrapper module too**, not just the loaded model — a fresh `nn.Module` defaults to training mode, which changes dropout/batchnorm behaviour during export.
      > - **External data files**: `torch.onnx.export` may produce `model.onnx.data` alongside the `.onnx`. Consolidate before TRT conversion: `m = onnx.load(path, load_external_data=True); onnx.save(m, consolidated_path)`. The exporter does this automatically.
      > - No `ForeignNode` failure was observed for RT-DETR on TRT 10.16 with either backend — see `references/engine-build.md` for the historical issue.
      
      #### 2b-v: Handle Multi-Modal Models (e.g., Grounding DINO)
      - Models that take **both image AND text** inputs need special handling for DeepStream (nvinfer only supports image input)
      - Strategy: **freeze the text prompt** into the ONNX graph as a constant
        1. Run the model once with a fixed text prompt (e.g., "person . car . truck .")
        2. Export ONNX with the text embeddings baked in as constants
        3. The resulting ONNX model only needs `pixel_values` as input
      - If freezing is not possible, check `onnx-community/` for pre-converted single-input versions
      - **Inform the user** about the frozen text prompt and its implications (fixed detection classes)
      
      #### 2b-vi: onnxsim — Run After Export When Needed
      
      If the model has dynamic shape paths that cause TRT `ForeignNode` fusion issues, simplify the ONNX graph with `onnxsim` **before** engine building:
      
      ```bash
      source build/.venv_optimum/bin/activate
      pip install onnxsim
      python3 -m onnxsim \
        models/$MODEL_NAME/model/$MODEL_NAME.onnx \
        models/$MODEL_NAME/model/${MODEL_NAME}_sim.onnx
      # Use the _sim.onnx for engine building if the original triggers ForeignNode errors
      ```
      
      Only run `onnxsim` if TRT build fails with `ForeignNode` warnings — it is not needed for most models.
      
      #### 2b-vii: Validate ONNX Output
      - After export, validate the ONNX file:
        ```bash
        source build/.venv_optimum/bin/activate
        python3 -c "
        import onnx
        m = onnx.load('models/$MODEL_NAME/model/$MODEL_NAME.onnx')
        onnx.checker.check_model(m)
        print('Inputs:')
        for i in m.graph.input:
          dims = [d.dim_param or d.dim_value for d in i.type.tensor_type.shape.dim]
          print(f'  {i.name}: {dims}')
        print('Outputs:')
        for o in m.graph.output:
          dims = [d.dim_param or d.dim_value for d in o.type.tensor_type.shape.dim]
          print(f'  {o.name}: {dims}')
        print('ONNX validation passed!')
        "
        ```
      - Verify:
        - Single image input (no text/mask inputs -- remove if needed)
        - Output shapes match expected detection format
        - Dynamic batch dimension is present
      
      #### 2b-viii: Cleanup
      - Deactivate the venv after export is complete:
        ```bash
        deactivate
        ```
      - **Keep `build/.venv_optimum` across runs** — it is shared by every SafeTensors → ONNX export and rebuilding it for each model costs minutes and GBs. `cleanup.sh` intentionally does not remove it.
      - `cleanup.sh` removes per-model artifacts (`models/$MODEL_NAME/hf_model`, `models/$MODEL_NAME/onnx_export`, and any legacy `build/.venv_$MODEL_NAME` left over from older runs):
        ```bash
        # Validated script; will refuse unsafe paths. Shared .venv_optimum is preserved.
        bash .claude/skills/deepstream-import-vision-model/scripts/model/cleanup.sh "$MODEL_NAME"
        # Preview without removing:
        # bash .claude/skills/deepstream-import-vision-model/scripts/model/cleanup.sh "$MODEL_NAME" --dry-run
        ```
      - The ONNX file is now at `models/$MODEL_NAME/model/$MODEL_NAME.onnx` -- proceed to engine building
      
      ### Step 2d: NGC Model Download (Category D)
      
      When the model comes from NVIDIA NGC (not HuggingFace), download using the `ngc` CLI if available, or fall back to `wget` for direct file download:
      
      ```bash
      # Vetted helper: prefers ngc CLI if installed, else falls back to authenticated
      # HTTPS+TLS via curl against the public NGC catalog API. All inputs validated
      # against ^[A-Za-z0-9._-]+$. See .claude/skills/deepstream-import-vision-model/scripts/model/ngc-download.sh for details.
      bash .claude/skills/deepstream-import-vision-model/scripts/model/ngc-download.sh \
          "$NGC_ORG" "$NGC_TEAM" "$MODEL_NAME" "$NGC_VERSION" \
          "models/$MODEL_NAME/ngc_download"
      
      # Inspect downloaded files
      echo "Downloaded files:"
      ls -lhR models/$MODEL_NAME/ngc_download/
      ```
      
      - Identify the ONNX file(s) in the downloaded archive (often inside a subdirectory named after the model version)
      - If the download contains a `.etlt` or `.engine` file only (TAO encrypted format), check if a plain ONNX is also provided; if not, use the TAO-provided engine directly and skip Step 4 (engine build)
      - Copy the ONNX to the model directory:
        ```bash
        NGC_ONNX=$(find models/$MODEL_NAME/ngc_download -name "*.onnx" | head -1)
        cp "$NGC_ONNX" models/$MODEL_NAME/model/$MODEL_NAME.onnx
        echo "ONNX: $NGC_ONNX -> models/$MODEL_NAME/model/$MODEL_NAME.onnx"
        ```
      - Extract `config.json` from the archive and build `labels.txt` (same logic as HF path):
        ```bash
        NGC_CONFIG=$(find models/$MODEL_NAME/ngc_download -name "config.json" | head -1)
        if [ -z "$NGC_CONFIG" ]; then
          echo "ERROR: config.json not found in NGC archive — cannot create labels.txt"
          echo "Cannot proceed without a label map — aborting. Provide an NGC archive that contains config.json."
          exit 1
        else
          cp "$NGC_CONFIG" models/$MODEL_NAME/config/config.json
          echo "config.json extracted from: $NGC_CONFIG"
      
          # Same helper the HF route uses: gates the architecture, then writes labels.txt.
          # The NGC route reaches config.json later than the HF route, so without this a
          # classification model would build an engine and a parser before anything noticed.
          build/.venv_optimum/bin/python \
            .claude/skills/deepstream-import-vision-model/scripts/model/config-to-labels.py \
            --config models/$MODEL_NAME/config/config.json \
            --labels models/$MODEL_NAME/config/labels.txt || exit 1
        fi
        ```
      
      ## Step 3: Download the ONNX Model
      
      The model directory structure was already created in the MANDATORY block at the top. Do NOT run `mkdir -p` again here — just download the file:
      
      ```bash
      wget -O "models/$MODEL_NAME/model/$MODEL_NAME.onnx" "${ONNX_URL}"
      ```
      
      Where `$ONNX_URL` is the resolved URL constructed at the end of Step 2a (Category A) or derived from the NGC download path (Category D). Categories B and D write the ONNX directly to `models/$MODEL_NAME/model/$MODEL_NAME.onnx` during export/copy — Step 3 only applies to Category A.
      - Also download any external data files if the ONNX model references them (files with `.onnx_data` extension or similar)
      - Verify the download completed successfully and report file size
      
      ## Timing
      
      Record wall-clock time at the start and end of this skill:
      ```bash
      STEP_START=$(date +%s.%N)
      # ... all steps ...
      STEP_END=$(date +%s.%N)
      STEP_DURATION=$(python3 -c "print(round($STEP_END - $STEP_START, 2))")   # bc is not in the container; python3 always is
      ```
      
      ## Output Summary
      
      When complete, print:
      ```
      === HF Model Acquire Complete ===  [Steps 1-3: ${STEP_DURATION}s]
      Model:  $MODEL_NAME
      ONNX:   models/$MODEL_NAME/model/$MODEL_NAME.onnx ({size} MB)
      Input:  {input_name} {input_shape}
      Output: {output_names} {output_shapes}
      Labels: {num_classes} classes -> models/$MODEL_NAME/config/labels.txt
      Ready for: Steps 4-5 — read references/engine-build.md models/$MODEL_NAME/model/$MODEL_NAME.onnx
      ```
      (`{size}`, `{input_name}`, `{input_shape}`, `{output_names}`, `{output_shapes}`, `{num_classes}` are filled from the ONNX inspection output — all other fields use bash variables.)
      
    • pipeline-run.md 27.2 KB
      
      # DS Run Pipeline -- Steps 6-7
      
      Integrate a TensorRT model into DeepStream with parser, validation, and multi-stream benchmarks.
      
      The model directory is: `$ARGUMENTS`
      
      ## Pre-flight: Extract Variables
      
      ```bash
      [ -z "$ARGUMENTS" ] && { echo "ERROR: No model directory provided. Usage: /deepstream-import-vision-model models/<model_name>/"; exit 1; }
      MODEL_DIR="${ARGUMENTS%/}"
      MODEL_NAME=$(basename "$MODEL_DIR")
      
      # Find ONNX file (exclude _dynamic variants created during export)
      ONNX_FILE=$(ls models/$MODEL_NAME/model/*.onnx 2>/dev/null | grep -v '_dynamic' | head -1)
      [ -z "$ONNX_FILE" ] && { echo "ERROR: No ONNX file found in models/$MODEL_NAME/model/ — run Steps 1-3 first (references/model-acquire.md)"; exit 1; }
      MODEL_FILENAME=$(basename "$ONNX_FILE" .onnx)
      
      # Engine + MAX_BS from the shared resolver (see scripts/model/resolve-engine.sh).
      eval "$(bash .claude/skills/deepstream-import-vision-model/scripts/model/resolve-engine.sh "$MODEL_NAME")" || exit 1
      
      # Read PEAK_GPU_STREAMS from trtexec Step 5b log — fixed filename, no timestamp, no wildcard
      TRTEXEC_LOG="models/$MODEL_NAME/benchmarks/b${MAX_BS}/trtexec_b${MAX_BS}.log"
      [ -f "$TRTEXEC_LOG" ] || { echo "ERROR: trtexec log not found at $TRTEXEC_LOG — run Steps 4-5 first (references/engine-build.md)"; exit 1; }
      QPS_BS_MAX=$(grep -oP 'Throughput:\s*\K[0-9.]+' "$TRTEXEC_LOG" | tail -1)
      read IMGS_PER_SEC PEAK_GPU_STREAMS < <(python3 -c "
      import math
      imgs = float('$QPS_BS_MAX') * $MAX_BS
      print(round(imgs, 2), int(math.floor(imgs / 30)))
      ")
      
      # Read spatial dimensions from ONNX inspection
      INSPECT_OUT=$(python3 .claude/skills/deepstream-import-vision-model/scripts/model/inspect-onnx.py "$ONNX_FILE")
      INPUT_NAME=$(echo "$INSPECT_OUT" | grep -oP 'input_name:\s*\K\S+')
      H=$(echo "$INSPECT_OUT"          | grep -oP 'height:\s*\K[0-9]+')
      W=$(echo "$INSPECT_OUT"          | grep -oP 'width:\s*\K[0-9]+')
      [ -z "$INPUT_NAME" ] && { echo "ERROR: could not parse INPUT_NAME from inspect output"; exit 1; }
      [ -z "$H" ]          && { echo "ERROR: could not parse H — dynamic spatial dims? Set H manually"; exit 1; }
      [ -z "$W" ]          && { echo "ERROR: could not parse W — dynamic spatial dims? Set W manually"; exit 1; }
      
      # Detect installed CUDA version for parser compilation
      CUDA_VER=$(ls /usr/local/ 2>/dev/null | grep -oP '^cuda-\K[0-9]+\.[0-9]+$' | sort -V | tail -1)
      [ -z "$CUDA_VER" ] && CUDA_VER=13.2
      echo "CUDA_VER=$CUDA_VER"
      
      # Count labels
      [ -f "models/$MODEL_NAME/config/labels.txt" ] || { echo "ERROR: labels.txt not found — run Steps 1-3 first (references/model-acquire.md)"; exit 1; }
      NUM_LABELS=$(wc -l < models/$MODEL_NAME/config/labels.txt)
      
      # Parser function suffix: PascalCase of MODEL_NAME, sanitized for C++ identifiers
      # e.g. yolov8n→Yolov8n  rtdetr-l→RtdetrL  grounding-dino-base→GroundingDinoBase
      PARSER_FUNC_SUFFIX=$(python3 -c "
      import re
      parts = re.sub(r'[^a-zA-Z0-9]', ' ', '$MODEL_NAME').split()
      print(''.join(p.capitalize() for p in parts))
      ")
      # Sanitize MODEL_NAME for use in C++ source/library filenames — mirrors PARSER_FUNC_SUFFIX logic.
      # e.g. rtdetr-l → rtdetr_l  grounding-dino-base → grounding_dino_base
      # printf, NOT echo: echo appends a newline that `tr -c` turns into a trailing '_', yielding
      # e.g. `rtdetr_r50vd_` and a custom-lib-path that never matches the Makefile's TARGET_LIB.
      MODEL_NAME_SAFE=$(printf '%s' "$MODEL_NAME" | tr -c 'A-Za-z0-9' '_')
      
      # Video source — default is sample_720p.mp4 (MANDATORY). Never autonomously substitute
      # sample_1080p_h264.mp4 or any other file. DS_VIDEO may only be set when the user explicitly
      # provides a custom video path; it is not a licence to pick a different resolution.
      VIDEO="${DS_VIDEO:-/opt/nvidia/deepstream/deepstream/samples/streams/sample_720p.mp4}"
      [ -f "$VIDEO" ] || {
        echo "ERROR: Video file not found: $VIDEO"
        echo "  Fix 1: Set DS_VIDEO=/path/to/sample_720p.mp4 before running"
        echo "  Fix 2: Install DeepStream samples (DeepStream 9.1 samples): apt-get install deepstream-9.1-samples"
        exit 1
      }
      
      echo "Model:            $MODEL_NAME"
      echo "ONNX:             $ONNX_FILE  (input=$INPUT_NAME, ${H}x${W})"
      echo "Engine:           $ENGINE  (MAX_BS=$MAX_BS)"
      echo "PEAK_GPU_STREAMS: $PEAK_GPU_STREAMS  (floor($IMGS_PER_SEC img/s / 30))"
      echo "Labels:           $NUM_LABELS classes"
      ```
      
      > All subsequent commands use these variables — never hardcoded paths or template placeholders.
      
      ## Step 6: DeepStream Integration
      
      ```bash
      STEP6_START=$(date +%s.%N)
      ```
      
      ### 6a: Inspect Model Output Format
      
      Verify output tensor shapes and value ranges before writing the parser:
      ```bash
      python3 -c "
      import onnxruntime as ort, numpy as np
      sess = ort.InferenceSession('$ONNX_FILE')
      inp = sess.get_inputs()[0]
      out = sess.get_outputs()
      print(f'Input: {inp.name} shape={inp.shape}')
      for o in out: print(f'Output: {o.name} shape={o.shape}')
      dummy = np.random.randn(*[d if isinstance(d,int) else 1 for d in inp.shape]).astype(np.float32)
      result = sess.run(None, {inp.name: dummy})
      for i,r in enumerate(result): print(f'Output[{i}] range: [{r.min():.4f}, {r.max():.4f}]')
      "
      ```
      
      **CRITICAL**: Determine the correct `net-scale-factor` from the output ranges and model family:
      
      | Model expects | net-scale-factor | Notes |
      |---------------|-----------------|-------|
      | 0–255 input (OpenCV Zoo) | `1.0` | No normalization |
      | 0–1 normalized | `0.00392156862745098` (1/255) | Standard |
      | ImageNet normalized | `0.01752` + offsets | Rare in DS |
      
      Wrong scale factor = zero detections. Always verify with KITTI dump (Step 6g) before benchmarks.
      
      ### 6b: Write Custom Bounding Box Parser
      
      Create `models/$MODEL_NAME/parser/nvdsinfer_custombboxparser_${MODEL_NAME_SAFE}.cpp`:
      ```cpp
      extern "C"
      bool NvDsInferParseCustom${PARSER_FUNC_SUFFIX}(
          std::vector<NvDsInferLayerInfo> const &outputLayersInfo,
          NvDsInferNetworkInfo const &networkInfo,
          NvDsInferParseDetectionParams const &detectionParams,
          std::vector<NvDsInferObjectDetectionInfo> &objectList);
      
      CHECK_CUSTOM_PARSE_FUNC_PROTOTYPE(NvDsInferParseCustom${PARSER_FUNC_SUFFIX});
      ```
      
      Parser implementation rules:
      - Include `nvdsinfer_custom_impl.h` and use `NvDsInferObjectDetectionInfo` (classId, left, top, width, height, detectionConfidence)
      - Decode model-specific output format into pixel-space bounding boxes:
        - YOLOX-style `[N, num_anchors, 5+C]`: decode grid offsets, exp(w/h), objectness×class_score
        - SSD-style `[N, num_dets, 6]`: extract class, confidence, normalized → pixel coords
        - YOLO with BatchedNMS: parse keepCount, bboxes, scores, classes from 4 output layers
      - **Clip all coordinates** to `[0, networkInfo.width-1]` and `[0, networkInfo.height-1]`
      - Use `detectionParams.perClassPreclusterThreshold` for confidence filtering
      - **NMS**: Dense heads → `cluster-mode=2` (DeepStream NMS). Fused TRT NMS → `cluster-mode=4`
      - **Sanity check for undecoded output**: if bbox values land in [0, 3], the parser is reading grid-space offsets. Most models need `(raw + grid_offset) * stride` for cx/cy and `exp(raw) * stride` for w/h. Verify raw output ranges with Python/ONNX Runtime before writing the parser.
      - Reference: `/opt/nvidia/deepstream/deepstream/sources/libs/nvdsinfer_customparser/nvdsinfer_custombboxparser.cpp`; Header: `sources/includes/nvdsinfer_custom_impl.h`
      
      #### Model-family parser patterns
      
      - **DETR / Conditional DETR**: outputs `logits [B, num_queries, num_classes+1]` and `pred_boxes [B, num_queries, 4]`. Boxes are `(cx, cy, w, h)` normalized to `[0,1]` — convert to `(left, top, width, height)` in pixels. Use **softmax** (not sigmoid) on logits. **Background class is the LAST index** (e.g., index 91 for a 92-class DETR, despite `config.json` showing `"0": "N/A"`). Skip the background class when iterating. DETR uses Hungarian matching — NMS is not needed; set `cluster-mode=4` (not `nms-iou-threshold=0.0`, which is a legacy key).
      - **OWL-ViT / CLIP-based zero-shot detectors**: outputs `logits [B, num_patches, num_classes]` and `pred_boxes [B, num_patches, 4]`. **Sigmoid** activation (per-class independent scoring, not softmax). Boxes are `(cx, cy, w, h)` normalized `[0,1]`. Use `cluster-mode=2` (NMS with IoU threshold). CLIP preprocessing: `net-scale-factor=0.01459`, `offsets=122.77;116.75;104.09`. Confidence threshold 0.10 works well for general detection; lower to 0.05 for recall-focused tasks.
      - **HF RT-DETR preprocessing quirk**: `RTDetrImageProcessor` may have `do_normalize=false` even though `image_mean`/`image_std` fields exist. When `do_normalize=false`, the model expects `[0,1]` scaled input — set `net-scale-factor=1/255` with no offsets. The ONNX export does NOT bake normalization into the first Conv layer. Verify with ONNX Runtime on a real frame before debugging nvinfer.
      
      #### NGC TAO models — use the built-in parser library
      
      NVIDIA NGC TAO models (trafficcamnet, peoplenet, TrafficCamNet Transformer Lite, etc.) ship with TAO-specific parsers pre-compiled into a system library:
      - **Library path**: `/opt/nvidia/deepstream/deepstream/lib/libnvds_infercustomparser.so` — NOT `libnvds_infercustomparser_tao.so` (even if the NGC YAML config suggests it).
      - Custom parse function names: `NvDsInferParseCustomDDETRTAO`, `NvDsInferParseCustomRTDETRTAO`, etc.
      - **No custom parser compilation needed** — point `custom-lib-path` at the system library and `parse-bbox-func-name` at the TAO function.
      - KITTI dump from `deepstream-app` may emit zero-valued bbox coordinates for DETR/RT-DETR parsers even when detections are correct. Verify visually with JPEG frame extraction instead.
      
      ### `network-type` vs `model-type` — use `network-type=0`
      
      - `model-type` is a legacy/unknown key — nvinfer ignores it with a warning.
      - `network-type=0` (Detector) is required to invoke `parse-bbox-func-name`.
      - `network-type=100` (Other) does NOT invoke the custom bbox parser — it requires `output-tensor-meta=1` for external post-processing.
      - **Symptom of the wrong key**: custom parse function is never called (zero detections, no parser debug output) — check that `network-type=0` is set.
      
      ### 6c: Create Makefile
      
      Write `models/$MODEL_NAME/parser/Makefile` using Python to guarantee literal TAB characters in recipe lines (heredoc in bash can produce spaces, which break make):
      ```bash
      python3 - << EOF
      model = '$MODEL_NAME'
      model_safe = '$MODEL_NAME_SAFE'
      content = (
          "DEEPSTREAM_DIR ?= /opt/nvidia/deepstream/deepstream\n"
          "CUDA_VER ?= 12.8\n"
          "CC := g++\n"
          "CFLAGS := -Wall -std=c++11 -shared -fPIC\n"
          "CFLAGS += -I\$(DEEPSTREAM_DIR)/sources/includes -I/usr/local/cuda-\$(CUDA_VER)/include\n"
          "LIBS := -lnvinfer\n"
          "LFLAGS := -Wl,--start-group \$(LIBS) -Wl,--end-group\n"
          f"SRCFILES := nvdsinfer_custombboxparser_{model_safe}.cpp\n"
          f"TARGET_LIB := libnvdsinfer_{model_safe}_parser.so\n"
          "\n"
          "all: \$(TARGET_LIB)\n"
          "\$(TARGET_LIB): \$(SRCFILES)\n"
          "\t\$(CC) -o \$@ \$^ \$(CFLAGS) \$(LFLAGS)\n"   # TAB required by make
          "clean:\n"
          "\trm -rf \$(TARGET_LIB)\n"                       # TAB required by make
      )
      with open(f'models/{model}/parser/Makefile', 'w') as f:
          f.write(content)
      print(f"Makefile written: models/{model}/parser/Makefile")
      EOF
      ```
      
      ### 6d: Build Parser Library
      
      ```bash
      make -C models/$MODEL_NAME/parser \
        DEEPSTREAM_DIR=/opt/nvidia/deepstream/deepstream \
        CUDA_VER=$CUDA_VER
      
      # Verify the symbol is exported
      nm -D models/$MODEL_NAME/parser/libnvdsinfer_${MODEL_NAME_SAFE}_parser.so | grep NvDsInferParseCustom
      ```
      
      ### 6e: Create nvinfer Config File
      
      ```bash
      cat > models/$MODEL_NAME/config/config_infer_primary_${MODEL_NAME}.txt << EOF
      [property]
      gpu-id=0
      net-scale-factor=0.00392156862745098
      model-color-format=0
      onnx-file=../model/${MODEL_FILENAME}.onnx
      model-engine-file=../benchmarks/engines/${MODEL_FILENAME}_dynamic_b${MAX_BS}.engine
      labelfile-path=labels.txt
      batch-size=1
      network-mode=2
      num-detected-classes=${NUM_LABELS}
      process-mode=1
      interval=0
      gie-unique-id=1
      network-type=0
      custom-lib-path=../parser/libnvdsinfer_${MODEL_NAME_SAFE}_parser.so
      parse-bbox-func-name=NvDsInferParseCustom${PARSER_FUNC_SUFFIX}
      # 2=DeepStream NMS (dense heads: YOLO, SSD). Use 4 if engine has fused NMS output
      cluster-mode=2
      infer-dims=3;${H};${W}
      maintain-aspect-ratio=1
      
      [class-attrs-all]
      topk=200
      nms-iou-threshold=0.45
      pre-cluster-threshold=0.25
      EOF
      ```
      
      > **Path note**: All paths are relative to the `config/` directory where this file lives.
      > `net-scale-factor` defaults to `1/255` — update to `1.0` if the model expects 0–255 input (verify via Step 6a).
      
      Verify label count matches:
      ```bash
      echo "labels.txt: $NUM_LABELS classes -> num-detected-classes=$NUM_LABELS"
      ```
      
      ### 6f: Single-Stream Visual Validation
      
      > **ENCODER RULE:**
      > Primary encoder is `nvv4l2h264enc` (NVENC via V4L2) → `.mp4`. `x264enc` and `openh264enc` are **prohibited**.
      > On systems where `/dev/v4l2-nvenc` is unavailable, the approved fallback is `theoraenc + oggmux`
      > (LGPL; both ship in gst-plugins-base) → `.ogv`. If `theoraenc`/`oggmux` are absent, video creation is skipped.
      > Use `.claude/skills/deepstream-import-vision-model/scripts/deepstream/ds-single-stream.sh` which handles this automatically
      > and emits a `DS_SINGLE_STREAM_MODE=` marker the report parser reads.
      
      **Primary (NVENC available):**
      
      ```bash
      mkdir -p models/$MODEL_NAME/samples
      
      GST_DEBUG=1 gst-launch-1.0 \
        filesrc location=$VIDEO ! \
        qtdemux ! queue leaky=downstream ! h264parse ! queue ! nvv4l2decoder ! queue ! \
        m.sink_0 nvstreammux name=m batch-size=1 width=1280 height=720 ! queue ! \
        nvinfer config-file-path=models/$MODEL_NAME/config/config_infer_primary_${MODEL_NAME}.txt ! queue ! \
        nvvideoconvert ! 'video/x-raw(memory:NVMM),format=RGBA' ! \
        nvdsosd ! nvvideoconvert ! 'video/x-raw(memory:NVMM),format=NV12' ! \
        nvv4l2h264enc ! h264parse ! mp4mux ! \
        filesink location=models/$MODEL_NAME/samples/${MODEL_NAME}_output.mp4 sync=0
      ```
      
      **Fallback (NVENC unavailable — `/dev/v4l2-nvenc` missing, `theoraenc`/`oggmux` present):**
      
      Output extension switches from `.mp4` to `.ogv` (Ogg/Theora container). `theoraenc` consumes planar `I420`, not `NV12`.
      
      ```bash
      GST_DEBUG=1 gst-launch-1.0 \
        filesrc location=$VIDEO ! \
        qtdemux ! queue leaky=downstream ! h264parse ! queue ! nvv4l2decoder ! queue ! \
        m.sink_0 nvstreammux name=m batch-size=1 width=1280 height=720 ! queue ! \
        nvinfer config-file-path=models/$MODEL_NAME/config/config_infer_primary_${MODEL_NAME}.txt ! queue ! \
        nvvideoconvert ! nvdsosd ! nvvideoconvert ! \
        "video/x-raw, format=I420" ! theoraenc quality=48 ! oggmux ! \
        filesink location=models/$MODEL_NAME/samples/${MODEL_NAME}_output.ogv sync=0
      ```
      
      Extract a frame to visually confirm bounding boxes — auto-detect which output file exists:
      
      ```bash
      SAMPLE_OUT=$(ls models/$MODEL_NAME/samples/${MODEL_NAME}_output.{mp4,ogv} 2>/dev/null | head -1)
      
      case "$SAMPLE_OUT" in
        *.mp4)
          gst-launch-1.0 \
            filesrc location="$SAMPLE_OUT" ! \
            qtdemux ! h264parse ! nvv4l2decoder ! videoconvert ! "video/x-raw,format=RGB" ! \
            jpegenc quality=95 ! \
            multifilesink location=models/$MODEL_NAME/samples/frame_%04d.jpg max-files=3
          ;;
        *.ogv)
          gst-launch-1.0 \
            filesrc location="$SAMPLE_OUT" ! \
            oggdemux ! theoradec ! videoconvert ! "video/x-raw,format=RGB" ! \
            jpegenc quality=95 ! \
            multifilesink location=models/$MODEL_NAME/samples/frame_%04d.jpg max-files=3
          ;;
      esac
      ```
      
      If **no detections appear**, the most common cause is wrong `net-scale-factor` — update the config and re-run.
      
      ### 6g: KITTI Dump — Verify Detections Programmatically
      
      Run a KITTI dump to confirm detections exist before multi-stream benchmarks.
      
      > **Note:** `gie-kitti-output-dir` is a `deepstream-app` `[application]`
      > property — it is **not** read by `nvinfer` directly. Appending it to the
      > nvinfer config and running a `gst-launch-1.0 ... nvinfer ...` pipeline
      > silently produces zero KITTI files. Use the `ds-kitti-dump.sh` helper,
      > which wraps `deepstream-app` with the correct `[application]` section.
      
      ```bash
      mkdir -p models/$MODEL_NAME/samples/kitti_output
      
      bash .claude/skills/deepstream-import-vision-model/scripts/deepstream/ds-kitti-dump.sh \
        models/$MODEL_NAME/config/config_infer_primary_${MODEL_NAME}.txt \
        models/$MODEL_NAME/samples/kitti_output \
        100 \
        "$VIDEO"
      
      # Summarise detection results
      KITTI_FILES=$(ls models/$MODEL_NAME/samples/kitti_output/*.txt 2>/dev/null | wc -l)
      echo "KITTI frames written: $KITTI_FILES"
      echo "Top detected classes:"
      cat models/$MODEL_NAME/samples/kitti_output/*.txt 2>/dev/null \
        | awk '{print $1}' | sort | uniq -c | sort -rn | head -10
      ```
      
      **Validation gate**: If `KITTI_FILES == 0` or all files are empty, detections are broken. Do NOT proceed to Step 7.
      
      ```bash
      # MANDATORY hard stop — do not comment out or remove this check
      if [ "$KITTI_FILES" -eq 0 ]; then
        echo "ERROR: KITTI validation FAILED — zero detection files written."
        echo "Fix net-scale-factor, parser output format, or config before retrying."
        echo "Do NOT proceed to Step 7 benchmarks with broken detections."
        exit 1
      fi
      FRAMES_WITH_DETECTIONS=$(grep -rl '.' models/$MODEL_NAME/samples/kitti_output/ 2>/dev/null | wc -l)
      DETECTION_RATE=$(python3 -c "print(round($FRAMES_WITH_DETECTIONS/$KITTI_FILES*100,1))")
      echo "Detection rate: $FRAMES_WITH_DETECTIONS / $KITTI_FILES frames = ${DETECTION_RATE}%"
      if python3 -c "exit(0 if $FRAMES_WITH_DETECTIONS/$KITTI_FILES >= 0.9 else 1)"; then
        echo "KITTI validation PASSED (>= 90% frames with detections)"
      else
        echo "ERROR: Detection rate ${DETECTION_RATE}% < 90% threshold. Fix parser before proceeding."
        exit 1
      fi
      ```
      
      ```bash
      STEP6_END=$(date +%s.%N)
      STEP6_DURATION=$(python3 -c "print(round($STEP6_END - $STEP6_START, 2))")   # bc is not in the container; python3 always is
      echo "[Step 6] completed in ${STEP6_DURATION}s"
      ```
      
      ### DeepStream Troubleshooting
      
      | Symptom | Fix |
      |---------|-----|
      | Zero detections | Wrong `net-scale-factor` — check model family table in Step 6a |
      | Engine rebuilds every run | `model-engine-file` path wrong — verify relative path from `config/` |
      | Parser crash | Output tensor shape mismatch — re-check Step 6a output shapes |
      | Wrong bounding box positions | Grid/stride decoding mismatch — verify model architecture docs |
      | `"layers num: 0"` | Harmless for dynamic-shape engines — do not debug |
      | deepstream-app segfaults | Use `gst-launch-1.0` instead (transformer models) |
      
      ## Step 7: Multi-Stream DeepStream Benchmark
      
      ### 7b: Create DS Benchmark Config
      
      Create one nvinfer config for all DS benchmark runs. `batch-size` is overridden at runtime via the nvinfer GStreamer element property.
      
      This config is **derived from the Step 6e config** rather than re-authored — every `[property]` and
      `[class-attrs-all]` value is identical. Only the relative paths and `batch-size` differ, because this
      copy lives one directory deeper (`benchmarks/ds/` instead of `config/`). Deriving it keeps the two
      from drifting apart:
      
      ```bash
      mkdir -p models/$MODEL_NAME/benchmarks/ds
      
      sed -e 's#^onnx-file=\.\./#onnx-file=../../#' \
          -e 's#^model-engine-file=\.\./benchmarks/engines/#model-engine-file=../engines/#' \
          -e 's#^labelfile-path=labels\.txt#labelfile-path=../../config/labels.txt#' \
          -e "s#^batch-size=1\$#batch-size=${MAX_BS}#" \
          -e 's#^custom-lib-path=\.\./#custom-lib-path=../../#' \
          models/$MODEL_NAME/config/config_infer_primary_${MODEL_NAME}.txt \
        > models/$MODEL_NAME/benchmarks/ds/config_infer_ds_${MODEL_NAME}.txt
      ```
      
      The five derived keys, for reference:
      
      | Key | Step 6e (`config/`) | Step 7b (`benchmarks/ds/`) |
      |---|---|---|
      | `onnx-file` | `../model/…` | `../../model/…` |
      | `model-engine-file` | `../benchmarks/engines/…` | `../engines/…` |
      | `labelfile-path` | `labels.txt` | `../../config/labels.txt` |
      | `batch-size` | `1` | `${MAX_BS}` |
      | `custom-lib-path` | `../parser/…` | `../../parser/…` |
      
      > **Path note**: Paths are relative to `benchmarks/ds/` where this config lives.
      
      ### Queue Placement Rules (MANDATORY)
      
      Every pipeline stage must be separated by `queue` elements. Use `leaky=downstream` after `qtdemux` to drop excess frames under GPU saturation; all other queues use no leaky setting (threading only). Always set `batched-push-timeout=-1` on `nvstreammux`. **Never include** `nvmultistreamtiler`, `nvdsosd`, or extra `nvvideoconvert` in benchmark runs — only use for single-stream visual validation (Step 6f).
      
      ### 7c: Two-Run DS Benchmark
      
      Only **2 DS pipeline runs** characterise DS overhead vs trtexec.
      
      Both runs go through `deepstream-app` with `[application] enable-perf-measurement=1` (wrapped by `.claude/skills/deepstream-import-vision-model/scripts/deepstream/ds-perf-run.sh`). FPS is parsed from the canonical `**PERF:` lines DeepStream emits at the configured measurement interval. This replaces the older `gst-launch-1.0 ... ! fpsdisplaysink` path so the runtime no longer depends on `gstreamer1.0-plugins-bad`.
      
      > **PERF line format**: `**PERF: <fps_run> (<fps_avg>)` — one float per active source. The helper script averages the per-stream instantaneous FPS across the last few measurement windows; the parser below mirrors that contract.
      
      **DS Run 1 — Calibration at PEAK_GPU_STREAMS streams:**
      
      > **CRITICAL**: Use `$PEAK_GPU_STREAMS` directly. Do NOT pre-apply any efficiency discount (no ×0.6, ×0.7, etc.). Run 1 *measures* the real overhead — do not guess it.
      
      > Log filenames are **fixed** — no timestamp variation. Always `ds_s${N}_run1.log` and `ds_s${N}_run2.log` in `benchmarks/ds/`. The report-generation skill reads these exact paths.
      
      ```bash
      # Hard constraint: num_streams <= engine max batch size — always
      N=$(python3 -c "print(min($PEAK_GPU_STREAMS, $MAX_BS))")
      LOG_RUN1="models/$MODEL_NAME/benchmarks/ds/ds_s${N}_run1.log"
      
      STEP7_RUN1_START=$(date +%s.%N)
      bash .claude/skills/deepstream-import-vision-model/scripts/deepstream/ds-perf-run.sh \
        models/$MODEL_NAME/benchmarks/ds/config_infer_ds_${MODEL_NAME}.txt \
        "$N" \
        "$LOG_RUN1" \
        "$VIDEO"
      
      FPS_RUN1=$(grep -oP '\*\*PERF:\s*\K[0-9.]+' "$LOG_RUN1" | tail -10 | python3 -c "
      import sys; vals=[float(l) for l in sys.stdin if l.strip()]; print(round(sum(vals)/len(vals),2) if vals else 0)")
      python3 -c "exit(0 if float('$FPS_RUN1') > 0 else 1)" || \
        { echo "ERROR: FPS parsing failed for Run 1 — check $LOG_RUN1"; exit 1; }
      
      TOTAL_FPS_RUN1=$(python3 -c "print(round(float('$FPS_RUN1') * $N, 2))")
      RT_STREAMS=$(python3 -c "import math; print(min(int(math.floor(float('$TOTAL_FPS_RUN1') / 30)), $MAX_BS))")
      echo "DS Run 1: $N streams | FPS/stream=$FPS_RUN1 | total=$TOTAL_FPS_RUN1 img/s | RT_STREAMS=$RT_STREAMS"
      STEP7_RUN1_END=$(date +%s.%N)
      STEP7_RUN1_DURATION=$(python3 -c "print(round($STEP7_RUN1_END - $STEP7_RUN1_START, 2))")   # bc is not in the container; python3 always is
      echo "[Step 7 Run 1] completed in ${STEP7_RUN1_DURATION}s"
      ```
      
      **DS Run 2 — Validation at RT_STREAMS:**
      ```bash
      N=$RT_STREAMS
      LOG_RUN2="models/$MODEL_NAME/benchmarks/ds/ds_s${N}_run2.log"
      
      STEP7_RUN2_START=$(date +%s.%N)
      bash .claude/skills/deepstream-import-vision-model/scripts/deepstream/ds-perf-run.sh \
        models/$MODEL_NAME/benchmarks/ds/config_infer_ds_${MODEL_NAME}.txt \
        "$N" \
        "$LOG_RUN2" \
        "$VIDEO"
      
      FPS_RUN2=$(grep -oP '\*\*PERF:\s*\K[0-9.]+' "$LOG_RUN2" | tail -10 | python3 -c "
      import sys; vals=[float(l) for l in sys.stdin if l.strip()]; print(round(sum(vals)/len(vals),2) if vals else 0)")
      python3 -c "exit(0 if float('$FPS_RUN2') > 0 else 1)" || \
        { echo "ERROR: FPS parsing failed for Run 2 — check $LOG_RUN2"; exit 1; }
      
      TOTAL_FPS_RUN2=$(python3 -c "print(round(float('$FPS_RUN2') * $N, 2))")
      RT_CONFIRMED=$(python3 -c "print('YES' if float('$FPS_RUN2') >= 30 else 'NO')")
      echo "DS Run 2: $N streams | FPS/stream=$FPS_RUN2 | total=$TOTAL_FPS_RUN2 img/s | Real-time: $RT_CONFIRMED"
      STEP7_RUN2_END=$(date +%s.%N)
      STEP7_RUN2_DURATION=$(python3 -c "print(round($STEP7_RUN2_END - $STEP7_RUN2_START, 2))")   # bc is not in the container; python3 always is
      echo "[Step 7 Run 2] completed in ${STEP7_RUN2_DURATION}s"
      ```
      
      > **NVDEC saturation on fast nano models**: very fast models (YOLO-nano family, etc.) can saturate NVDEC before GPU. Symptom: DS aggregate FPS plateaus at the same value regardless of stream count (e.g., 6,976 at 128 streams, 7,060 at 200 streams). In this case, `PEAK_GPU_STREAMS` from trtexec is an overestimate — Run 1 at that count will show fps/stream well below 30. The `RT_STREAMS = floor(TOTAL_FPS_RUN1 / 30)` formula above produces the correct NVDEC-limited ceiling. Do not pre-apply an efficiency factor to `PEAK_GPU_STREAMS` to compensate — the 2-run method measures overhead, it does not guess it.
      
      **If Run 2 is still not real-time** (FPS/stream < 30): **converge to the true ceiling — do NOT halve.**
      
      > **Why not halve:** halving overshoots badly. If Run 2 at 38 streams measures 29.6 fps/stream
      > (total 1124.8 img/s — only 1.3% under real-time), the real ceiling is ~37 streams, not 19.
      > Halving to 19 discards ~half the GPU's real capacity and reports a misleadingly low number.
      > Instead, recompute the target from Run 2's *measured* total throughput
      > (`floor(TOTAL_FPS_RUN2 / 30)`), which is strictly below the count that just failed, then
      > step down by 1 until real-time. This lands on the true ceiling in 1–2 short retries.
      
      ```bash
      # Bounded convergence: recompute from measured throughput, then decrement by 1.
      RETRIES=0
      while [ "$RT_CONFIRMED" = "NO" ] && [ "$RETRIES" -lt 5 ]; do
        # First correction: jump to floor(measured_total/30); afterwards step down by 1.
        NEXT=$(python3 -c "import math; print(int(math.floor(float('$TOTAL_FPS_RUN2') / 30)))")
        [ "$NEXT" -ge "$N" ] && NEXT=$((N - 1))   # guarantee strict progress below the failing count
        RT_STREAMS=$(python3 -c "print(max(1, $NEXT))")
        N=$RT_STREAMS
        echo "Run 2 not real-time (fps/stream=$FPS_RUN2) — converging to $N streams"
        LOG_RUN2="models/$MODEL_NAME/benchmarks/ds/ds_s${N}_run2.log"
        bash .claude/skills/deepstream-import-vision-model/scripts/deepstream/ds-perf-run.sh \
          models/$MODEL_NAME/benchmarks/ds/config_infer_ds_${MODEL_NAME}.txt \
          "$N" \
          "$LOG_RUN2" \
          "$VIDEO"
        FPS_RUN2=$(grep -oP '\*\*PERF:\s*\K[0-9.]+' "$LOG_RUN2" | tail -10 | python3 -c "
      import sys; vals=[float(l) for l in sys.stdin if l.strip()]; print(round(sum(vals)/len(vals),2) if vals else 0)")
        TOTAL_FPS_RUN2=$(python3 -c "print(round(float('$FPS_RUN2') * $N, 2))")
        RT_CONFIRMED=$(python3 -c "print('YES' if float('$FPS_RUN2') >= 30 else 'NO')")
        RETRIES=$((RETRIES + 1))
        echo "Retry $RETRIES: $N streams | FPS/stream=$FPS_RUN2 | Real-time: $RT_CONFIRMED"
        [ "$N" -le 1 ] && break
      done
      ```
      
      **CONSTRAINT**: `num_streams <= engine_max_bs` always. Already enforced above via `min(RT_STREAMS, MAX_BS)`.
      
      ```bash
      TRTEXEC_QPS=$(grep -oP 'Throughput:\s*\K[0-9.]+' "$TRTEXEC_LOG" | tail -1)
      TRTEXEC_IMGS=$(python3 -c "print(round(float('$TRTEXEC_QPS') * $MAX_BS, 2))")
      DS_EFF_RUN1=$(python3 -c "print(round(float('$TOTAL_FPS_RUN1') / float('$TRTEXEC_IMGS') * 100, 1))")
      DS_EFF_RUN2=$(python3 -c "print(round(float('$TOTAL_FPS_RUN2') / float('$TRTEXEC_IMGS') * 100, 1))")
      ```
      
      ## Timing and Output Summary
      
      ```bash
      TOTAL_67_DURATION=$(python3 -c "print(round($STEP6_DURATION + $STEP7_RUN1_DURATION + $STEP7_RUN2_DURATION, 2))")   # bc is not in the container; python3 always is
      ```
      
      When complete, print:
      ```
      === DeepStream Integration Complete ===
      Model: $MODEL_NAME | Engine: $ENGINE
      trtexec: $TRTEXEC_IMGS img/s @ BS=$MAX_BS
      DS Run 1 (PEAK): $PEAK_GPU_STREAMS streams | $FPS_RUN1 fps/s | eff $DS_EFF_RUN1%
      DS Run 2 (RT):   $RT_STREAMS streams | $FPS_RUN2 fps/s | RT: $RT_CONFIRMED | eff $DS_EFF_RUN2%
      Timing: Step6=${STEP6_DURATION}s Run1=${STEP7_RUN1_DURATION}s Run2=${STEP7_RUN2_DURATION}s Total=${TOTAL_67_DURATION}s
      Ready for: Step 8 — read references/report-generation.md models/$MODEL_NAME/
      ```
      
    • README.md 774 B
      # deepstream-import-vision-model — Reference Documents
      
      Detailed phase guides for the `deepstream-import-vision-model` skill. Read the relevant file before starting each pipeline phase.
      
      | Document | Pipeline Steps | When to read |
      |---|---|---|
      | [model-acquire.md](model-acquire.md) | Steps 1–3 | Downloading from HuggingFace or NGC; ONNX vs SafeTensors detection and export |
      | [engine-build.md](engine-build.md) | Steps 4–5 | TensorRT dynamic engine build; `trtexec` BS=1 and BS=MAX\_BS benchmarks |
      | [pipeline-run.md](pipeline-run.md) | Steps 6–7 | Custom `nvinfer` bbox parser; single-stream validation; multi-stream benchmark sweep |
      | [report-generation.md](report-generation.md) | Step 8 | 5 benchmark charts; Markdown → HTML → PDF report generation |
      
    • report-generation.md 26.8 KB
      
      # NV Import Vision Model Report -- Step 8
      
      Generate benchmark report with charts, HTML, and PDF from completed benchmarks.
      
      The model directory is: `$ARGUMENTS`
      
      > ## ⛔ STRICT HTML+PDF RULE — NO EXCEPTIONS, NO DEVIATIONS
      >
      > **HTML and PDF MUST be generated via the canonical pipeline script. Do NOT write your own HTML generator.**
      >
      > **The ONLY permitted way to generate the HTML + PDF:**
      > ```bash
      > python3 .claude/skills/deepstream-import-vision-model/scripts/report/md-to-html-pdf.py \
      >   models/$MODEL_NAME/reports/benchmark_report.md \
      >   .claude/skills/deepstream-import-vision-model/scripts/report/report-style.css \
      >   models/$MODEL_NAME/reports/ \
      >   $MODEL_NAME
      > ```
      > This produces:
      > - `models/$MODEL_NAME/reports/benchmark_report.html` — styled with report_style.css, charts embedded as base64
      > - `models/$MODEL_NAME/reports/benchmark_report_${MODEL_NAME}.pdf` — via wkhtmltopdf
      >
      > **FORBIDDEN — never do any of these:**
      > - Write your own `generate_html.py` or any custom markdown-to-HTML converter script
      > - Call `wkhtmltopdf` directly — use `md-to-html-pdf.py` which already calls it correctly
      > - Use `md-to-pdf.sh` — GFM+Mermaid design doc tool only, wrong CSS
      > - Use `pandoc`, `pdflatex`, or any other converter
      >
      > The `report_style.css` provides the ONLY correct CSS (dark navy headers #283593, alternating rows #e8eaf6, dark code blocks #263238). Any other CSS produces wrong-looking reports.
      
      ## 8a: Report Structure — 12 Mandatory Sections
      
      The report must contain exactly these 12 sections in order:
      
      1. **Model Configuration** — model name, source (HF repo / NGC), architecture, ONNX source, input/output shapes, classes, custom parser name, cluster mode, precision, engine profile
      2. **System Configuration** — GPU (name + VRAM), Driver, CUDA, TensorRT, DeepStream, OS, Python, PyTorch, ONNX versions
      3. **Preprocessing** — net-scale-factor, offsets, color format, normalization details (with reference to the preprocessing table in deepstream-import-vision-model/SKILL.md)
      4. **Engine Build Summary** — source format, conversion path, engine filename (with max_bs postfix), engine size (MB), FP16 flag, builder_optimization_level if non-default, timing cache path
      5. **trtexec Results** — two runs (BS=1 and BS=MAX_BS) with: QPS, Images/s, GPU Compute mean/P99 (ms). Do NOT include H2D/D2H latency or Host Latency. Show PEAK_GPU_STREAMS derivation:
         ```
         PEAK_GPU_STREAMS = floor(QPS_at_MAX_BS × MAX_BS / 30)
                          = floor(imgs_per_sec_at_MAX_BS / 30)
         ```
      6. **PEAK_GPU_STREAMS Derivation** — explicit calculation block showing formula, inputs, and result. If a second engine was built, show both PEAK_GPU_STREAMS computations.
      7. **Single-Stream Validation** — KITTI frame count, frames with detections, top-10 detected classes (from KITTI dump), validation result (PASS/FAIL)
      8. **DeepStream Benchmark Results** — two runs:
         - **DS Run 1 (Calibration at PEAK_GPU_STREAMS)**: streams, batch, FPS/stream, total img/s, real-time (YES/NO)
         - **DS Run 2 (Validation at RT_STREAMS)**: streams, batch, FPS/stream, total img/s, real-time (YES)
      9. **trtexec vs DeepStream Comparison** — 3-column table: trtexec | DS Run 1 | DS Run 2, rows: engine, batch/streams, total imgs/s, FPS/stream, real-time ≥30fps, DS Efficiency %
      10. **Efficiency Analysis** — efficiency formula, Run 1 and Run 2 percentages, breakdown of the gap (NVDEC + mux + GStreamer overhead), GPU-bound vs pipeline-bound verdict
      11. **Pipeline Timing** — per-step wall-clock duration and total:
          | Step | Description | Duration |
          |------|-------------|----------|
          | 1-3  | HF Model Acquire (download + inspect ONNX) | {time}s |
          | 4    | Engine build | {time}s |
          | 5    | trtexec BS=1 + BS=MAX_BS | {time}s |
          | 6    | Parser + config + visual validation + KITTI | {time}s |
          | 7 Run 1 | DS Calibration (PEAK_GPU_STREAMS streams) | {time}s |
          | 7 Run 2 | DS Validation (RT_STREAMS streams) | {time}s |
          | 8    | Report generation | {time}s |
          | **Total** | **End-to-end** | **{total}s** |
      12. **Reference Commands** — exact reproducible commands:
          - trtexec engine build (full command with all flags and paths)
          - trtexec benchmark BS=1 and BS=MAX_BS
          - DeepStream single-stream validation (`gst-launch-1.0` with filesink + OSD)
          - DeepStream multi-stream benchmark (`deepstream-app` with `enable-perf-measurement=1` via `ds-perf-run.sh`, PEAK_GPU_STREAMS and RT_STREAMS variants)
          - nvinfer config key fields (as an ini code block)
          - Custom parser build command (`make` with DEEPSTREAM_DIR and CUDA_VER)
          - Use actual absolute paths from the model directory, never placeholders
      
      ## Pre-flight: Extract Variables from Benchmark Logs
      
      Before generating any output, derive all variables by reading completed benchmark files. These variables are used by every section below.
      
      ```bash
      STEP8_START=$(date +%s.%N)
      
      MODEL_DIR="${ARGUMENTS%/}"
      MODEL_NAME=$(basename "$MODEL_DIR")
      
      # Engine + MAX_BS + MODEL_FILENAME from the shared resolver (scripts/model/resolve-engine.sh).
      eval "$(bash .claude/skills/deepstream-import-vision-model/scripts/model/resolve-engine.sh "$MODEL_NAME")" || exit 1
      
      # Extract input name and spatial dims from ONNX (needed for reference commands in the report)
      ONNX_FILE=$(ls models/$MODEL_NAME/model/*.onnx 2>/dev/null | grep -v '_dynamic' | head -1)
      if [ -n "$ONNX_FILE" ]; then
        INSPECT_OUT=$(python3 .claude/skills/deepstream-import-vision-model/scripts/model/inspect-onnx.py "$ONNX_FILE" 2>/dev/null)
        INPUT_NAME=$(echo "$INSPECT_OUT" | grep -oP 'input_name:\s*\K\S+')
        H=$(echo "$INSPECT_OUT" | grep -oP 'height:\s*\K[0-9]+')
        W=$(echo "$INSPECT_OUT" | grep -oP 'width:\s*\K[0-9]+')
      fi
      INPUT_NAME=${INPUT_NAME:-"images"}  # fallback
      H=${H:-"640"}; W=${W:-"640"}       # fallback — update if model uses different resolution
      
      # Parse trtexec BS=1 log — fixed filename trtexec_b1.log (no timestamp, no wildcard needed)
      TRTEXEC_LOG_BS1="models/$MODEL_NAME/benchmarks/b1/trtexec_b1.log"
      [ -f "$TRTEXEC_LOG_BS1" ] || { echo "ERROR: $TRTEXEC_LOG_BS1 not found — run Steps 4-5 first (references/engine-build.md)"; exit 1; }
      QPS_BS1=$(grep -oP 'Throughput:\s*\K[0-9.]+' "$TRTEXEC_LOG_BS1" | tail -1)
      GPU_MEAN_BS1=$(grep -oP 'GPU Compute Time:.*mean = \K[0-9.]+' "$TRTEXEC_LOG_BS1" | tail -1)
      
      # Parse trtexec BS=MAX_BS log — fixed filename trtexec_b${MAX_BS}.log
      TRTEXEC_LOG_BSMAX="models/$MODEL_NAME/benchmarks/b${MAX_BS}/trtexec_b${MAX_BS}.log"
      [ -f "$TRTEXEC_LOG_BSMAX" ] || { echo "ERROR: $TRTEXEC_LOG_BSMAX not found — run Steps 4-5 first (references/engine-build.md)"; exit 1; }
      QPS_BS_MAX=$(grep -oP 'Throughput:\s*\K[0-9.]+' "$TRTEXEC_LOG_BSMAX" | tail -1)
      GPU_MEAN_BS_MAX=$(grep -oP 'GPU Compute Time:.*mean = \K[0-9.]+' "$TRTEXEC_LOG_BSMAX" | tail -1)
      GPU_P99_BS_MAX=$(grep -oP 'GPU Compute Time:.*percentile\(99%\) = \K[0-9.]+' "$TRTEXEC_LOG_BSMAX" | tail -1)
      [ -z "$QPS_BS_MAX" ] && { echo "ERROR: Could not parse Throughput from $TRTEXEC_LOG_BSMAX — log may be empty or malformed"; exit 1; }
      [ -z "$MAX_BS" ] && { echo "ERROR: Could not parse batch size from engine filename: $ENGINE"; exit 1; }
      
      read IMGS_PER_SEC PEAK_GPU_STREAMS < <(python3 -c "
      import math
      imgs = float('$QPS_BS_MAX') * $MAX_BS
      print(round(imgs, 2), int(math.floor(imgs / 30)))
      ")
      
      # Parse DeepStream Run 1 and Run 2 FPS from logs written by pipeline-run
      # Fixed filename pattern: benchmarks/ds/ds_s{N}_run1.log and ds_s{N}_run2.log
      # Use glob to find them (N varies per model) then extract N from filename
      DS_LOG_RUN1=$(ls models/$MODEL_NAME/benchmarks/ds/ds_s*_run1.log 2>/dev/null | head -1)
      # Step 7's convergence loop can leave SEVERAL run2 logs (e.g. s17 -> s11 failed -> s9 passed).
      # Lexicographic `head -1` picks ds_s11 over ds_s9, reporting the NON-real-time run as the
      # validated result. Convergence steps downward, so the converged log is the newest — pick by mtime.
      # (`sort -V | tail -1` is NOT correct here: it re-selects the highest N, i.e. the failing run.)
      DS_LOG_RUN2=$(ls -t models/$MODEL_NAME/benchmarks/ds/ds_s*_run2.log 2>/dev/null | head -1)
      [ -z "$DS_LOG_RUN1" ] && { echo "ERROR: No DS Run 1 log found at benchmarks/ds/ds_s*_run1.log — run Steps 6-7 first (references/pipeline-run.md)"; exit 1; }
      [ -z "$DS_LOG_RUN2" ] && { echo "ERROR: No DS Run 2 log found at benchmarks/ds/ds_s*_run2.log — run Steps 6-7 first (references/pipeline-run.md)"; exit 1; }
      
      N_RUN1=$(basename "$DS_LOG_RUN1" | grep -oP 'ds_s\K[0-9]+(?=_run1)')
      N_RUN2=$(basename "$DS_LOG_RUN2" | grep -oP 'ds_s\K[0-9]+(?=_run2)')
      [[ "$N_RUN1" =~ ^[0-9]+$ ]] || { echo "ERROR: Could not parse stream count from $(basename "$DS_LOG_RUN1") — expected filename pattern ds_s<N>_run1.log"; exit 1; }
      [[ "$N_RUN2" =~ ^[0-9]+$ ]] || { echo "ERROR: Could not parse stream count from $(basename "$DS_LOG_RUN2") — expected filename pattern ds_s<N>_run2.log"; exit 1; }
      RT_STREAMS=$N_RUN2
      
      # deepstream-app **PERF: format is `**PERF: fps_run0 (fps_avg0)  fps_run1 (fps_avg1)  ...`
      # Capture stream-0 instantaneous FPS (\K after `**PERF:`) — 1 value per line — so
      # tail -10 always covers exactly 10 measurement windows regardless of stream count.
      # Multiply by stream count for total throughput.
      FPS_RAW_RUN1=$(grep -oP '\*\*PERF:\s*\K[0-9.]+' "$DS_LOG_RUN1" | tail -10 | python3 -c "
      import sys; vals=[float(l) for l in sys.stdin if l.strip()]; print(round(sum(vals)/len(vals),2) if vals else 0)")
      FPS_RAW_RUN2=$(grep -oP '\*\*PERF:\s*\K[0-9.]+' "$DS_LOG_RUN2" | tail -10 | python3 -c "
      import sys; vals=[float(l) for l in sys.stdin if l.strip()]; print(round(sum(vals)/len(vals),2) if vals else 0)")
      
      TOTAL_FPS_RUN1=$(python3 -c "print(round(float('$FPS_RAW_RUN1') * $N_RUN1, 2))")
      TOTAL_FPS_RUN2=$(python3 -c "print(round(float('$FPS_RAW_RUN2') * $N_RUN2, 2))")
      
      echo "=== Report Variables ==="
      echo "MODEL_NAME=$MODEL_NAME  MAX_BS=$MAX_BS"
      echo "BS=1:       QPS=$QPS_BS1  GPU mean=${GPU_MEAN_BS1}ms"
      echo "BS=$MAX_BS: QPS=$QPS_BS_MAX  imgs/s=$IMGS_PER_SEC  PEAK_GPU_STREAMS=$PEAK_GPU_STREAMS"
      echo "DS Run 1:   FPS/stream=$FPS_RAW_RUN1  streams=$N_RUN1  total=$TOTAL_FPS_RUN1 img/s"
      echo "DS Run 2:   FPS/stream=$FPS_RAW_RUN2  streams=$N_RUN2  total=$TOTAL_FPS_RUN2 img/s  RT_STREAMS=$RT_STREAMS"
      ```
      
      Then immediately write `benchmark_data.json` before generating charts (so charts can load it if needed):
      
      ```bash
      mkdir -p models/$MODEL_NAME/reports
      python3 << 'EOF'
      import json, os
      
      def to_num(v, cast=float):
          """Return cast(v) or None if v is empty/invalid — prevents malformed JSON."""
          try:
              return cast(v) if v and str(v).strip() else None
          except (ValueError, TypeError):
              return None
      
      data = {
          "model_name":       os.environ.get("MODEL_NAME", ""),
          "engine":           os.environ.get("ENGINE", ""),
          "max_bs":           to_num(os.environ.get("MAX_BS"), int),
          "trtexec": {
              "bs1":   {
                  "qps":         to_num(os.environ.get("QPS_BS1")),
                  "gpu_mean_ms": to_num(os.environ.get("GPU_MEAN_BS1"))
              },
              "bsmax": {
                  "qps":         to_num(os.environ.get("QPS_BS_MAX")),
                  "gpu_mean_ms": to_num(os.environ.get("GPU_MEAN_BS_MAX")),
                  "p99_ms":      to_num(os.environ.get("GPU_P99_BS_MAX")),
                  "imgs_per_sec": to_num(os.environ.get("IMGS_PER_SEC"))
              }
          },
          "peak_gpu_streams": to_num(os.environ.get("PEAK_GPU_STREAMS"), int),
          "deepstream": {
              "run1": {
                  "streams":        to_num(os.environ.get("N_RUN1"), int),
                  "total_fps":      to_num(os.environ.get("TOTAL_FPS_RUN1")),
                  "fps_per_stream": to_num(os.environ.get("FPS_RAW_RUN1"))
              },
              "run2": {
                  "streams":        to_num(os.environ.get("N_RUN2"), int),
                  "total_fps":      to_num(os.environ.get("TOTAL_FPS_RUN2")),
                  "fps_per_stream": to_num(os.environ.get("FPS_RAW_RUN2"))
              }
          }
      }
      out_path = os.path.join("models", os.environ.get("MODEL_NAME", "unknown"),
                              "reports", "benchmark_data.json")
      with open(out_path, "w") as f:
          json.dump(data, f, indent=2)
      print("benchmark_data.json written")
      EOF
      ```
      > `<< 'EOF'` (quoted) prevents bash expansion — Python reads all variables via `os.environ.get()`, applies `to_num()` for safe numeric conversion (returns `None` instead of producing malformed JSON when a variable is unset), then uses `json.dump` to guarantee valid output.
      
      ## 8c-1: Chart Generation (MANDATORY)
      
      All Python scripts in this step run inside the **shared venv** at `build/.venv_optimum` (which holds `matplotlib`, `numpy`, `markdown`, and `onnxruntime`). Activate it once before running any report scripts:
      
      ```bash
      source build/.venv_optimum/bin/activate
      ```
      
      Generate exactly **5 charts** using `matplotlib` in `models/{model_name}/reports/charts/`. Use the script at `.claude/skills/deepstream-import-vision-model/scripts/report/generate-benchmark-charts.py` or generate manually. Chart names are fixed — do not rename them.
      
      | Filename | Content | Chart type |
      |----------|---------|------------|
      | `chart_trtexec_bs1_vs_bsmax.png` | Bar chart: QPS at BS=1 vs BS=MAX_BS (side by side) | Grouped bar |
      | `chart_trtexec_throughput.png` | GPU-only images/sec at MAX_BS, with PEAK_GPU_STREAMS annotation (dashed line at y=PEAK_GPU_STREAMS×30) | Single bar or line |
      | `chart_ds_streams_vs_fps.png` | Line chart: X=stream count (PEAK_GPU_STREAMS, RT_STREAMS), Y=FPS/stream. Red dashed line at 30fps threshold. | Line + markers |
      | `chart_trt_vs_ds.png` | Grouped bars: trtexec total imgs/s \| DS Run 1 total imgs/s \| DS Run 2 total imgs/s | Grouped bar |
      | `chart_efficiency.png` | DS efficiency %: 2 bars (Run 1 efficiency, Run 2 efficiency), dashed line at 100% | Bar |
      
      Do NOT generate H2D/D2H transfer overhead charts.
      
      Chart style requirements:
      - Figure size: `figsize=(10, 6)`, DPI: 150
      - Title: two-line format via `two_line_title(model_name, subtitle)` — model name on line 1, chart description on line 2 (prevents long titles from clipping outside figure bounds)
      - Axis labels: 13px; Bar value labels: bold, 12-13px, positioned above bars
      - Grid: `axis='y', alpha=0.3`; `plt.tight_layout()` before save
      - Use `matplotlib.use('Agg')` (no display needed)
      
      ## 8c-1b: Markdown Report (MANDATORY)
      
      Generate `benchmark_report.md` before the HTML. This file must contain all 12 sections filled with actual values — no placeholders allowed.
      
      First, gather system info not already captured in pre-flight:
      
      ```bash
      GPU_INFO=$(nvidia-smi --query-gpu=name,memory.total --format=csv,noheader | head -1)
      GPU_NAME=$(echo "$GPU_INFO" | cut -d, -f1 | xargs)
      GPU_VRAM=$(echo "$GPU_INFO" | cut -d, -f2 | xargs)
      DRIVER_VER=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader | head -1 | xargs)
      CUDA_VER=$(nvcc --version 2>/dev/null | grep -oP 'release \K[0-9.]+' || echo "N/A")
      TRT_VER=$(trtexec 2>&1 | head -3 | grep -oP 'TensorRT v\K[0-9.]+' || echo "N/A")
      DS_VER=$(deepstream-app --version-all 2>/dev/null | grep -oP 'DeepStreamSDK \K[0-9.]+' || echo "N/A")
      ENGINE_SIZE_MB=$(du -m "$ENGINE" | cut -f1)
      IMGS_PER_SEC_BS1=$(python3 -c "print(round(float('$QPS_BS1') * 1, 2))")
      GPU_P99_BS1=$(grep -oP 'GPU Compute Time:.*percentile\(99%\) = \K[0-9.]+' "$TRTEXEC_LOG_BS1" | tail -1)
      GPU_P99_BS1=${GPU_P99_BS1:-"N/A"}  # fallback if log too short to have P99
      EFFICIENCY_RUN1=$(python3 -c "print(round(float('$TOTAL_FPS_RUN1') / float('$IMGS_PER_SEC') * 100, 1))")
      EFFICIENCY_RUN2=$(python3 -c "print(round(float('$TOTAL_FPS_RUN2') / float('$IMGS_PER_SEC') * 100, 1))")
      RT_LABEL_RUN1=$(python3 -c "print('YES' if float('$FPS_RAW_RUN1') >= 30 else 'NO')")
      RT_LABEL_RUN2=$(python3 -c "print('YES' if float('$FPS_RAW_RUN2') >= 30 else 'NO')")
      ```
      
      Then write the markdown (use unquoted `<< MDEOF` so bash expands variables):
      
      ```bash
      cat > models/$MODEL_NAME/reports/benchmark_report.md << MDEOF
      # ${MODEL_NAME} Benchmark Report
      
      Generated: $(date '+%Y-%m-%d %H:%M:%S')
      
      ---
      
      ## 1. Model Configuration
      
      | Parameter | Value |
      |-----------|-------|
      | **Model Name** | ${MODEL_NAME} |
      | **Source** | (fill from Steps 1-3 log) |
      | **Architecture** | (fill from config.json model_type) |
      | **ONNX Source** | models/${MODEL_NAME}/model/ |
      | **Precision** | FP16 |
      | **Engine File** | $(basename $ENGINE) |
      | **Engine Profile** | min=1x3x640x640  opt=${MAX_BS}x3x640x640  max=${MAX_BS}x3x640x640 |
      | **Custom Parser** | libnvdsinfer_${MODEL_NAME}_parser.so |
      | **Cluster Mode** | (fill from nvinfer config) |
      
      ## 2. System Configuration
      
      | Parameter | Value |
      |-----------|-------|
      | **GPU** | ${GPU_NAME} |
      | **VRAM** | ${GPU_VRAM} |
      | **Driver** | ${DRIVER_VER} |
      | **CUDA** | ${CUDA_VER} |
      | **TensorRT** | ${TRT_VER} |
      | **DeepStream** | ${DS_VER} |
      
      ## 3. Preprocessing
      
      | Parameter | Value |
      |-----------|-------|
      | **net-scale-factor** | (fill from nvinfer config) |
      | **offsets** | (fill from nvinfer config) |
      | **Color Format** | (fill from nvinfer config) |
      | **Input Resolution** | 640×640 |
      
      ## 4. Engine Build Summary
      
      | Parameter | Value |
      |-----------|-------|
      | **Source Format** | ONNX |
      | **Engine File** | $(basename $ENGINE) |
      | **Engine Size** | ${ENGINE_SIZE_MB} MB |
      | **FP16** | Enabled |
      | **MAX Batch Size** | ${MAX_BS} |
      | **Workspace** | 32768 MiB |
      | **Timing Cache** | models/${MODEL_NAME}/benchmarks/engines/timing.cache |
      
      ## 5. trtexec Results
      
      | Metric | BS=1 | BS=${MAX_BS} |
      |--------|------|------|
      | **QPS (queries/s)** | ${QPS_BS1} | ${QPS_BS_MAX} |
      | **Images/s** | ${IMGS_PER_SEC_BS1} | ${IMGS_PER_SEC} |
      | **GPU Compute Mean (ms)** | ${GPU_MEAN_BS1} | ${GPU_MEAN_BS_MAX} |
      | **GPU Compute P99 (ms)** | ${GPU_P99_BS1} | ${GPU_P99_BS_MAX} |
      
      > Note: H2D/D2H latency excluded — trtexec run with \`--noDataTransfers\` to match DeepStream (GPU-to-GPU data flow, no host transfers).
      
      ![trtexec BS=1 vs BS=${MAX_BS}](charts/chart_trtexec_bs1_vs_bsmax.png)
      
      ## 6. PEAK_GPU_STREAMS Derivation
      
      \`\`\`
      PEAK_GPU_STREAMS = floor(imgs_per_sec_at_MAX_BS / 30)
                      = floor(${IMGS_PER_SEC} / 30)
                      = ${PEAK_GPU_STREAMS} streams
      \`\`\`
      
      ![trtexec throughput at BS=${MAX_BS}](charts/chart_trtexec_throughput.png)
      
      ## 7. Single-Stream Validation
      
      | Parameter | Value |
      |-----------|-------|
      | **Video Source** | sample_720p.mp4 (1280×720) |
      | **KITTI Output Dir** | models/${MODEL_NAME}/samples/kitti_output/ |
      | **Total Frames** | (fill from kitti dump) |
      | **Frames with Detections** | (fill from kitti dump) |
      | **Detection Rate** | (fill — must be ≥ 90%) |
      | **Visual Capture Mode** | (fill: `nvv4l2h264enc MP4` OR `theoraenc OGV (NVENC unavailable)` OR `skipped (no encoder available)`) |
      | **Visual Capture Artifact** | (fill: `samples/${MODEL_NAME}_output.mp4` for NVENC path; `samples/${MODEL_NAME}_output.ogv` for theoraenc fallback; `N/A` if skipped) |
      | **Validation Result** | PASS |
      
      > **Encoder reporting rule (MANDATORY):** The Visual Capture Mode field MUST be exactly one of:
      > - `nvv4l2h264enc MP4` — NVENC succeeded; artifact is `.mp4`
      > - `theoraenc OGV (NVENC unavailable)` — if `DS_SINGLE_STREAM_MODE=theoraenc-fallback`; use `.ogv` path from `DS_SINGLE_STREAM_OUTPUT=`
      > - `skipped (no encoder available)` — if `DS_SINGLE_STREAM_MODE=skipped`; no artifact file
      > `x264enc` and `openh264enc` are prohibited and must never appear in this field.
      
      ## 8. DeepStream Benchmark Results
      
      ### DS Run 1 — Calibration at PEAK_GPU_STREAMS (${N_RUN1} streams)
      
      | Metric | Value |
      |--------|-------|
      | **Streams** | ${N_RUN1} |
      | **Batch Size** | ${N_RUN1} |
      | **FPS / Stream** | ${FPS_RAW_RUN1} |
      | **Total Images/s** | ${TOTAL_FPS_RUN1} |
      | **Real-Time (≥30 fps/stream)** | ${RT_LABEL_RUN1} |
      
      ### DS Run 2 — Validation at RT_STREAMS (${N_RUN2} streams)
      
      | Metric | Value |
      |--------|-------|
      | **Streams** | ${N_RUN2} |
      | **Batch Size** | ${N_RUN2} |
      | **FPS / Stream** | ${FPS_RAW_RUN2} |
      | **Total Images/s** | ${TOTAL_FPS_RUN2} |
      | **Real-Time (≥30 fps/stream)** | ${RT_LABEL_RUN2} |
      
      ![DeepStream FPS/stream vs stream count](charts/chart_ds_streams_vs_fps.png)
      
      ## 9. trtexec vs DeepStream Comparison
      
      | Metric | trtexec BS=${MAX_BS} | DS Run 1 (${N_RUN1} streams) | DS Run 2 (${N_RUN2} streams) |
      |--------|---------------------|------------------------------|------------------------------|
      | **Engine** | $(basename $ENGINE) | $(basename $ENGINE) | $(basename $ENGINE) |
      | **Batch / Streams** | BS=${MAX_BS} | ${N_RUN1} streams | ${N_RUN2} streams |
      | **Total imgs/s** | ${IMGS_PER_SEC} | ${TOTAL_FPS_RUN1} | ${TOTAL_FPS_RUN2} |
      | **FPS / stream** | $(python3 -c "print(round(float('$IMGS_PER_SEC')/${MAX_BS},1))") | ${FPS_RAW_RUN1} | ${FPS_RAW_RUN2} |
      | **Real-Time ≥30fps** | YES | ${RT_LABEL_RUN1} | ${RT_LABEL_RUN2} |
      | **DS Efficiency %** | — | ${EFFICIENCY_RUN1}% | ${EFFICIENCY_RUN2}% |
      
      ![trtexec vs DeepStream total throughput](charts/chart_trt_vs_ds.png)
      
      ## 10. Efficiency Analysis
      
      \`\`\`
      DS Efficiency = DS_total_imgs_per_sec / trtexec_imgs_per_sec × 100
      Run 1: ${TOTAL_FPS_RUN1} / ${IMGS_PER_SEC} × 100 = ${EFFICIENCY_RUN1}%
      Run 2: ${TOTAL_FPS_RUN2} / ${IMGS_PER_SEC} × 100 = ${EFFICIENCY_RUN2}%
      \`\`\`
      
      Efficiency gap breakdown: NVDEC decode overhead (~5-10%), GStreamer mux/queue overhead (~5-10%), CPU scheduler jitter (~2-5%).
      
      Interpretation notes for the numbers above:
      
      - **Well-balanced pipeline**: GPU=99-100%, NVDEC=99-100%, CPU=30-40% with no single core pinned. The ~50% DS/trtexec gap at this utilization is physically irreducible — it's the cost of real decode + memory transfers that trtexec skips with \`--noDataTransfers\`.
      - **DS efficiency above 100% is expected for ViT / transformer models**: the TRT compiler backend (opt-level 4) often produces bimodal GPU latency with two alternating execution paths (e.g., 1.5ms and 4.0ms modes for OWL-ViT). trtexec reports high variance and a conservative median; DeepStream's pipelined scheduling smooths the bimodal pattern and can achieve 100-110% of the trtexec baseline. This is not a measurement error.
      - **1080p tends to saturate NVDEC** while GPU has headroom. The pipeline is pinned to 720p (\`sample_720p.mp4\`) specifically to keep benchmarks comparable across models.
      
      ![DeepStream efficiency vs trtexec baseline](charts/chart_efficiency.png)
      
      ## 11. Pipeline Timing
      
      | Step | Description | Duration |
      |------|-------------|----------|
      | 1-3 | HF Model Acquire (download + inspect ONNX) | (fill from step timing) |
      | 4 | Engine build | (fill from step timing) |
      | 5 | trtexec BS=1 + BS=${MAX_BS} | (fill from step timing) |
      | 6 | Parser + config + visual validation + KITTI | (fill from step timing) |
      | 7 Run 1 | DS Calibration (${N_RUN1} streams) | (fill from step timing) |
      | 7 Run 2 | DS Validation (${N_RUN2} streams) | (fill from step timing) |
      | 8 | Report generation | (fill) |
      | **Total** | **End-to-end** | **(fill)** |
      
      ## 12. Reference Commands
      
      ### Engine Build
      \`\`\`bash
      trtexec --onnx=models/${MODEL_NAME}/model/${MODEL_FILENAME}.onnx \\
        --saveEngine=models/${MODEL_NAME}/benchmarks/engines/${MODEL_FILENAME}_dynamic_b${MAX_BS}.engine \\
        --minShapes=${INPUT_NAME}:1x3x${H}x${W} \\
        --optShapes=${INPUT_NAME}:${MAX_BS}x3x${H}x${W} \\
        --maxShapes=${INPUT_NAME}:${MAX_BS}x3x${H}x${W} \\
        --fp16 --memPoolSize=workspace:32768M \\
        --timingCacheFile=models/${MODEL_NAME}/benchmarks/engines/timing.cache
      \`\`\`
      
      ### trtexec Benchmark
      \`\`\`bash
      # BS=1
      trtexec --loadEngine=$(basename $ENGINE) --shapes=${INPUT_NAME}:1x3x${H}x${W} \\
        --noDataTransfers --warmUp=1000 --duration=10
      
      # BS=${MAX_BS}
      trtexec --loadEngine=$(basename $ENGINE) --shapes=${INPUT_NAME}:${MAX_BS}x3x${H}x${W} \\
        --noDataTransfers --warmUp=1000 --duration=10
      \`\`\`
      
      ### DeepStream Single-Stream Validation
      \`\`\`bash
      # See models/${MODEL_NAME}/scripts/ for full gst-launch-1.0 command
      \`\`\`
      
      ### DeepStream Multi-Stream Benchmark
      \`\`\`bash
      # DS Run 1: ${N_RUN1} streams — see models/${MODEL_NAME}/scripts/
      # DS Run 2: ${N_RUN2} streams — see models/${MODEL_NAME}/scripts/
      \`\`\`
      
      ### Custom Parser Build
      \`\`\`bash
      cd models/${MODEL_NAME}/parser && make DEEPSTREAM_DIR=/opt/nvidia/deepstream/deepstream CUDA_VER=13.2
      \`\`\`
      MDEOF
      echo "benchmark_report.md written: $(wc -l < models/$MODEL_NAME/reports/benchmark_report.md) lines"
      ```
      
      > **Note on "fill" fields**: Fields marked `(fill from ...)` must be replaced with actual values from the step logs before finalizing. Search the step output logs for the exact values and substitute them. Do not leave any `(fill ...)` placeholder in the final report.
      
      ## 8c-2 + 8c-3: HTML + PDF Report (MANDATORY — ONE COMMAND)
      
      Before generating HTML+PDF, verify all 5 charts exist:
      
      ```bash
      CHART_DIR="models/$MODEL_NAME/reports/charts"
      MISSING_CHARTS=0
      for CHART in chart_trtexec_bs1_vs_bsmax.png chart_trtexec_throughput.png \
                   chart_ds_streams_vs_fps.png chart_trt_vs_ds.png chart_efficiency.png; do
        [ ! -f "$CHART_DIR/$CHART" ] && { echo "ERROR: Missing $CHART_DIR/$CHART"; MISSING_CHARTS=$((MISSING_CHARTS+1)); }
      done
      [ "$MISSING_CHARTS" -gt 0 ] && { echo "ERROR: $MISSING_CHARTS chart(s) missing — re-run 8c-1"; exit 1; }
      echo "All 5 charts verified OK"
      ```
      
      Then run the canonical pipeline script — this generates BOTH the HTML and PDF correctly:
      
      ```bash
      # setup.sh apt-installs wkhtmltopdf INSIDE the container, so it does NOT survive the ephemeral
      # `--rm` bootstrap container. Every later `docker run --rm` starts without it and the PDF step
      # silently degrades to HTML-only. Ensure it is present in THIS invocation before rendering.
      command -v wkhtmltopdf >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq wkhtmltopdf; }
      
      python3 .claude/skills/deepstream-import-vision-model/scripts/report/md-to-html-pdf.py \
        models/$MODEL_NAME/reports/benchmark_report.md \
        .claude/skills/deepstream-import-vision-model/scripts/report/report-style.css \
        models/$MODEL_NAME/reports/ \
        $MODEL_NAME
      ```
      
      This script uses `report_style.css` (navy `#283593` headers, `#e8eaf6` rows, `#263238` code blocks), embeds charts as base64 data URIs, calls `wkhtmltopdf` internally, and outputs `benchmark_report.html` + `benchmark_report_{model_name}.pdf`.
      
      > **NAMING RULES:**
      > - HTML: always `benchmark_report.html` (no model name suffix)
      > - PDF: always `benchmark_report_{model_name}.pdf` (model name postfix required)
      
      Verify PDF size is >500 KB (confirms charts embedded). Run all python commands with the shared venv active (`source build/.venv_optimum/bin/activate`); `markdown` and `matplotlib` are already installed there.
      
      ## 8c-4: Final Report Checklist and Timing
      
      After generating markdown, HTML, and PDF, record step timing:
      
      ```bash
      STEP8_END=$(date +%s.%N)
      STEP8_DURATION=$(python3 -c "print(round($STEP8_END - $STEP8_START, 2))")   # bc is not in the container; python3 always is
      echo "[Step 8] Report generation completed in ${STEP8_DURATION}s"
      ```
      
      Before marking the report as complete, verify ALL of these exist:
      - [ ] `reports/benchmark_report.md` — markdown source (12 sections)
      - [ ] `reports/benchmark_report.html` — styled HTML (charts/ alongside)
      - [ ] `reports/benchmark_report_{model_name}.pdf` — PDF >500 KB (confirms charts embedded)
      - [ ] `reports/benchmark_data.json` — raw benchmark numbers
      - [ ] `reports/charts/` — all 5 PNGs: `chart_trtexec_bs1_vs_bsmax.png`, `chart_trtexec_throughput.png`, `chart_ds_streams_vs_fps.png`, `chart_trt_vs_ds.png`, `chart_efficiency.png`
      - **Charts**: fixed filenames above — never rename or add model name suffix to charts
      
    • windows.md 3.3 KB
      <!--
      Copyright (c) 2026, NVIDIA CORPORATION.  All rights reserved.
      Licensed under the Apache License, Version 2.0 (the "License").
      -->
      
      # Running on Windows (and cross-platform)
      
      This skill runs on **Windows with Docker Desktop** with **no host packages**: every compute step runs
      **inside the DeepStream Linux container** via `docker run`. The host only needs `docker` + the NVIDIA
      driver. The single exception is the host-side install (copying the skill into `<project>\.claude\skills\`),
      which cannot run in a container — so the skill ships exactly one PowerShell script, **`install.ps1`**,
      the twin of `install.sh`. There are no `.ps1` duplicates of the compute scripts.
      
      ## Why this works
      The ONNX export, TensorRT engine build, custom nvinfer parser compile, DeepStream run, and PDF
      report all execute **inside** `nvcr.io/nvidia/deepstream:9.1-triton-multiarch` (the venv
      `build/.venv_optimum` + `wkhtmltopdf` are installed into that container by `setup.sh`). The container
      is the portability layer. The only per-shell difference is the `docker run` **bind-mount token**.
      
      ## Prerequisites (Windows)
      1. **Docker Desktop** with the **WSL2 backend enabled** (Settings → General → *Use the WSL 2 based
         engine*) — required for GPU (`--gpus all` works only through the WSL2 backend).
      2. A recent **NVIDIA driver** with WSL/CUDA support. No CUDA toolkit / TensorRT / DeepStream needed
         on the host — the container ships them.
      3. Docker Desktop → Settings → Resources → **File Sharing**: share the drive holding your working dir.
      4. `docker pull nvcr.io/nvidia/deepstream:9.1-triton-multiarch`.
      
      ## The one thing that differs per shell: the mount token
      Claude Code fills this in based on the host OS:
      
      | Shell | working-dir mount |
      |-------|-------------------|
      | **PowerShell** | `-v "${PWD}:/work"` |
      | **cmd** | `-v "%cd%:/work"` |
      | **WSL2 / Linux bash** | `-v "$PWD":/work` |
      
      ## Bootstrap + preflight (PowerShell example)
      ```powershell
      # one-time bootstrap: venv + torch/onnx/onnxruntime + wkhtmltopdf, all in-container
      docker run --rm -it --gpus all --shm-size=16g -v "${PWD}:/work" -w /work `
        --entrypoint bash nvcr.io/nvidia/deepstream:9.1-triton-multiarch `
        .claude/skills/deepstream-import-vision-model/setup.sh
      
      # preflight — GPU + venv + trtexec (container-mode auto-detects /.dockerenv)
      docker run --rm --gpus all -v "${PWD}:/work" -w /work `
        --entrypoint bash nvcr.io/nvidia/deepstream:9.1-triton-multiarch `
        .claude/skills/deepstream-import-vision-model/scripts/preflight.sh
      ```
      Every subsequent phase runs the same way — via `docker run … -lc '<commands>'` or the
      `.claude/skills/deepstream-import-vision-model/scripts/dsrun.sh` wrapper.
      
      ## Notes
      - The skill ships a `.gitattributes` forcing **LF** on all scripts, so a Windows checkout won't
        CRLF-corrupt them (CRLF breaks bash-in-container).
      - `--shm-size=16g` works on the WSL2 backend.
      - **Install:** on native Windows run the bundled **`install.ps1`** — the twin of `install.sh`, same
        sequence and flags (`-Target`=`--target`, `-NoCursor`=`--no-cursor`, `-DryRun`=`--dry-run`):
        `.\install.ps1 -Target C:\path\to\project` (copies the skill into `<project>\.claude\skills\`). On
        Linux/WSL2/Git Bash use `bash install.sh --target <project>`.
      - Prefer a **WSL2 Ubuntu terminal** for the exact Linux experience — inside WSL2 everything runs
        unchanged.
      
  • scripts
    • deepstream
      • benchmark-ds.sh 3.6 KB
        #!/usr/bin/env bash
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        set -euo pipefail
        ################################################################################
        # DeepStream benchmark using gst-launch-1.0
        # Thumb rule: batch_size == num_streams (always equal).
        # Measures total throughput by timing full video processing with fakesink.
        #
        # Usage: ./benchmark-ds.sh <config_file> <num_streams> [input_video]
        # Example: ./benchmark-ds.sh config_infer_primary_b21.txt 21 video.mp4
        #
        # batch_size in the nvinfer config must match num_streams.
        ################################################################################
        
        CONFIG="${1:-}"
        NUM_STREAMS="${2:-}"
        VIDEO="${3:-/opt/nvidia/deepstream/deepstream/samples/streams/sample_720p.mp4}"
        MUXER_W=1280
        MUXER_H=720
        NS_PER_SEC=$(( 1000 * 1000 * 1000 ))
        
        if [ -z "$CONFIG" ] || [ -z "$NUM_STREAMS" ]; then
            echo "Usage: $0 <config_file> <num_streams> [input_video]"
            exit 1
        fi
        
        # Detect video FPS via mediainfo; fall back to 30 for the standard sample
        VIDEO_FPS=$(mediainfo --Inform="Video;%FrameRate%" "${VIDEO}" 2>/dev/null | awk '{printf "%.0f", $1+0}')
        VIDEO_FPS="${VIDEO_FPS:-30}"
        
        # Detect actual frame count; fall back to 1440 if mediainfo unavailable or fails
        if [ -n "$3" ]; then
            FRAMES_PER_STREAM=$(mediainfo --Inform="Video;%FrameCount%" "${VIDEO}" 2>/dev/null)
            if ! echo "$FRAMES_PER_STREAM" | grep -qE '^[0-9]+$' || [ "$FRAMES_PER_STREAM" -eq 0 ]; then
                echo "Warning: mediainfo failed, falling back to 1440 frames" >&2
                FRAMES_PER_STREAM=1440
            fi
        else
            # Default sample_720p.mp4 is ~1440 frames at 30fps
            FRAMES_PER_STREAM=1440
        fi
        TOTAL_FRAMES=$((FRAMES_PER_STREAM * NUM_STREAMS))
        
        echo "=== DeepStream Benchmark ==="
        echo "Config:  $CONFIG"
        echo "Streams: $NUM_STREAMS"
        echo "Frames/stream: $FRAMES_PER_STREAM"
        echo "Total frames:  $TOTAL_FRAMES"
        echo ""
        
        # Build source elements
        SOURCES=""
        for i in $(seq 0 $((NUM_STREAMS - 1))); do
            SOURCES+="filesrc location=${VIDEO} ! qtdemux ! queue ! h264parse ! queue ! nvv4l2decoder ! queue ! mux.sink_${i} "
        done
        
        PIPELINE="${SOURCES} nvstreammux name=mux batch-size=${NUM_STREAMS} width=${MUXER_W} height=${MUXER_H} batched-push-timeout=-1 ! \
            queue ! nvinfer config-file-path=${CONFIG} ! queue ! fakesink sync=0"
        
        echo "Starting pipeline..."
        START_TIME=$(date +%s%N)
        
        GST_DEBUG=0 gst-launch-1.0 -e ${PIPELINE} 2>&1 | grep -v "^$" || true
        
        END_TIME=$(date +%s%N)
        ELAPSED_NS=$((END_TIME - START_TIME))
        # awk instead of bc (bc is not installed in the DeepStream container; awk always is)
        ELAPSED_SEC=$(awk "BEGIN{printf \"%.2f\", $ELAPSED_NS / $NS_PER_SEC}")
        FPS=$(awk "BEGIN{printf \"%.1f\", $TOTAL_FRAMES / $ELAPSED_SEC}")
        REALTIME=$(awk "BEGIN{printf \"%.2f\", $FPS / (${NUM_STREAMS} * ${VIDEO_FPS})}")
        
        echo ""
        echo "=== Results ==="
        echo "Wall time:     ${ELAPSED_SEC}s"
        echo "Total frames:  ${TOTAL_FRAMES}"
        echo "Throughput:    ${FPS} img/s"
        echo "Per-stream:    $(awk "BEGIN{printf \"%.1f\", $FPS / $NUM_STREAMS}") fps"
        echo "Real-time factor: ${REALTIME}x (${NUM_STREAMS} streams @ ${VIDEO_FPS}fps)"
        echo "==============="
        
      • ds-kitti-dump.sh 4.5 KB
        #!/usr/bin/env bash
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        ################################################################################
        # Step 6: KITTI dump using deepstream-app (built-in KITTI support)
        # Generates a temporary deepstream-app config, runs for N frames, dumps KITTI.
        #
        # Usage: ./ds-kitti-dump.sh <nvinfer_config> <kitti_output_dir> [num_frames] [input_video]
        # Example: ./ds-kitti-dump.sh config_infer_primary_yolox.txt kitti_output 100
        ################################################################################
        set -euo pipefail
        
        NVINFER_CONFIG="$1"
        KITTI_DIR="$2"
        NUM_FRAMES="${3:-100}"
        VIDEO="${4:-/opt/nvidia/deepstream/deepstream/samples/streams/sample_720p.mp4}"
        
        if [ -z "$NVINFER_CONFIG" ] || [ -z "$KITTI_DIR" ]; then
            echo "Usage: $0 <nvinfer_config> <kitti_output_dir> [num_frames] [input_video]"
            exit 1
        fi
        
        # Validate inputs before resolving paths
        [ -f "$NVINFER_CONFIG" ] || { echo "ERROR: nvinfer config not found: $NVINFER_CONFIG"; exit 1; }
        [ -f "$VIDEO" ] || { echo "ERROR: video file not found: $VIDEO"; exit 1; }
        
        # Resolve to absolute paths
        NVINFER_CONFIG="$(realpath "$NVINFER_CONFIG")"
        KITTI_DIR="$(realpath -m "$KITTI_DIR")"
        VIDEO="$(realpath "$VIDEO")"
        
        mkdir -p "${KITTI_DIR}"
        
        echo "=== DeepStream KITTI Dump ==="
        echo "nvinfer config: $NVINFER_CONFIG"
        echo "KITTI dir:      $KITTI_DIR"
        echo "Max frames:     $NUM_FRAMES"
        echo "Input video:    $VIDEO"
        echo ""
        
        # Generate temporary deepstream-app config
        trap 'rm -f "${TMPCONFIG:-}"' EXIT
        TMPCONFIG=$(mktemp /tmp/ds_kitti_XXXXXX.txt)
        
        cat > "$TMPCONFIG" << EOF
        [application]
        enable-perf-measurement=0
        gie-kitti-output-dir=${KITTI_DIR}
        
        [tiled-display]
        enable=0
        
        [source0]
        enable=1
        type=3
        uri=file://${VIDEO}
        num-sources=1
        gpu-id=0
        
        [sink0]
        enable=1
        type=1
        #1=FakeSink
        sync=0
        
        [osd]
        enable=0
        
        [streammux]
        live-source=0
        batch-size=1
        batched-push-timeout=-1
        width=1280
        height=720
        
        [primary-gie]
        enable=1
        batch-size=1
        gie-unique-id=1
        config-file=${NVINFER_CONFIG}
        
        [tests]
        file-loop=0
        EOF
        
        echo "Temp config: $TMPCONFIG"
        echo "Running deepstream-app..."
        
        # Run deepstream-app (it will process entire video).
        # Temporarily disable pipefail so head -30 closing the pipe early (SIGPIPE to grep)
        # doesn't trigger set -e before we can capture deepstream-app's exit code.
        set +o pipefail
        timeout 120 deepstream-app -c "$TMPCONFIG" 2>&1 | grep -v "^$" | head -30
        DS_EXIT_CODE=${PIPESTATUS[0]}
        set -o pipefail
        
        if [ $DS_EXIT_CODE -eq 124 ]; then
            echo "Warning: deepstream-app timed out after 120 seconds"
        elif [ $DS_EXIT_CODE -ne 0 ]; then
            echo "Error: deepstream-app failed with exit code $DS_EXIT_CODE"
            exit 1
        fi
        
        # Count KITTI files generated
        TOTAL_FILES=$(ls -1 "${KITTI_DIR}"/*.txt 2>/dev/null | wc -l)
        echo ""
        echo "Total KITTI files generated: ${TOTAL_FILES}"
        
        # Keep only first N frames, remove the rest
        if [ "$TOTAL_FILES" -gt "$NUM_FRAMES" ]; then
            # Guard against misconfigured KITTI_DIR blowing away something else
            [ -n "$KITTI_DIR" ] && [ -d "$KITTI_DIR" ] && [ "$KITTI_DIR" != "/" ] \
                || { echo "ERROR: invalid KITTI_DIR for cleanup: $KITTI_DIR"; exit 1; }
            TO_REMOVE=$((TOTAL_FILES - NUM_FRAMES))
            echo "Trimming to first ${NUM_FRAMES} frames (removing ${TO_REMOVE})..."
            # NUL-delimited read so filenames with spaces/newlines are handled safely.
            KITTI_FILES=()
            while IFS= read -r -d '' f; do
                KITTI_FILES+=("$f")
            done < <(find "$KITTI_DIR" -maxdepth 1 -type f -name '*.txt' -print0 | sort -z)
            for ((i = NUM_FRAMES; i < ${#KITTI_FILES[@]}; i++)); do
                rm -f -- "${KITTI_FILES[i]}"
            done
            TOTAL_FILES=$(find "$KITTI_DIR" -maxdepth 1 -type f -name '*.txt' 2>/dev/null | wc -l)
            echo "Kept ${TOTAL_FILES} KITTI files"
        fi
        
        # Show sample KITTI output
        echo ""
        echo "=== Sample KITTI Output (first 3 files) ==="
        for f in $(ls -1 "${KITTI_DIR}"/*.txt 2>/dev/null | sort | head -3); do
            echo "--- $(basename $f) ---"
            cat "$f"
        done
        
        echo ""
        echo "=== KITTI Dump Complete ==="
        
      • ds-perf-run.sh 4.8 KB
        #!/usr/bin/env bash
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        ################################################################################
        # Step 7c: DeepStream perf-measurement run via deepstream-app.
        #
        # Replaces the older `gst-launch-1.0 ... ! fpsdisplaysink ...` benchmark, which
        # pulled in `gstreamer1.0-plugins-bad`. `deepstream-app` is part of the NVIDIA
        # DeepStream SDK and emits `**PERF: fps_run0 (fps_avg0)  fps_run1 (fps_avg1)  ...`
        # lines (one pair per active source) that the report-generation phase parses.
        #
        # Usage: ./ds-perf-run.sh <nvinfer_config> <num_streams> <log_path> [input_video]
        # Example:
        #   ./ds-perf-run.sh config_infer_ds_yolox.txt 32 \
        #       models/yolox/benchmarks/ds/ds_s32_run1.log \
        #       /opt/nvidia/deepstream/deepstream/samples/streams/sample_720p.mp4
        #
        # Notes:
        #   - `[primary-gie] batch-size` and `[streammux] batch-size` are both set to N
        #     (matches the skill-wide rule batch_size == num_streams).
        #   - `num-sources=N` fans the single input video out to N pipeline sources;
        #     deepstream-app handles the file-loop / EOS bookkeeping.
        #   - The nvinfer config must already point at the engine, parser, and labels.
        #     This script does NOT mutate the nvinfer config.
        ################################################################################
        set -euo pipefail
        
        NVINFER_CONFIG="${1:-}"
        NUM_STREAMS="${2:-}"
        LOG_PATH="${3:-}"
        VIDEO="${4:-/opt/nvidia/deepstream/deepstream/samples/streams/sample_720p.mp4}"
        
        if [ -z "$NVINFER_CONFIG" ] || [ -z "$NUM_STREAMS" ] || [ -z "$LOG_PATH" ]; then
            echo "Usage: $0 <nvinfer_config> <num_streams> <log_path> [input_video]"
            exit 1
        fi
        
        [ -f "$NVINFER_CONFIG" ] || { echo "ERROR: nvinfer config not found: $NVINFER_CONFIG"; exit 1; }
        [ -f "$VIDEO" ] || { echo "ERROR: video file not found: $VIDEO"; exit 1; }
        command -v deepstream-app >/dev/null 2>&1 || { echo "ERROR: deepstream-app not on PATH"; exit 1; }
        
        NVINFER_CONFIG="$(realpath "$NVINFER_CONFIG")"
        VIDEO="$(realpath "$VIDEO")"
        LOG_PATH="$(realpath -m "$LOG_PATH")"
        mkdir -p "$(dirname "$LOG_PATH")"
        
        N="$NUM_STREAMS"
        MUXER_W=1280
        MUXER_H=720
        
        echo "=== DeepStream Perf Run ==="
        echo "nvinfer config: $NVINFER_CONFIG"
        echo "Streams (=N):   $N"
        echo "Input video:    $VIDEO"
        echo "Log path:       $LOG_PATH"
        echo ""
        
        trap 'rm -f "${TMPCONFIG:-}"' EXIT
        TMPCONFIG=$(mktemp /tmp/ds_perf_XXXXXX.txt)
        
        cat > "$TMPCONFIG" <<EOF
        [application]
        enable-perf-measurement=1
        perf-measurement-interval-sec=2
        
        [tiled-display]
        enable=0
        
        [source0]
        enable=1
        type=3
        uri=file://${VIDEO}
        num-sources=${N}
        gpu-id=0
        
        [sink0]
        enable=1
        type=1
        sync=0
        
        [osd]
        enable=0
        
        [streammux]
        live-source=0
        batch-size=${N}
        batched-push-timeout=-1
        width=${MUXER_W}
        height=${MUXER_H}
        
        [primary-gie]
        enable=1
        batch-size=${N}
        gie-unique-id=1
        config-file=${NVINFER_CONFIG}
        
        [tests]
        file-loop=1
        EOF
        
        echo "Temp config: $TMPCONFIG"
        echo "Running deepstream-app..."
        
        set +o pipefail
        # file-loop=1 has no built-in stop condition; timeout(1) kills deepstream-app
        # after 60 s and returns exit 124 — treated as success below.
        timeout 60s deepstream-app -c "$TMPCONFIG" 2>&1 | tee "$LOG_PATH"
        DS_EXIT_CODE=${PIPESTATUS[0]}
        set -o pipefail
        
        # exit 124 = timeout fired as expected (file-loop=1, 60 s cap)
        if [ $DS_EXIT_CODE -ne 0 ] && [ $DS_EXIT_CODE -ne 124 ]; then
            echo "ERROR: deepstream-app exited with code $DS_EXIT_CODE — see $LOG_PATH" >&2
            exit "$DS_EXIT_CODE"
        fi
        
        # Average stream-0 instantaneous FPS across the last 10 **PERF: lines.
        # Using stream 0 (the \K capture after `**PERF:`) gives exactly 1 value per
        # measurement window so tail -10 always covers 10 windows regardless of N.
        # Multiply by N for total throughput.
        PERF_FPS=$(grep -oP '\*\*PERF:\s*\K[0-9.]+' "$LOG_PATH" | tail -10 | python3 -c "
        import sys
        vals = [float(line) for line in sys.stdin if line.strip()]
        print(round(sum(vals)/len(vals), 2) if vals else 0)
        ")
        
        if [ -z "$PERF_FPS" ] || [ "$PERF_FPS" = "0" ]; then
            echo "ERROR: no **PERF: lines parsed from $LOG_PATH" >&2
            exit 1
        fi
        
        TOTAL_FPS=$(python3 -c "print(round(float('$PERF_FPS') * $N, 2))")
        
        echo ""
        echo "=== Perf Summary ==="
        echo "Streams:        $N"
        echo "FPS/stream:     $PERF_FPS"
        echo "Total imgs/sec: $TOTAL_FPS"
        echo "Log:            $LOG_PATH"
        echo "===================="
        
      • ds-single-stream.sh 4.9 KB
        #!/usr/bin/env bash
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        ################################################################################
        # Step 6: Single-stream DeepStream pipeline -- saves output video with OSD boxes.
        #
        # Usage: ./ds-single-stream.sh <config_file> <output_video> [input_video]
        # Example: ./ds-single-stream.sh config_infer_primary_yolox.txt yolox_output.mp4
        #
        # Encoder policy (MANDATORY):
        #   - Primary path uses nvv4l2h264enc (NVENC) with .mp4 container. nvdsosd
        #     overlays are reliably preserved only with NVENC on the NVMM memory path.
        #   - x264enc and openh264enc are PROHIBITED and must never be used.
        #   - On NVENC-init failure, the script checks theoraenc + oggmux availability
        #     (LGPL elements; both ship in gst-plugins-base):
        #       * Available  → falls back to theoraenc+oggmux → saves <output>.ogv
        #           nvvideoconvert ! "video/x-raw, format=I420" ! theoraenc quality=48 ! oggmux
        #         Emits DS_SINGLE_STREAM_MODE=theoraenc-fallback and DS_SINGLE_STREAM_OUTPUT=<path>
        #       * Unavailable → skips video creation, emits DS_SINGLE_STREAM_MODE=skipped, exit 0
        #     The benchmark report must surface which encoder mode was used.
        ################################################################################
        
        set -o pipefail
        
        CONFIG="$1"
        OUTPUT="$2"
        VIDEO="${3:-/opt/nvidia/deepstream/deepstream/samples/streams/sample_720p.mp4}"
        MUXER_W=1280
        MUXER_H=720
        
        if [ -z "$CONFIG" ] || [ -z "$OUTPUT" ]; then
            echo "Usage: $0 <config_file> <output_video> [input_video]"
            exit 1
        fi
        
        OUTPUT_DIR="$(dirname "$OUTPUT")"
        LOG_FILE="$(mktemp -t ds-single-stream-XXXXXX.log)"
        trap 'rm -f "$LOG_FILE"' EXIT
        
        mkdir -p "$OUTPUT_DIR"
        
        echo "=== DeepStream Single-Stream Test ==="
        echo "Config: $CONFIG"
        echo "Input:  $VIDEO"
        echo "Output: $OUTPUT (primary: nvv4l2h264enc)"
        echo ""
        
        gst-launch-1.0 \
            filesrc location="${VIDEO}" ! qtdemux ! queue ! h264parse ! queue ! nvv4l2decoder ! queue ! mux.sink_0 \
            nvstreammux name=mux batch-size=1 width=${MUXER_W} height=${MUXER_H} batched-push-timeout=-1 ! \
            nvinfer config-file-path="${CONFIG}" ! \
            nvvideoconvert ! nvdsosd ! nvvideoconvert ! \
            "video/x-raw(memory:NVMM), format=NV12" ! nvv4l2h264enc ! h264parse ! mp4mux ! \
            filesink location="${OUTPUT}" sync=0 \
            2>&1 | tee "$LOG_FILE"
        STATUS=${PIPESTATUS[0]}
        
        if [ $STATUS -eq 0 ] && [ -s "$OUTPUT" ]; then
            echo ""
            echo "Output saved to: ${OUTPUT}"
            echo "DS_SINGLE_STREAM_MODE=nvenc-primary"
            echo "DS_SINGLE_STREAM_OUTPUT=${OUTPUT}"
            exit 0
        fi
        
        # Detect NVENC-init failure -- the only condition under which we use the theoraenc fallback.
        # x264enc and openh264enc are prohibited. Any other failure surfaces as a hard error.
        if grep -qE "v4l2-nvenc.*failed during initialization|Could not open device.*v4l2-nvenc|nvv4l2h264enc.*not-negotiated" "$LOG_FILE"; then
            echo ""
            echo "WARNING: nvv4l2h264enc (NVENC) is unavailable on this GPU." >&2
        
            if ! gst-inspect-1.0 theoraenc > /dev/null 2>&1 || ! gst-inspect-1.0 oggmux > /dev/null 2>&1; then
                echo "WARNING: theoraenc/oggmux not available. Skipping video creation." >&2
                echo "DS_SINGLE_STREAM_MODE=skipped"
                exit 0
            fi
        
            echo "         Falling back to theoraenc+oggmux (OGV output)." >&2
            echo ""
            OGV_OUTPUT="$(echo "${OUTPUT}" | sed -E 's/\.[Mm][Pp]4$//').ogv"
            rm -f "$OUTPUT" "$OGV_OUTPUT"
        
            gst-launch-1.0 \
                filesrc location="${VIDEO}" ! qtdemux ! queue ! h264parse ! queue ! nvv4l2decoder ! queue ! mux.sink_0 \
                nvstreammux name=mux batch-size=1 width=${MUXER_W} height=${MUXER_H} batched-push-timeout=-1 ! \
                nvinfer config-file-path="${CONFIG}" ! \
                nvvideoconvert ! nvdsosd ! nvvideoconvert ! \
                "video/x-raw, format=I420" ! theoraenc quality=48 ! oggmux ! \
                filesink location="${OGV_OUTPUT}" sync=0 \
                2>&1
            THEORA_STATUS=$?
        
            if [ $THEORA_STATUS -eq 0 ] && [ -s "$OGV_OUTPUT" ]; then
                echo ""
                echo "theoraenc fallback succeeded. Output saved to: ${OGV_OUTPUT}"
                echo "DS_SINGLE_STREAM_MODE=theoraenc-fallback"
                echo "DS_SINGLE_STREAM_OUTPUT=${OGV_OUTPUT}"
                exit 0
            fi
        
            echo "ERROR: theoraenc fallback pipeline failed (exit ${THEORA_STATUS})." >&2
            exit ${THEORA_STATUS:-1}
        fi
        
        echo "Pipeline failed with exit code $STATUS (not an NVENC-init failure)." >&2
        exit $STATUS
        
      • ds-sweep.sh 12.3 KB
        #!/usr/bin/env bash
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        ################################################################################
        # DeepStream BS_OPT sweep — smart 2-phase approach.
        #
        # Phase 1: trtexec probe at BS=1,4,8 (~30s total, fast)
        #   - Fits power-law curve: QPS = a × BS^(-alpha)
        #   - Predicts BS where trtexec QPS = FPS_THRESHOLD / DS_EFFICIENCY
        #   - This accounts for DeepStream pipeline overhead vs raw trtexec
        #
        # Phase 2: DeepStream confirmation (1-2 runs)
        #   - Runs DS at BS_pred and BS_pred-step if needed
        #   - Picks highest BS where DS fps/stream >= FPS_THRESHOLD
        #   - Uses dynamic engine (no per-BS engine builds during sweep)
        #
        # Thumb rules:
        #   - batch_size == num_streams (always equal)
        #   - Dynamic engine: min=1, opt=10, max=max(BATCH_SIZES_PROBE) e.g. 8
        #     Extended at build time to max=BS_pred+margin once predicted
        #   - BS_OPT drives production engine build (static, timing cache reuse)
        #
        # Usage:
        #   ./ds-sweep.sh <dynamic_engine> <onnx_path> <config_template> \
        #                 <parser_so> <labels> <engines_dir> <configs_dir> [video]
        ################################################################################
        set -euo pipefail
        
        DYNAMIC_ENGINE="$1"
        ONNX_PATH="$2"
        CONFIG_TEMPLATE="$3"
        PARSER_SO="$4"
        LABELS="$5"
        ENGINES_DIR="$6"
        CONFIGS_DIR="$7"
        VIDEO="${8:-/opt/nvidia/deepstream/deepstream/samples/streams/sample_720p.mp4}"
        
        # Derive INPUT_NAME, H, W from the ONNX model — mirrors how engine-build.md does it.
        # Env var overrides let callers handle models with dynamic spatial dims (e.g. H=800 W=800 ./ds-sweep.sh ...).
        SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
        INSPECT_SCRIPT="$(realpath "${SCRIPT_DIR}/../../model/inspect-onnx.py")"
        if [ -z "${INPUT_NAME:-}" ] || [ -z "${H:-}" ] || [ -z "${W:-}" ]; then
            INSPECT_OUT=$(python3 "${INSPECT_SCRIPT}" "${ONNX_PATH}")
            INPUT_NAME="${INPUT_NAME:-$(echo "${INSPECT_OUT}" | grep -oP 'input_name:\s*\K\S+')}"
            H="${H:-$(echo "${INSPECT_OUT}" | grep -oP 'height:\s*\K[0-9]+')}"
            W="${W:-$(echo "${INSPECT_OUT}" | grep -oP 'width:\s*\K[0-9]+')}"
        fi
        [ -z "${INPUT_NAME}" ] && { echo "ERROR: could not parse INPUT_NAME from ONNX — set INPUT_NAME env var"; exit 1; }
        [ -z "${H}" ] && { echo "ERROR: H not detected (dynamic spatial dims) — set H env var, e.g. H=800"; exit 1; }
        [ -z "${W}" ] && { echo "ERROR: W not detected (dynamic spatial dims) — set W env var, e.g. W=800"; exit 1; }
        
        # DS_ERR_LOG: destination for GStreamer/DeepStream stderr output.
        # Override via environment variable to redirect elsewhere (e.g. a file path or /dev/stderr).
        # Defaults to a log file alongside the sweep engine logs so errors are preserved for diagnosis.
        DS_ERR_LOG="${DS_ERR_LOG:-${ENGINES_DIR}/ds_sweep_gst_errors.log}"
        mkdir -p "$(dirname "${DS_ERR_LOG}")"
        # Truncate/create the log at the start of the sweep so it reflects the current run only.
        : > "${DS_ERR_LOG}"
        echo "[ds-sweep] GStreamer stderr → ${DS_ERR_LOG}"
        
        # TIMING_CACHE="${ENGINES_DIR}/timing.cache"  # used by the engine-build phase, not sweep
        NS_PER_SEC=$(( 1000 * 1000 * 1000 ))  # nanoseconds per second (date +%s%N divisor)
        FPS_THRESHOLD=30          # target fps/stream in DeepStream
        DS_EFFICIENCY=0.65        # DS is ~65% of trtexec throughput (GStreamer pipeline overhead
                                  # includes muxer, memory mgmt, custom parser, metadata — measured)
        TRT_QPS_TARGET=$(awk "BEGIN{printf \"%.4f\", ${FPS_THRESHOLD} / ${DS_EFFICIENCY}}")  # ~46.2 QPS (awk: bc absent in container)
        PROBE_SIZES=(1 4 8)       # fast trtexec probe batch sizes
        PROBE_DURATION=10         # seconds per trtexec probe run
        # NEVER use filesrc num-buffers as a frame count — num-buffers counts file byte blocks (4096B),
        # not video frames. Leave num-buffers unset so filesrc reads to natural EOS.
        # Detect actual frame count and FPS via mediainfo — consistent with benchmark-ds.sh.
        VIDEO_FPS=$(mediainfo --Inform="Video;%FrameRate%" "${VIDEO}" 2>/dev/null | awk '{printf "%.0f", $1+0}')
        VIDEO_FPS="${VIDEO_FPS:-30}"
        ACTUAL_FRAMES_PER_STREAM=$(mediainfo --Inform="Video;%FrameCount%" "${VIDEO}" 2>/dev/null)
        if ! echo "${ACTUAL_FRAMES_PER_STREAM}" | grep -qE '^[0-9]+$' || [ "${ACTUAL_FRAMES_PER_STREAM:-0}" -eq 0 ]; then
            ACTUAL_FRAMES_PER_STREAM=1440   # fallback for sample_720p.mp4: ~48s × 30fps
        fi
        echo "  Video frames/stream: ${ACTUAL_FRAMES_PER_STREAM} (${VIDEO_FPS}fps detected)"
        MUXER_W=1280
        MUXER_H=720
        
        mkdir -p "${CONFIGS_DIR}"
        
        echo "======================================================"
        echo "DS BS_OPT Sweep — 2-Phase Smart Search"
        echo "  FPS threshold : ${FPS_THRESHOLD} fps/stream"
        echo "  DS efficiency : ${DS_EFFICIENCY} (trtexec QPS target: ${TRT_QPS_TARGET})"
        echo "  Probe sizes   : ${PROBE_SIZES[*]}"
        echo "  Input tensor  : ${INPUT_NAME} (${H}x${W})"
        echo "======================================================"
        
        # ── PHASE 1: trtexec probe at BS=1,4,8 ──────────────────
        echo ""
        echo "PHASE 1: trtexec probe (BS=${PROBE_SIZES[*]})"
        
        declare -a PROBE_BS_ARR PROBE_QPS_ARR
        
        for BS in "${PROBE_SIZES[@]}"; do
            echo "  trtexec BS=${BS}..."
            LOG="${ENGINES_DIR}/probe_bs${BS}.log"
            trtexec \
                --loadEngine="${DYNAMIC_ENGINE}" \
                --fp16 \
                --shapes=${INPUT_NAME}:${BS}x3x${H}x${W} \
                --duration=${PROBE_DURATION} \
                --warmUp=2000 \
                > "${LOG}" 2>&1
            QPS=$(grep "Throughput:" "${LOG}" | grep -oP 'Throughput: \K[0-9.]+' | head -1)
            echo "    BS=${BS}: ${QPS} QPS"
            PROBE_BS_ARR+=("${BS}")
            PROBE_QPS_ARR+=("${QPS}")
        done
        
        # ── Power-law fit: QPS = a × BS^(-alpha) ────────────────
        # Use BS=4 and BS=8 points to fit alpha (most stable region)
        # alpha = log(QPS4/QPS8) / log(8/4)
        QPS4="${PROBE_QPS_ARR[1]}"
        QPS8="${PROBE_QPS_ARR[2]}"
        
        ALPHA=$(python3 -c "
        import math
        qps4, qps8 = float('${QPS4}'), float('${QPS8}')
        alpha = math.log(qps4 / qps8) / math.log(8.0 / 4.0)
        print(f'{alpha:.4f}')
        ")
        A_COEFF=$(python3 -c "
        import math
        qps8, alpha = float('${QPS8}'), float('${ALPHA}')
        a = qps8 * (8.0 ** alpha)
        print(f'{a:.4f}')
        ")
        
        echo ""
        echo "  Curve fit: QPS = ${A_COEFF} × BS^(-${ALPHA})"
        
        # Solve for BS where QPS = TRT_QPS_TARGET
        # BS_pred = (a / QPS_target)^(1/alpha)
        # Guard: if alpha ~ 0 (flat curve — memory-bandwidth-bound or very small model),
        # 1/alpha diverges. Use the cap directly and let Phase 2 DS runs confirm.
        BS_PRED=$(python3 -c "
        import math
        a, alpha = float('${A_COEFF}'), float('${ALPHA}')
        target = float('${TRT_QPS_TARGET}')
        if abs(alpha) < 1e-3:
            bs_pred = 128
        else:
            bs_pred = (a / target) ** (1.0 / alpha)
        print(int(bs_pred))
        ")
        
        echo "  Predicted BS_pred = ${BS_PRED} (trtexec QPS ≈ ${TRT_QPS_TARGET} at this batch)"
        echo ""
        
        # Clamp BS_pred to reasonable range [8, 128]
        BS_PRED=$(python3 -c "print(max(8, min(128, int('${BS_PRED}'))))")
        
        # ── PHASE 2: DeepStream confirmation ────────────────────
        echo "PHASE 2: DeepStream confirmation around BS_pred=${BS_PRED}"
        
        # Test BS_pred and BS_pred - small step if first fails
        # Round BS_pred to nearest sensible value
        BS_STEP=$(python3 -c "
        bs = int('${BS_PRED}')
        # step = ~10% of BS_pred, minimum 1
        step = max(1, round(bs * 0.1))
        print(step)
        ")
        
        CANDIDATES=("${BS_PRED}")
        BS_LOWER=$(( BS_PRED - BS_STEP ))
        [ "${BS_LOWER}" -ge 1 ] && CANDIDATES+=("${BS_LOWER}")
        
        best_bs=1
        best_fps_stream=0
        best_ips=0
        had_valid_ds_run=false
        
        for BS in "${CANDIDATES[@]}"; do
            echo ""
            echo "=== DS Confirmation BS=${BS} (${BS} streams) ==="
        
            # Write nvinfer config pointing to dynamic engine at this batch size
            BS_CONFIG="${CONFIGS_DIR}/config_infer_sweep_b${BS}.txt"
            sed \
                -e "s|model-engine-file=.*|model-engine-file=${DYNAMIC_ENGINE}|" \
                -e "s|batch-size=.*|batch-size=${BS}|" \
                -e "s|custom-lib-path=.*|custom-lib-path=${PARSER_SO}|" \
                -e "s|labelfile-path=.*|labelfile-path=${LABELS}|" \
                "${CONFIG_TEMPLATE}" > "${BS_CONFIG}"
        
            # actual frames = ACTUAL_FRAMES_PER_STREAM × BS (no num-buffers limit on filesrc —
            # let each source read to natural EOS so we always process the full video)
            TOTAL_FRAMES=$((ACTUAL_FRAMES_PER_STREAM * BS))
            # Build SOURCES as an array so a VIDEO path containing spaces or glob
            # characters survives shell expansion intact.
            SOURCES=()
            for ((i = 0; i < BS; i++)); do
                SOURCES+=(
                    filesrc "location=${VIDEO}" !
                    qtdemux ! queue ! h264parse ! queue ! nvv4l2decoder ! queue !
                    "mux.sink_${i}"
                )
            done
        
            START_TIME=$(date +%s%N)
            GST_RC=0
            GST_DEBUG=0 gst-launch-1.0 -e \
                "${SOURCES[@]}" \
                nvstreammux name=mux batch-size=${BS} width=${MUXER_W} height=${MUXER_H} batched-push-timeout=40000 ! \
                queue ! \
                nvinfer config-file-path="${BS_CONFIG}" ! \
                queue ! \
                fakesink sync=0 2>>"${DS_ERR_LOG}" || GST_RC=$?
            END_TIME=$(date +%s%N)
            ELAPSED_NS=$(( END_TIME - START_TIME ))
        
            # Reject runs that exited non-zero or finished implausibly fast — both
            # indicate a plugin/config error rather than a real benchmark result.
            # Scoring such a run would produce divide-by-zero or a bogus high FPS.
            if [ "${GST_RC}" -ne 0 ] || [ "${ELAPSED_NS}" -lt "${NS_PER_SEC}" ]; then
                echo "  [fail] gst-launch exit=${GST_RC} elapsed_ns=${ELAPSED_NS} — see ${DS_ERR_LOG}" >&2
                continue
            fi
            had_valid_ds_run=true
        
            # Warn if the pipeline wrote anything to stderr — likely a plugin/config error
            if [ -s "${DS_ERR_LOG}" ]; then
                echo "  [warn] GStreamer stderr output captured — see ${DS_ERR_LOG} for details" >&2
            fi
        
            # awk instead of bc (bc is not installed in the DeepStream container; awk always is)
            ELAPSED_SEC=$(awk "BEGIN{printf \"%.2f\", ${ELAPSED_NS} / $NS_PER_SEC}")
            DS_IPS=$(awk "BEGIN{printf \"%.1f\", ${TOTAL_FRAMES} / ${ELAPSED_SEC}}")
            DS_FPS_STREAM=$(awk "BEGIN{printf \"%.1f\", ${DS_IPS} / ${BS}}")
            DS_REALTIME=$(awk "BEGIN{printf \"%.2f\", ${DS_FPS_STREAM} / ${FPS_THRESHOLD}}")
            DS_FPS_INT=$(echo "${DS_FPS_STREAM}" | cut -d. -f1)
            DS_IPS_INT=$(echo "${DS_IPS}" | cut -d. -f1)
        
            echo "  BS=${BS}: wall=${ELAPSED_SEC}s imgs/s=${DS_IPS} fps/stream=${DS_FPS_STREAM} realtime=${DS_REALTIME}x"
        
            if [ "${DS_FPS_INT}" -ge "${FPS_THRESHOLD}" ]; then
                best_bs="${BS}"
                best_fps_stream="${DS_FPS_STREAM}"
                best_ips="${DS_IPS_INT}"
                echo "  -> PASS (>=${FPS_THRESHOLD} fps/stream)"
                break   # highest candidate that passes is BS_OPT
            else
                echo "  -> FAIL (<${FPS_THRESHOLD} fps/stream), trying lower..."
            fi
        done
        
        # Abort if no candidate produced a valid DS run — emitting a default BS_OPT=1
        # in this case would mislead the caller into building a production engine on
        # top of a broken sweep.
        if [ "${had_valid_ds_run}" != true ]; then
            echo "ERROR: no valid DeepStream confirmation run completed — see ${DS_ERR_LOG}" >&2
            echo "       refusing to emit ${ENGINES_DIR}/bs_opt.txt" >&2
            exit 1
        fi
        
        # Write results
        echo ""
        echo "======================================================"
        echo "SWEEP COMPLETE"
        echo "  BS_OPT         = ${best_bs}"
        echo "  DS fps/stream  = ${best_fps_stream} (threshold: ${FPS_THRESHOLD})"
        echo "  DS imgs/sec    = ${best_ips}"
        echo "  trtexec alpha  = ${ALPHA} (curve steepness)"
        echo "  BS_pred was    = ${BS_PRED}"
        echo "======================================================"
        
        cat > "${ENGINES_DIR}/bs_opt.txt" << EOF
        BS_OPT=${best_bs}
        DS_FPS_PER_STREAM=${best_fps_stream}
        DS_IPS=${best_ips}
        TRT_ALPHA=${ALPHA}
        TRT_A_COEFF=${A_COEFF}
        BS_PRED=${BS_PRED}
        EOF
        
        # Print probe summary
        echo ""
        echo "Phase 1 trtexec probe summary:"
        echo "batch,qps,imgs_per_sec"
        for i in "${!PROBE_BS_ARR[@]}"; do
            BS="${PROBE_BS_ARR[$i]}"
            QPS="${PROBE_QPS_ARR[$i]}"
            IPS=$(awk "BEGIN{printf \"%d\", ${QPS} * ${BS}}")
            echo "${BS},${QPS},${IPS}"
        done
        
        echo "${best_bs}"
        
      • extract-frame.sh 1.9 KB
        #!/usr/bin/env bash
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        set -o pipefail
        ################################################################################
        # Step 6: Extract first frame from output video as PNG for visual inspection.
        #
        # Usage: ./extract-frame.sh <input_video> <output_png>
        # Example: ./extract-frame.sh yolox_output.mp4 yolox_frame_sample.png
        ################################################################################
        
        INPUT="$1"
        OUTPUT="$2"
        
        if [ -z "$INPUT" ] || [ -z "$OUTPUT" ]; then
            echo "Usage: $0 <input_video> <output_png>"
            exit 1
        fi
        
        if [[ "$INPUT" == *.ogv ]]; then
            gst-launch-1.0 \
                filesrc location="${INPUT}" ! oggdemux ! theoradec ! videoconvert ! "video/x-raw,format=RGB" ! \
                pngenc snapshot=true ! filesink location="${OUTPUT}" \
                2>&1 | grep -v "^$"
        else
            gst-launch-1.0 \
                filesrc location="${INPUT}" ! qtdemux ! queue ! h264parse ! queue ! nvv4l2decoder ! queue ! \
                nvvideoconvert ! "video/x-raw,format=RGB" ! videoconvert ! \
                pngenc snapshot=true ! filesink location="${OUTPUT}" \
                2>&1 | grep -v "^$"
        fi
        STATUS=$?
        
        if [ $STATUS -eq 0 ] && [ -f "$OUTPUT" ]; then
            echo "Frame extracted: ${OUTPUT} ($(ls -lh "$OUTPUT" | awk '{print $5}'))"
        else
            echo "ERROR: Pipeline failed with exit code $STATUS" >&2
            exit $STATUS
        fi
        
    • engine
      • benchmark-trtexec.sh 2.8 KB
        #!/usr/bin/env bash
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        ################################################################################
        # Step 8a: TensorRT benchmark using trtexec for arbitrary batch sizes.
        # Runs 10-second benchmarks and reports GPU compute time + throughput.
        #
        # Usage: ./benchmark-trtexec.sh <bs:engine> [<bs:engine> ...] [duration_sec]
        # Example: ./benchmark-trtexec.sh 1:yolox_nano_b1.engine 64:yolox_nano_b64.engine
        #          ./benchmark-trtexec.sh 1:b1.engine 64:b64.engine 20
        ################################################################################
        
        # Last plain-integer arg is treated as duration; all others are bs:engine pairs.
        DURATION=10
        ENGINE_PAIRS=()
        for arg in "$@"; do
            if [[ "$arg" =~ ^[0-9]+$ ]]; then
                DURATION="$arg"
            else
                ENGINE_PAIRS+=("$arg")
            fi
        done
        
        if [ ${#ENGINE_PAIRS[@]} -eq 0 ]; then
            echo "Usage: $0 <bs:engine> [<bs:engine> ...] [duration_sec]"
            echo "  e.g. $0 1:model_b1.engine 64:model_b64.engine"
            exit 1
        fi
        
        TRTEXEC="/usr/src/tensorrt/bin/trtexec"
        
        echo "=== TensorRT Benchmark ==="
        echo "Duration: ${DURATION}s per engine"
        echo ""
        
        for ENGINE_INFO in "${ENGINE_PAIRS[@]}"; do
            BATCH="${ENGINE_INFO%%:*}"
            ENGINE="${ENGINE_INFO#*:}"
        
            if [ ! -f "$ENGINE" ]; then
                echo "SKIP Batch ${BATCH}: ${ENGINE} not found"
                echo ""
                continue
            fi
        
            echo "--- Batch ${BATCH}: ${ENGINE} ---"
            OUTPUT=$($TRTEXEC --loadEngine="$ENGINE" --fp16 --duration="$DURATION" 2>&1)
        
            THROUGHPUT=$(echo "$OUTPUT" | grep "\[I\] Throughput:" | grep -oP 'Throughput: \K[0-9.]+')
            GPU_MEAN=$(echo "$OUTPUT" | grep "GPU Compute Time:" | grep -oP 'mean = \K[0-9.]+')
            GPU_MIN=$(echo "$OUTPUT" | grep "GPU Compute Time:" | grep -oP 'min = \K[0-9.]+')
            GPU_MAX=$(echo "$OUTPUT" | grep "GPU Compute Time:" | grep -oP 'max = \K[0-9.]+')
            IMGS_PER_SEC=$(awk "BEGIN{printf \"%d\", ${THROUGHPUT:-0} * $BATCH}" 2>/dev/null)  # awk: bc absent in container
        
            echo "  GPU Compute: ${GPU_MEAN} ms (min=${GPU_MIN}, max=${GPU_MAX})"
            echo "  Throughput:  ${THROUGHPUT} qps"
            echo "  Images/sec:  ${IMGS_PER_SEC}"
            echo ""
        done
        
        echo "=== Benchmark Complete ==="
        
    • model
      • cleanup.sh 2.7 KB
        #!/usr/bin/env bash
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        # cleanup.sh — Remove build/models artifacts for a given model name.
        # Validated replacement for ad-hoc directory removal after ONNX export.
        #
        # Only removes paths that:
        #   - are non-empty
        #   - exist
        #   - resolve under ./build/ or ./models/
        #   - match the given MODEL_NAME (regex-validated)
        #
        # Usage:
        #   bash cleanup.sh <MODEL_NAME> [--dry-run]
        #
        # Example:
        #   bash cleanup.sh yolov8n
        #   bash cleanup.sh yolov8n --dry-run
        set -euo pipefail
        
        MODEL_NAME="${1:-}"
        DRY_RUN=false
        if [[ "${2:-}" == "--dry-run" ]]; then
            DRY_RUN=true
        fi
        
        if [[ -z "$MODEL_NAME" ]]; then
            echo "Usage: $0 <MODEL_NAME> [--dry-run]" >&2
            exit 1
        fi
        
        if ! [[ "$MODEL_NAME" =~ ^[A-Za-z0-9._-]+$ ]]; then
            echo "ERROR: MODEL_NAME must match ^[A-Za-z0-9._-]+$ (got: $MODEL_NAME)" >&2
            exit 1
        fi
        
        # The regex above accepts "." and ".." — reject them explicitly since those
        # would make the candidate paths (build/.venv_$MODEL_NAME, models/$MODEL_NAME/*)
        # point at directories we don't own.
        if [[ "$MODEL_NAME" == "." || "$MODEL_NAME" == ".." ]]; then
            echo "ERROR: MODEL_NAME cannot be '.' or '..' (got: $MODEL_NAME)" >&2
            exit 1
        fi
        
        CWD="$(pwd -P)"
        
        # Paths eligible for removal — all are scoped under CWD's build/ or models/
        CANDIDATES=(
            "build/.venv_${MODEL_NAME}"
            "models/${MODEL_NAME}/hf_model"
            "models/${MODEL_NAME}/onnx_export"
        )
        
        echo "=== cleanup.sh — MODEL_NAME=$MODEL_NAME dry-run=$DRY_RUN ==="
        for rel in "${CANDIDATES[@]}"; do
            abs="$CWD/$rel"
            if [[ ! -e "$abs" ]]; then
                echo "  skip (not present): $rel"
                continue
            fi
        
            # Assert the resolved path is still under CWD's build/ or models/
            resolved="$(cd "$(dirname "$abs")" && pwd -P)/$(basename "$abs")"
            case "$resolved" in
                "$CWD"/build/*|"$CWD"/models/*) ;;
                *)
                    echo "  SKIP (outside build/ or models/): $resolved"
                    continue
                    ;;
            esac
        
            if $DRY_RUN; then
                echo "  [dry-run] rm -rf $resolved"
            else
                echo "  removing: $resolved"
                rm -rf -- "$resolved"
            fi
        done
        
        echo "Done."
        
      • config-to-labels.py 4.1 KB
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Copyright (c) 2026, NVIDIA CORPORATION.  All rights reserved.
        # Licensed under the Apache License, Version 2.0 (the "License").
        """Gate the architecture and write labels.txt, from a model's config.json.
        
        Single implementation shared by both acquisition routes. The HuggingFace path (Step 2b)
        and the NGC path (Step 2d) reach config.json at different points but need identical
        behaviour, and keeping two copies in the runbook meant the NGC route silently drifted —
        it extracted labels without ever rejecting non-detection architectures, so a
        classification model could reach engine build. See references/model-acquire.md.
        
        Usage:
            python3 config-to-labels.py --config <config.json> --labels <labels.txt>
            python3 config-to-labels.py --config <config.json> --labels <labels.txt> --skip-arch-check
        
        Exits non-zero on a non-detection architecture or a missing label map. Callers must treat
        a non-zero exit as fatal: never fall back to COCO, ImageNet, or any other default list.
        """
        from __future__ import annotations
        
        import argparse
        import json
        import sys
        from pathlib import Path
        
        # Detection heads this skill supports. DETR-family variants use the middle two.
        DETECTION_SUFFIXES = ("ForObjectDetection", "ForConditionalDetection", "ForZeroShotObjectDetection")
        NON_DETECTION_SUFFIXES = (
            "ForImageClassification", "ForSemanticSegmentation", "ForInstanceSegmentation",
            "ForPanopticSegmentation", "ForDepthEstimation", "ForMaskedLM",
            "ForTokenClassification", "ForCausalLM",
        )
        
        
        def assert_detection(cfg: dict) -> None:
            """Abort unless config.json declares an object-detection architecture."""
            arch_list = cfg.get("architectures") or []
            if not arch_list:
                print("[labels] config.json has no 'architectures' field; skipping architecture gate",
                      file=sys.stderr)
                return
            arch = arch_list[0]
            if arch.endswith(NON_DETECTION_SUFFIXES) or not arch.endswith(DETECTION_SUFFIXES):
                sys.exit(
                    f"ERROR: deepstream-import-vision-model currently supports object detection models "
                    f"only. Detected architecture: {arch}. Classification, segmentation, and other vision "
                    f"tasks are not yet supported."
                )
            print(f"[labels] architecture OK: {arch}")
        
        
        def extract_labels(cfg: dict) -> list[str]:
            """Pull the class list, trying each known config layout in order."""
            if "id2label" in cfg:                                    # standard HF layout
                return [cfg["id2label"][str(i)] for i in range(len(cfg["id2label"]))]
            if "label2id" in cfg:                                    # reversed map
                return [k for k, _ in sorted(cfg["label2id"].items(), key=lambda kv: kv[1])]
            if "names" in cfg:                                       # some YOLO repos
                names = cfg["names"]
                return [names[str(i)] for i in range(len(names))] if isinstance(names, dict) else list(names)
            sys.exit("ERROR: No label map found in config.json — cannot create labels.txt")
        
        
        def main() -> None:
            ap = argparse.ArgumentParser(description="Validate architecture and emit labels.txt.")
            ap.add_argument("--config", required=True, help="path to config.json")
            ap.add_argument("--labels", required=True, help="path to write labels.txt")
            ap.add_argument("--skip-arch-check", action="store_true",
                            help="emit labels without gating the architecture (diagnostics only)")
            args = ap.parse_args()
        
            try:
                cfg = json.loads(Path(args.config).read_text())
            except (OSError, json.JSONDecodeError) as exc:
                sys.exit(f"ERROR: could not read {args.config}: {exc}")
        
            if not args.skip_arch_check:
                assert_detection(cfg)
        
            labels = extract_labels(cfg)
            out = Path(args.labels)
            out.parent.mkdir(parents=True, exist_ok=True)
            out.write_text("\n".join(labels) + "\n")
            print(f"labels.txt: {len(labels)} classes -> {out}")
            print("  " + ", ".join(labels[:5]) + (" ..." if len(labels) > 5 else ""))
        
        
        if __name__ == "__main__":
            main()
        
      • hf-download-config.sh 2.2 KB
        #!/usr/bin/env bash
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        # hf-download-config.sh — Download config.json from a HuggingFace repo.
        # Safer replacement for the inline `curl -fsSL ... -o ...` snippet.
        #
        # Usage:
        #   bash hf-download-config.sh <HF_ORG> <MODEL_NAME> <DEST_PATH>
        #
        # Example:
        #   bash hf-download-config.sh onnx-community yolov8n models/yolov8n/config/config.json
        #
        # Honors $HF_TOKEN if set.
        set -euo pipefail
        
        HF_ORG="${1:-}"
        MODEL_NAME="${2:-}"
        DEST="${3:-}"
        
        if [[ -z "$HF_ORG" || -z "$MODEL_NAME" || -z "$DEST" ]]; then
            echo "Usage: $0 <HF_ORG> <MODEL_NAME> <DEST_PATH>" >&2
            exit 1
        fi
        
        for arg_name in HF_ORG MODEL_NAME; do
            val="${!arg_name}"
            if ! [[ "$val" =~ ^[A-Za-z0-9._/-]+$ ]]; then
                echo "ERROR: $arg_name contains invalid characters: $val" >&2
                exit 1
            fi
        done
        
        # DEST must be a relative path and must not contain .. segments
        # (prevents writes outside the project tree)
        case "$DEST" in
            /*)
                echo "ERROR: DEST_PATH must be relative (absolute paths are rejected): $DEST" >&2
                exit 1
                ;;
            *..*)
                echo "ERROR: DEST_PATH contains '..' — refusing: $DEST" >&2
                exit 1
                ;;
        esac
        
        URL="https://huggingface.co/${HF_ORG}/${MODEL_NAME}/resolve/main/config.json"
        
        CURL_OPTS=(-fsSL --proto '=https' --tlsv1.2 --max-time 60 -o "$DEST")
        if [[ -n "${HF_TOKEN:-}" ]]; then
            CURL_OPTS+=(-H "Authorization: Bearer ${HF_TOKEN}")
        fi
        
        mkdir -p "$(dirname "$DEST")"
        
        if ! curl "${CURL_OPTS[@]}" "$URL"; then
            echo "ERROR: config.json not found at ${HF_ORG}/${MODEL_NAME} — cannot extract labels" >&2
            exit 1
        fi
        
        echo "Downloaded: $DEST"
        
      • hf-list-files.sh 4.4 KB
        #!/usr/bin/env bash
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        # hf-list-files.sh — List model files in a HuggingFace repo.
        # Uses the HF tree API with validated inputs, HTTPS+TLSv1.2, and a bounded
        # timeout. Parses the JSON response via the stdlib json module (no shell pipe).
        #
        # Usage:
        #   bash hf-list-files.sh <HF_ORG> <MODEL_NAME> [subpath]
        #
        # Examples:
        #   bash hf-list-files.sh onnx-community yolov8n
        #   bash hf-list-files.sh onnx-community yolov8n onnx        # check /onnx subdir
        #
        # Honors $HF_TOKEN if set (passed as Authorization: Bearer header).
        set -euo pipefail
        
        HF_ORG="${1:-}"
        MODEL_NAME="${2:-}"
        SUBPATH="${3:-}"
        
        if [[ -z "$HF_ORG" || -z "$MODEL_NAME" ]]; then
            echo "Usage: $0 <HF_ORG> <MODEL_NAME> [subpath]" >&2
            exit 1
        fi
        
        # Input validation — reject anything that could escape the URL
        for arg_name in HF_ORG MODEL_NAME SUBPATH; do
            val="${!arg_name:-}"
            if [[ -n "$val" ]] && ! [[ "$val" =~ ^[A-Za-z0-9._/-]+$ ]]; then
                echo "ERROR: $arg_name contains invalid characters (must match ^[A-Za-z0-9._/-]+\$): $val" >&2
                exit 1
            fi
        done
        
        URL="https://huggingface.co/api/models/${HF_ORG}/${MODEL_NAME}/tree/main"
        [[ -n "$SUBPATH" ]] && URL="${URL}/${SUBPATH}"
        
        # -sS: silent progress but still surface errors on stderr
        # -w "%{http_code}": append HTTP status as the last 3 chars of the response body
        # Drop -f so curl doesn't exit non-zero on 4xx — we inspect the status ourselves
        # so 404 (missing subpath) can be distinguished from network/auth failures.
        CURL_OPTS=(-sS --proto '=https' --tlsv1.2 --max-time 30 -w '%{http_code}')
        if [[ -n "${HF_TOKEN:-}" ]]; then
            CURL_OPTS+=(-H "Authorization: Bearer ${HF_TOKEN}")
        fi
        
        # Separate exit-code capture from body so we can diagnose failures precisely.
        CURL_RC=0
        RESPONSE="$(curl "${CURL_OPTS[@]}" "$URL")" || CURL_RC=$?
        
        if [[ $CURL_RC -ne 0 ]]; then
            echo "ERROR: curl failed (exit $CURL_RC) while fetching $URL" >&2
            exit 1
        fi
        
        # -w appends the 3-digit status to the body; split them back apart.
        HTTP_CODE="${RESPONSE: -3}"
        JSON="${RESPONSE:0:${#RESPONSE}-3}"
        
        case "$HTTP_CODE" in
            200) ;;  # fall through to parsing
            404)
                # Acceptable: the requested subpath (e.g. /onnx) doesn't exist.
                exit 0
                ;;
            401|403)
                echo "ERROR: HTTP $HTTP_CODE from HuggingFace for $URL (auth/permission)" >&2
                exit 1
                ;;
            *)
                echo "ERROR: HTTP $HTTP_CODE from HuggingFace for $URL" >&2
                exit 1
                ;;
        esac
        
        # 200 but empty body is unexpected — surface it rather than silently swallow.
        if [[ -z "$JSON" ]]; then
            echo "ERROR: HTTP 200 but empty body from $URL" >&2
            exit 1
        fi
        
        # Parse via python3 (json module is stdlib). Each line: <path>
        python3 - "$JSON" <<'PYEOF'
        import json, sys
        data = sys.argv[1]
        try:
            entries = json.loads(data)
        except json.JSONDecodeError as e:
            # Surface the decode error so callers can distinguish "empty repo" from
            # "HF returned something we can't parse" (upstream format change, captive
            # portal HTML, etc.). Truncate the raw data so we don't dump a multi-MB
            # response into logs.
            preview = data[:500] + ("... [truncated]" if len(data) > 500 else "")
            print(f"ERROR: failed to parse JSON from HuggingFace API: {e}", file=sys.stderr)
            print(f"  raw response: {preview!r}", file=sys.stderr)
            sys.exit(1)
        if not isinstance(entries, list):
            preview = repr(entries)[:500]
            print(
                f"ERROR: unexpected response type from HuggingFace API: "
                f"{type(entries).__name__} (expected list)",
                file=sys.stderr,
            )
            print(f"  contents: {preview}", file=sys.stderr)
            sys.exit(1)
        # Empty list is valid (directory exists but has no files) — exit 0 silently.
        for e in entries:
            p = e.get("path") if isinstance(e, dict) else None
            if p:
                print(p)
        PYEOF
        
      • inspect-onnx.py 3.7 KB
        #!/usr/bin/env python3
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        """
        Step 1: Inspect an ONNX model — inputs, outputs, opset, operators, validity.
        Usage: python3 inspect-onnx.py <onnx_file>
        """
        import sys
        import onnx
        
        if len(sys.argv) != 2:
            print(f"Usage: {sys.argv[0]} <onnx_file>")
            sys.exit(1)
        
        try:
            model = onnx.load(sys.argv[1])
        except FileNotFoundError:
            print(f"Error: File '{sys.argv[1]}' not found")
            sys.exit(1)
        except Exception as e:
            print(f"Error loading ONNX model: {e}")
            sys.exit(1)
        
        print("=== ONNX Model Info ===")
        print(f"File:     {sys.argv[1]}")
        opset_ver = model.opset_import[0].version if model.opset_import else "N/A"
        print(f"Opset:    {opset_ver}")
        print(f"IR ver:   {model.ir_version}")
        print(f"Producer: {model.producer_name} {model.producer_version}")
        
        graph = getattr(model, "graph", None)
        if graph is None:
            print("Error: ONNX model has no graph")
            sys.exit(1)
        
        print(f"Nodes:    {len(graph.node)}")
        
        dtype_map = {1: "float32", 10: "float16", 7: "int64", 6: "int32", 9: "bool"}
        
        print("\n=== INPUTS ===")
        for inp in graph.input:
            shape = [d.dim_value if d.dim_value else d.dim_param for d in inp.type.tensor_type.shape.dim]
            dtype = dtype_map.get(inp.type.tensor_type.elem_type, inp.type.tensor_type.elem_type)
            print(f"  {inp.name}: shape={shape}, dtype={dtype}")
        
        print("\n=== OUTPUTS ===")
        for out in graph.output:
            shape = [d.dim_value if d.dim_value else d.dim_param for d in out.type.tensor_type.shape.dim]
            dtype = dtype_map.get(out.type.tensor_type.elem_type, out.type.tensor_type.elem_type)
            print(f"  {out.name}: shape={shape}, dtype={dtype}")
        
        print("\n=== Operators ===")
        op_types = sorted(set(n.op_type for n in graph.node))
        print(f"  {', '.join(op_types)}")
        print(f"  Total unique ops: {len(op_types)}")
        
        try:
            onnx.checker.check_model(model)
            print("\n✓ ONNX model is valid")
        except Exception as e:
            print(f"\n✗ ONNX validation error: {e}")
        
        # --- Machine-parseable summary (consumed by the engine-build and pipeline-run phases) ---
        # grep patterns expect lines: "input_name: <name>", "height: <int>", "width: <int>"
        print("\n=== Machine-Parseable Summary ===")
        if graph and graph.input:
            inp = graph.input[0]
            dims = inp.type.tensor_type.shape.dim
            print(f"input_name: {inp.name}")
            if len(dims) >= 4:
                # Assume NCHW: dim[0]=batch, dim[1]=channels, dim[2]=H, dim[3]=W
                h_val = dims[2].dim_value  # 0 means dynamic
                w_val = dims[3].dim_value
                if h_val > 0 and w_val > 0:
                    print(f"height: {h_val}")
                    print(f"width: {w_val}")
                else:
                    # Dynamic spatial dims — print symbol so callers can detect failure
                    print(f"height: DYNAMIC (symbol={dims[2].dim_param or 'unknown'})")
                    print(f"width: DYNAMIC (symbol={dims[3].dim_param or 'unknown'})")
                    print("WARNING: Dynamic H/W — set height and width manually in trtexec flags")
            else:
                print(f"WARNING: Input has {len(dims)} dims — expected 4 (NCHW); cannot auto-detect H/W")
        else:
            print("WARNING: No inputs found in ONNX graph")
        
      • make-static-batch-onnx.py 2.7 KB
        #!/usr/bin/env python3
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        """
        Step 7: Create static-batch ONNX files from a batch-1 ONNX.
        Patches input/output batch dims and internal Reshape nodes.
        
        Usage: python3 make-static-batch-onnx.py <src_onnx> <dst_onnx> <batch_size>
        Example: python3 make-static-batch-onnx.py yolox_nano.onnx b16/yolox_nano_b16.onnx 16
        """
        import sys
        import onnx
        import numpy as np
        from onnx import numpy_helper
        
        if len(sys.argv) != 4:
            print(f"Usage: {sys.argv[0]} <src_onnx> <dst_onnx> <batch_size>")
            sys.exit(1)
        
        src_path = sys.argv[1]
        dst_path = sys.argv[2]
        try:
            batch_size = int(sys.argv[3])
            if batch_size <= 0:
                raise ValueError("Batch size must be positive")
        except ValueError as e:
            print(f"Error: Invalid batch size '{sys.argv[3]}': {e}")
            sys.exit(1)
        
        try:
            model = onnx.load(src_path)
        except FileNotFoundError:
            print(f"Error: File '{src_path}' not found")
            sys.exit(1)
        except Exception as e:
            print(f"Error loading ONNX model: {e}")
            sys.exit(1)
        
        graph = getattr(model, "graph", None)
        if graph is None:
            print("Error: ONNX model has no graph")
            sys.exit(1)
        
        # Set static batch on inputs
        for inp in graph.input:
            if len(inp.type.tensor_type.shape.dim) > 0:
                inp.type.tensor_type.shape.dim[0].dim_param = ""
                inp.type.tensor_type.shape.dim[0].dim_value = batch_size
        
        # Set static batch on outputs
        for out in graph.output:
            if len(out.type.tensor_type.shape.dim) > 0:
                out.type.tensor_type.shape.dim[0].dim_param = ""
                out.type.tensor_type.shape.dim[0].dim_value = batch_size
        
        # Fix Reshape nodes that reference batch=1
        for node in graph.node:
            if node.op_type == "Reshape":
                shape_input = node.input[1]
                for init in graph.initializer:
                    if init.name == shape_input:
                        shape_data = numpy_helper.to_array(init).copy()
                        if shape_data.size > 0 and shape_data[0] == 1:
                            shape_data[0] = batch_size
                            init.CopyFrom(numpy_helper.from_array(shape_data, name=init.name))
        
        onnx.save(model, dst_path)
        print(f"Saved {dst_path} with batch={batch_size}")
        
      • ngc-download.sh 3.8 KB
        #!/usr/bin/env bash
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        # ngc-download.sh — Download all files from a public NGC model version.
        # Prefers the official `ngc` CLI. Falls back to authenticated HTTPS via curl
        # only when the CLI is not installed; the fallback is explicitly warned about.
        #
        # Usage:
        #   bash ngc-download.sh <NGC_ORG> <NGC_TEAM> <MODEL_NAME> <NGC_VERSION> <DEST_DIR>
        #
        # Example:
        #   bash ngc-download.sh nvidia tao peoplenet trainable_v2.6 models/peoplenet/ngc_download
        set -euo pipefail
        
        NGC_ORG="${1:-}"
        NGC_TEAM="${2:-}"
        MODEL_NAME="${3:-}"
        NGC_VERSION="${4:-}"
        DEST_DIR="${5:-}"
        
        if [[ -z "$NGC_ORG" || -z "$MODEL_NAME" || -z "$NGC_VERSION" || -z "$DEST_DIR" ]]; then
            echo "Usage: $0 <NGC_ORG> <NGC_TEAM> <MODEL_NAME> <NGC_VERSION> <DEST_DIR>" >&2
            echo "  NGC_TEAM may be empty-string if the model has no team segment." >&2
            exit 1
        fi
        
        for var in NGC_ORG MODEL_NAME NGC_VERSION; do
            val="${!var}"
            if ! [[ "$val" =~ ^[A-Za-z0-9._-]+$ ]]; then
                echo "ERROR: $var contains invalid characters: $val" >&2
                exit 1
            fi
        done
        if [[ -n "$NGC_TEAM" ]] && ! [[ "$NGC_TEAM" =~ ^[A-Za-z0-9._-]+$ ]]; then
            echo "ERROR: NGC_TEAM contains invalid characters: $NGC_TEAM" >&2
            exit 1
        fi
        
        case "$DEST_DIR" in
            ""|"/"|*..*)
                echo "ERROR: invalid DEST_DIR: $DEST_DIR" >&2
                exit 1
                ;;
        esac
        
        mkdir -p "$DEST_DIR"
        
        # Preferred: ngc CLI (authenticated, verified)
        if command -v ngc >/dev/null 2>&1 && ngc --version >/dev/null 2>&1; then
            if [[ -n "$NGC_TEAM" ]]; then
                SPEC="${NGC_ORG}/${NGC_TEAM}/${MODEL_NAME}:${NGC_VERSION}"
            else
                SPEC="${NGC_ORG}/${MODEL_NAME}:${NGC_VERSION}"
            fi
            echo "Using ngc CLI to download $SPEC -> $DEST_DIR"
            ngc registry model download-version "$SPEC" --dest "$DEST_DIR"
            exit 0
        fi
        
        # Fallback: HTTPS via curl, public NGC catalog API only
        echo "WARNING: ngc CLI not available — falling back to unauthenticated HTTPS for public files." >&2
        echo "  For gated/private models, install the ngc CLI: https://ngc.nvidia.com/setup/installers/cli" >&2
        
        if [[ -n "$NGC_TEAM" ]]; then
            NGC_BASE="https://api.ngc.nvidia.com/v2/models/${NGC_ORG}/${NGC_TEAM}/${MODEL_NAME}/versions/${NGC_VERSION}/files"
        else
            NGC_BASE="https://api.ngc.nvidia.com/v2/models/${NGC_ORG}/${MODEL_NAME}/versions/${NGC_VERSION}/files"
        fi
        
        SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
        FILES="$("$SCRIPT_DIR/ngc-list-files.sh" "$NGC_ORG" "$NGC_TEAM" "$MODEL_NAME" "$NGC_VERSION")"
        
        if [[ -z "$FILES" ]]; then
            echo "ERROR: No files returned from NGC catalog" >&2
            exit 1
        fi
        
        echo "NGC files available:"
        echo "$FILES"
        
        while IFS= read -r FNAME; do
            [[ -z "$FNAME" ]] && continue
            # Skip anything with traversal characters
            case "$FNAME" in
                */..*|..*|*..|/*)
                    echo "  skipping suspicious filename: $FNAME"
                    continue
                    ;;
            esac
            DEST_PATH="$DEST_DIR/$FNAME"
            mkdir -p "$(dirname "$DEST_PATH")"
            echo "Downloading: $FNAME"
            if ! curl -fsSL --proto '=https' --tlsv1.2 --max-time 600 \
                     -o "$DEST_PATH" "${NGC_BASE}/${FNAME}"; then
                echo "  WARNING: failed to download $FNAME — skipping"
            fi
        done <<< "$FILES"
        
        echo "Done. Files in $DEST_DIR:"
        ls -lh "$DEST_DIR" 2>/dev/null || true
        
      • ngc-list-files.sh 2.7 KB
        #!/usr/bin/env bash
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        # ngc-list-files.sh — List files in a public NGC model version.
        # Safer replacement for the inline curl+python snippet.
        #
        # Usage:
        #   bash ngc-list-files.sh <NGC_ORG> <NGC_TEAM> <MODEL_NAME> <NGC_VERSION>
        #
        # Example:
        #   bash ngc-list-files.sh nvidia tao peoplenet trainable_v2.6
        #
        # Output: one filename per line.
        set -euo pipefail
        
        NGC_ORG="${1:-}"
        NGC_TEAM="${2:-}"
        MODEL_NAME="${3:-}"
        NGC_VERSION="${4:-}"
        
        if [[ -z "$NGC_ORG" || -z "$MODEL_NAME" || -z "$NGC_VERSION" ]]; then
            echo "Usage: $0 <NGC_ORG> <NGC_TEAM> <MODEL_NAME> <NGC_VERSION>" >&2
            echo "  NGC_TEAM may be empty-string if the model has no team segment." >&2
            exit 1
        fi
        
        for var in NGC_ORG MODEL_NAME NGC_VERSION; do
            val="${!var}"
            if ! [[ "$val" =~ ^[A-Za-z0-9._-]+$ ]]; then
                echo "ERROR: $var contains invalid characters: $val" >&2
                exit 1
            fi
        done
        if [[ -n "$NGC_TEAM" ]] && ! [[ "$NGC_TEAM" =~ ^[A-Za-z0-9._-]+$ ]]; then
            echo "ERROR: NGC_TEAM contains invalid characters: $NGC_TEAM" >&2
            exit 1
        fi
        
        if [[ -n "$NGC_TEAM" ]]; then
            NGC_BASE="https://api.ngc.nvidia.com/v2/models/${NGC_ORG}/${NGC_TEAM}/${MODEL_NAME}/versions/${NGC_VERSION}/files"
        else
            NGC_BASE="https://api.ngc.nvidia.com/v2/models/${NGC_ORG}/${MODEL_NAME}/versions/${NGC_VERSION}/files"
        fi
        
        if ! JSON="$(curl -fsSL --proto '=https' --tlsv1.2 --max-time 30 "${NGC_BASE}/" 2>/dev/null)"; then
            echo "ERROR: Could not retrieve file list from NGC API" >&2
            echo "URL: ${NGC_BASE}/" >&2
            exit 1
        fi
        
        python3 - "$JSON" "${NGC_BASE}/" <<'PYEOF'
        import json, sys
        data, url = sys.argv[1], sys.argv[2]
        try:
            files = json.loads(data)
        except json.JSONDecodeError as e:
            print(f"ERROR parsing NGC file list: {e}", file=sys.stderr)
            sys.exit(1)
        if isinstance(files, list):
            names = [f.get("name", "") for f in files if isinstance(f, dict)]
        else:
            names = [f.get("name", "") for f in files.get("modelFiles", []) if isinstance(f, dict)]
        names = [n for n in names if n]
        if not names:
            print(f"ERROR: NGC API returned no model files at {url}", file=sys.stderr)
            sys.exit(1)
        for n in names:
            print(n)
        PYEOF
        
      • resolve-engine.sh 1.8 KB
        #!/usr/bin/env bash
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        
        # Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. Apache-2.0.
        #
        # Resolve the engine that batch scaling actually produced, plus the values derived from it.
        #
        # WHY a helper: Step 6/7 (pipeline-run.md) and Step 8 (report-generation.md) both need the same
        # engine, and both used to inline the same lookup. They drifted — pipeline-run.md selected with
        # `head -1` and so picked b1 while report-generation.md picked the highest MAX_BS, meaning the
        # benchmark and the report could describe different engines. One implementation cannot drift.
        #
        # Selection rule: `sort -V | tail -1` takes the HIGHEST MAX_BS, i.e. the final engine from
        # iterative scaling. A lexicographic `head -1` would take b1 out of b1/b16/b8.
        #
        # USAGE — evaluate the output to set ENGINE / MAX_BS / MODEL_FILENAME in the caller's shell:
        #     eval "$(bash .claude/skills/deepstream-import-vision-model/scripts/model/resolve-engine.sh "$MODEL_NAME")"
        #
        # Exits non-zero with a message on stderr when no engine is present.
        set -euo pipefail
        
        MODEL_NAME="${1:?usage: resolve-engine.sh <MODEL_NAME>}"
        ENGINE_DIR="models/${MODEL_NAME}/benchmarks/engines"
        
        ENGINE=$(ls "${ENGINE_DIR}"/*_dynamic_b*.engine 2>/dev/null | sort -V | tail -1 || true)
        if [ -z "$ENGINE" ]; then
            echo "ERROR: No engine found in ${ENGINE_DIR}/ — run Steps 4-5 first (references/engine-build.md)" >&2
            exit 1
        fi
        
        MAX_BS=$(echo "$ENGINE" | grep -oP '_b\K[0-9]+(?=\.engine)')
        MODEL_FILENAME=$(basename "$ENGINE" | sed 's/_dynamic_b[0-9]*\.engine//')
        
        printf 'ENGINE=%q\n'          "$ENGINE"
        printf 'MAX_BS=%q\n'          "$MAX_BS"
        printf 'MODEL_FILENAME=%q\n'  "$MODEL_FILENAME"
        printf 'echo "Using engine: %s (MAX_BS=%s)"\n' "$ENGINE" "$MAX_BS"
        
      • safetensors-to-onnx.sh 3.6 KB
        #!/usr/bin/env bash
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        ################################################################################
        # Step 1 (alternate): Convert SafeTensors model to ONNX.
        #
        # Uses the shared venv's torch.onnx.export via safetensors_to_onnx.py. The former
        # optimum-cli path was removed: optimum held transformers below 4.54.0, which blocked
        # the releases that fix its RCE advisories, and optimum 2.1.0 dropped the `onnx`
        # subcommand outright.
        #
        # Writes <output_dir>/model.onnx — the same filename optimum produced.
        #
        # Usage: ./safetensors-to-onnx.sh <hf_model_id_or_path> <output_dir> [extra args]
        # Extra args are passed through to safetensors_to_onnx.py, e.g.
        #   --opset 17 · --image-size 640 · --max-batch 16 · --static-batch
        # Examples:
        #   ./safetensors-to-onnx.sh PekingU/rtdetr_r50vd ./onnx_export
        #   ./safetensors-to-onnx.sh facebook/detr-resnet-50 ./onnx_export --opset 17
        #   ./safetensors-to-onnx.sh ./local_model_dir ./onnx_export --image-size 800
        ################################################################################
        set -euo pipefail
        
        if [ $# -lt 2 ]; then
            echo "Usage: $0 <hf_model_id_or_path> <output_dir> [extra args for safetensors_to_onnx.py]"
            echo ""
            echo "Examples:"
            echo "  $0 PekingU/rtdetr_r50vd ./onnx_export"
            echo "  $0 facebook/detr-resnet-50 ./onnx_export --opset 17"
            exit 1
        fi
        
        MODEL="$1"
        OUTPUT_DIR="$2"
        shift 2
        EXTRA_ARGS=("$@")
        
        SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
        # Installed layout is <root>/.claude/skills/deepstream-import-vision-model/scripts/model/, so the
        # working root (where setup.sh created build/.venv_optimum) is five levels up from this script.
        REPO_ROOT="$(cd "$SCRIPT_DIR/../../../../.." && pwd)"
        mkdir -p "$REPO_ROOT/build"
        VENV_DIR="$REPO_ROOT/build/.venv_optimum"
        VENV_PY="$VENV_DIR/bin/python"
        
        echo "=== SafeTensors → ONNX Export ==="
        echo "Model:      $MODEL"
        echo "Output dir: $OUTPUT_DIR"
        echo "Extra args: ${EXTRA_ARGS[*]-}"
        echo "Venv:       $VENV_DIR"
        echo ""
        
        # The venv is built ONCE, IN THE CONTAINER, by setup.sh — with virtualenv, because the DeepStream
        # container's python lacks ensurepip (so `python3 -m venv` would fail here). Reuse it; never recreate.
        if [ ! -x "$VENV_PY" ]; then
            echo "ERROR: venv not found at $VENV_DIR — run the one-time bootstrap first (in-container):" >&2
            echo "  docker run --rm -it --gpus all --shm-size=16g -v \"\$PWD\":/work -w /work \\" >&2
            echo "    --entrypoint bash nvcr.io/nvidia/deepstream:9.1-triton-multiarch \\" >&2
            echo "    .claude/skills/deepstream-import-vision-model/setup.sh" >&2
            exit 1
        fi
        echo "Using venv: $VENV_DIR"
        echo ""
        
        # ${arr[@]+"${arr[@]}"} expands to nothing when the array is empty. Plain "${arr[@]-}"
        # would pass a single empty-string argument instead, which argparse rejects.
        "$VENV_PY" "$SCRIPT_DIR/safetensors_to_onnx.py" \
            --model "$MODEL" --output-dir "$OUTPUT_DIR" ${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"}
        
        echo ""
        echo "=== Export Complete ==="
        ls -lh "$OUTPUT_DIR"/*.onnx 2>/dev/null
        
      • safetensors_to_onnx.py 10 KB
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Copyright (c) 2026, NVIDIA CORPORATION.  All rights reserved.
        # Licensed under the Apache License, Version 2.0 (the "License").
        """SafeTensors -> ONNX export for object-detection models, via torch.onnx.export.
        
        Replaces the former `optimum-cli export onnx` path. optimum pinned transformers below
        4.54.0, which made it impossible to reach the transformers releases that fix the RCE
        advisories (5.3.0 / 5.5.0); optimum 2.1.0 also dropped the `onnx` subcommand entirely.
        
        Scope is object detection, matching the skill. Every supported DETR-family detector
        (DETR, Conditional DETR, Deformable DETR, RT-DETR, YOLOS) exposes the same contract:
        one `pixel_values` input and `logits` + `pred_boxes` outputs. That uniformity is what
        makes a single generic exporter sufficient here.
        
        Writes `<output_dir>/model.onnx` — the same filename optimum produced, so downstream
        steps are unchanged.
        """
        from __future__ import annotations
        
        import argparse
        import json
        import sys
        from pathlib import Path
        
        import torch
        from transformers import AutoConfig, AutoModelForObjectDetection
        
        DEFAULT_SIZE = 640
        DETECTION_SUFFIXES = ("ForObjectDetection", "ForConditionalDetection", "ForZeroShotObjectDetection")
        
        
        def resolve_input_size(model_dir: str, override: int | None) -> tuple[int, int]:
            """Pick the export resolution: explicit override, else preprocessor_config.json, else 640."""
            if override:
                return override, override
        
            cfg_path = Path(model_dir) / "preprocessor_config.json"
            if cfg_path.is_file():
                try:
                    size = json.loads(cfg_path.read_text()).get("size") or {}
                except (json.JSONDecodeError, OSError):
                    size = {}
                if isinstance(size, dict):
                    if "height" in size and "width" in size:
                        return int(size["height"]), int(size["width"])
                    # DETR-style resize: a single shortest/longest edge. Export square at the
                    # shortest edge — nvinfer feeds fixed-size letterboxed frames anyway.
                    edge = size.get("shortest_edge") or size.get("longest_edge")
                    if edge:
                        return int(edge), int(edge)
                elif isinstance(size, int):
                    return size, size
        
            return DEFAULT_SIZE, DEFAULT_SIZE
        
        
        def assert_detection_architecture(model_id: str, revision: str) -> None:
            """Fail fast on non-detection models, matching the skill's stated scope."""
            try:
                config = AutoConfig.from_pretrained(model_id, revision=revision)
            except Exception as exc:  # noqa: BLE001 - surface the loader's own message
                sys.exit(f"ERROR: could not read model config for {model_id!r}: {exc}")
        
            arch_list = getattr(config, "architectures", None) or []
            if arch_list and not any(a.endswith(DETECTION_SUFFIXES) for a in arch_list):
                sys.exit(
                    f"ERROR: deepstream-import-vision-model supports object detection models only. "
                    f"Detected architecture: {arch_list[0]}. Classification, segmentation, and other "
                    f"vision tasks are not supported."
                )
        
        
        class DetectionWrapper(torch.nn.Module):
            """Reduce the HF output object to the two tensors DeepStream's parser consumes."""
        
            def __init__(self, model: torch.nn.Module) -> None:
                super().__init__()
                self.model = model
        
            def forward(self, pixel_values: torch.Tensor):
                out = self.model(pixel_values=pixel_values)
                return out.logits, out.pred_boxes
        
        
        def consolidate_external_data(onnx_path: Path) -> None:
            """Fold any sidecar `.onnx.data` back into the model file.
        
            torch.onnx.export splits tensors out for large models; trtexec expects one file.
            """
            sidecar = onnx_path.with_suffix(onnx_path.suffix + ".data")
            if not sidecar.exists():
                return
            import onnx
        
            model = onnx.load(str(onnx_path), load_external_data=True)
            onnx.save(model, str(onnx_path))
            sidecar.unlink()
            print(f"[export] consolidated external data ({sidecar.name}) into {onnx_path.name}")
        
        
        def verify(onnx_path: Path) -> bool:
            """Assert the DeepStream input/output contract; return whether batch stayed dynamic.
        
            Neither backend reliably honours a dynamic batch dimension: the dynamo exporter can
            specialize it where the model's own code captures `shape[0]`, so it is checked rather
            than assumed.
            """
            import onnx
        
            model = onnx.load(str(onnx_path))
            onnx.checker.check_model(model)
        
            inputs = {i.name: i for i in model.graph.input}
            outputs = [o.name for o in model.graph.output]
            print(f"[export] inputs={list(inputs)} outputs={outputs}")
        
            if "pixel_values" not in inputs:
                sys.exit(f"ERROR: exported graph has no 'pixel_values' input (got {list(inputs)})")
            for required in ("logits", "pred_boxes"):
                if required not in outputs:
                    sys.exit(f"ERROR: exported graph is missing the '{required}' output (got {outputs})")
        
            dims = inputs["pixel_values"].type.tensor_type.shape.dim
            shape = [d.dim_param or d.dim_value for d in dims]
            print(f"[export] pixel_values shape={shape}")
            return bool(dims[0].dim_param)
        
        
        def export_dynamo(wrapper, h, w, onnx_path, opset, static_batch, max_batch) -> None:
            """torch.export-based exporter. Handles models the TorchScript tracer cannot."""
            # Trace with batch=2 so torch.export cannot specialize the dimension to the constant 1.
            dummy = torch.randn(1 if static_batch else 2, 3, h, w)
            shapes = None if static_batch else {
                "pixel_values": {0: torch.export.Dim("batch", min=1, max=max_batch)},
            }
            torch.onnx.export(
                wrapper, (dummy,), str(onnx_path), dynamic_shapes=shapes, dynamo=True,
                input_names=["pixel_values"], output_names=["logits", "pred_boxes"],
                opset_version=opset,
            )
        
        
        def export_torchscript(wrapper, h, w, onnx_path, opset, static_batch, max_batch) -> None:
            """Legacy TorchScript exporter.
        
            Still the more reliable path for a dynamic batch dimension on DETR-family detectors —
            RT-DETR specializes batch under dynamo but stays dynamic here. It does fail on
            text-conditioned models (Grounding DINO and friends), which is what dynamo is for.
            """
            axes = None if static_batch else {
                "pixel_values": {0: "batch"}, "logits": {0: "batch"}, "pred_boxes": {0: "batch"},
            }
            torch.onnx.export(
                wrapper, torch.randn(1, 3, h, w), str(onnx_path), dynamic_axes=axes,
                do_constant_folding=True, dynamo=False,
                input_names=["pixel_values"], output_names=["logits", "pred_boxes"],
                opset_version=opset,
            )
        
        
        def main() -> None:
            ap = argparse.ArgumentParser(description="Export a HuggingFace detection model to ONNX.")
            ap.add_argument("--model", required=True, help="HF model id or local model directory")
            ap.add_argument("--output-dir", required=True, help="directory to write model.onnx into")
            # 18, not 17: the dynamo exporter implements >=18 and auto-upgrades anyway, and its
            # downgrade path fails outright on Resize ("No Adapter To Version 17 for Resize").
            # Opset 18 is compatible with TRT 10.16 — see references/engine-build.md.
            ap.add_argument("--opset", type=int, default=18, help="ONNX opset (default 18)")
            ap.add_argument("--image-size", type=int, default=None,
                            help="square export size; default reads preprocessor_config.json, else 640")
            ap.add_argument("--max-batch", type=int, default=16,
                            help="upper bound for the dynamic batch dimension (default 16)")
            ap.add_argument("--static-batch", action="store_true",
                            help="export a fixed batch-1 graph instead of a dynamic batch dimension")
            ap.add_argument("--revision", default="main",
                            help="Hub revision (branch, tag, or commit SHA). Pin a SHA for reproducible builds.")
            ap.add_argument("--legacy-torchscript", action="store_true",
                            help="force the TorchScript exporter instead of trying dynamo first")
            args = ap.parse_args()
        
            assert_detection_architecture(args.model, args.revision)
        
            h, w = resolve_input_size(args.model, args.image_size)
            print(f"[export] model={args.model}  input=1x3x{h}x{w}  opset={args.opset}")
        
            model = AutoModelForObjectDetection.from_pretrained(args.model, revision=args.revision)
            # .eval() on the wrapper too — it is a fresh nn.Module and defaults to training mode,
            # which the exporter warns about and which changes dropout/batchnorm behaviour.
            wrapper = DetectionWrapper(model).eval()
        
            out_dir = Path(args.output_dir)
            out_dir.mkdir(parents=True, exist_ok=True)
            onnx_path = out_dir / "model.onnx"
        
            # Neither backend handles every architecture. Try dynamo first (it copes with models the
            # tracer chokes on), then fall back to TorchScript when dynamo specializes the batch
            # dimension — which is what RT-DETR, the default model, does.
            strategies = [("legacy-torchscript", export_torchscript)] if args.legacy_torchscript \
                else [("dynamo", export_dynamo), ("legacy-torchscript", export_torchscript)]
        
            for index, (name, export_fn) in enumerate(strategies):
                last = index == len(strategies) - 1
                print(f"[export] backend={name}")
                try:
                    export_fn(wrapper, h, w, onnx_path, args.opset, args.static_batch, args.max_batch)
                except Exception as exc:  # noqa: BLE001 - report and try the next backend
                    if last:
                        sys.exit(f"ERROR: {name} export failed: {exc}")
                    print(f"[export] {name} failed ({type(exc).__name__}); trying the next backend")
                    continue
        
                consolidate_external_data(onnx_path)
                if args.static_batch or verify(onnx_path):
                    break
                if last:
                    sys.exit(
                        "ERROR: every backend baked in a static batch dimension. Re-run with "
                        "--static-batch and build a fixed-batch engine for this model."
                    )
                print(f"[export] {name} produced a static batch dimension; trying the next backend")
        
            size_mb = onnx_path.stat().st_size / (1024 * 1024)
            print(f"[export] wrote {onnx_path} ({size_mb:.1f} MB)")
        
        
        if __name__ == "__main__":
            main()
        
    • report
      • generate-benchmark-charts.py 10.8 KB
        #!/usr/bin/env python3
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        """
        Step 8: Generate exactly 5 benchmark charts as PNG images for the report.
        
        Usage: python3 generate-benchmark-charts.py <output_dir> <json_data_file>
        
        Expected JSON format (benchmark_data.json written by report-generation skill pre-flight):
        {
          "model_name": "yolo26_nano",
          "engine": "models/yolo26_nano/benchmarks/engines/yolo26n_dynamic_b256.engine",
          "max_bs": 256,
          "trtexec": {
            "bs1":   {"qps": 220.5, "gpu_mean_ms": 4.53},
            "bsmax": {"qps": 39.2,  "gpu_mean_ms": 103.7, "p99_ms": 105.1, "imgs_per_sec": 10035.2}
          },
          "peak_gpu_streams": 334,
          "deepstream": {
            "run1": {"streams": 334, "total_fps": 7850.0, "fps_per_stream": 23.5},
            "run2": {"streams": 238, "total_fps": 7378.4, "fps_per_stream": 31.0}
          }
        }
        
        Outputs (fixed names — do not rename):
          chart_trtexec_bs1_vs_bsmax.png   — grouped bar: QPS BS=1 vs BS=MAX_BS
          chart_trtexec_throughput.png     — bar: imgs/sec at MAX_BS + PEAK_GPU_STREAMS annotation
          chart_ds_streams_vs_fps.png      — line: stream count vs fps/stream, 30fps threshold
          chart_trt_vs_ds.png              — grouped bar: trtexec vs DS Run1 vs DS Run2 total imgs/s
          chart_efficiency.png             — bar: DS Run1 and Run2 pipeline efficiency %
        """
        import sys
        import json
        import os
        import matplotlib
        matplotlib.use('Agg')
        import matplotlib.pyplot as plt
        
        plt.rcParams.update({
            'figure.facecolor': 'white',
            'axes.facecolor': '#FAFAFA',
            'axes.grid': True,
            'grid.alpha': 0.3,
            'font.size': 11,
            'axes.titlesize': 13,
            'axes.titleweight': 'bold',
        })
        
        COLORS = {
            'blue':   '#2196F3',
            'green':  '#4CAF50',
            'orange': '#FF9800',
            'pink':   '#E91E63',
            'purple': '#9C27B0',
            'teal':   '#00BCD4',
            'red':    '#FF5722',
        }
        
        
        def two_line_title(model_name, subtitle):
            """Two-line title: model name (line 1) + subtitle (line 2)."""
            return f'{model_name}\n{subtitle}'
        
        
        def chart_trtexec_bs1_vs_bsmax(data, output_dir):
            """Grouped bar chart: QPS at BS=1 vs BS=MAX_BS side by side."""
            max_bs = data['max_bs']
            qps_bs1   = data['trtexec']['bs1']['qps']
            qps_bsmax = data['trtexec']['bsmax']['qps']
        
            labels = ['BS=1', f'BS={max_bs}']
            values = [qps_bs1, qps_bsmax]
            colors = [COLORS['blue'], COLORS['green']]
        
            fig, ax = plt.subplots(figsize=(10, 6))
            bars = ax.bar(labels, values, color=colors, width=0.5, edgecolor='white', linewidth=1.5)
            for bar, val in zip(bars, values):
                ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + max(values) * 0.01,
                        f'{val:.1f}', ha='center', va='bottom', fontweight='bold', fontsize=13)
            ax.set_ylabel('QPS (queries/sec)', fontsize=13)
            ax.set_ylim(0, max(values) * 1.18)
            ax.grid(axis='y', alpha=0.3)
            ax.set_title(two_line_title(data['model_name'], f'trtexec QPS: BS=1 vs BS={max_bs}'))
            plt.tight_layout()
            out = os.path.join(output_dir, 'chart_trtexec_bs1_vs_bsmax.png')
            fig.savefig(out, dpi=150)
            plt.close(fig)
            print(f'  chart_trtexec_bs1_vs_bsmax.png')
        
        
        def chart_trtexec_throughput(data, output_dir):
            """Single bar: GPU-only imgs/sec at MAX_BS with PEAK_GPU_STREAMS annotation."""
            max_bs          = data['max_bs']
            imgs_per_sec    = data['trtexec']['bsmax']['imgs_per_sec']
            peak_streams    = data['peak_gpu_streams']
            realtime_imgs   = peak_streams * 30  # the throughput that satisfies peak_streams at 30fps
        
            fig, ax = plt.subplots(figsize=(10, 6))
            bar = ax.bar([f'BS={max_bs}'], [imgs_per_sec], color=COLORS['blue'], width=0.4,
                         edgecolor='white', linewidth=1.5)
            ax.text(bar[0].get_x() + bar[0].get_width() / 2, imgs_per_sec + imgs_per_sec * 0.01,
                    f'{imgs_per_sec:.0f}', ha='center', va='bottom', fontweight='bold', fontsize=13)
        
            # Annotation line at PEAK_GPU_STREAMS × 30fps threshold
            ax.axhline(y=realtime_imgs, color=COLORS['red'], linestyle='--', linewidth=2,
                       label=f'PEAK_GPU_STREAMS={peak_streams} × 30fps = {realtime_imgs:.0f} imgs/s')
            ax.text(0.98, realtime_imgs + imgs_per_sec * 0.01,
                    f'PEAK={peak_streams} streams',
                    ha='right', va='bottom', color=COLORS['red'], fontsize=10, fontweight='bold',
                    transform=ax.get_yaxis_transform())
        
            ax.set_ylabel('Images / sec', fontsize=13)
            ax.set_ylim(0, imgs_per_sec * 1.25)
            ax.grid(axis='y', alpha=0.3)
            ax.legend(loc='upper left', fontsize=10)
            ax.set_title(two_line_title(data['model_name'],
                                        f'GPU Throughput at BS={max_bs} (PEAK_GPU_STREAMS={peak_streams})'))
            plt.tight_layout()
            out = os.path.join(output_dir, 'chart_trtexec_throughput.png')
            fig.savefig(out, dpi=150)
            plt.close(fig)
            print(f'  chart_trtexec_throughput.png')
        
        
        def chart_ds_streams_vs_fps(data, output_dir):
            """Line chart: X=stream count, Y=fps/stream. Red dashed line at 30fps."""
            run1 = data['deepstream']['run1']
            run2 = data['deepstream']['run2']
        
            stream_counts = [run1['streams'], run2['streams']]
            fps_vals      = [run1['fps_per_stream'], run2['fps_per_stream']]
        
            # Sort by stream count ascending
            pairs = sorted(zip(stream_counts, fps_vals))
            stream_counts = [p[0] for p in pairs]
            fps_vals      = [p[1] for p in pairs]
        
            fig, ax = plt.subplots(figsize=(10, 6))
            ax.plot(stream_counts, fps_vals, color=COLORS['blue'], linewidth=2.5,
                    marker='o', markersize=10, zorder=4)
            for sc, fp in zip(stream_counts, fps_vals):
                ax.text(sc, fp + max(fps_vals) * 0.025,
                        f'{fp:.1f} fps', ha='center', va='bottom', fontweight='bold', fontsize=12)
        
            ax.axhline(y=30, color=COLORS['red'], linestyle='--', linewidth=2,
                       label='30 fps/stream real-time threshold')
        
            ax.set_xlabel('Stream Count', fontsize=13)
            ax.set_ylabel('FPS / Stream', fontsize=13)
            lower = -max(fps_vals) * 0.15
            ax.set_ylim(lower, max(fps_vals) * 1.3)
            ax.set_xticks(stream_counts)
            ax.grid(axis='y', alpha=0.3)
            ax.legend(loc='upper right', fontsize=10)
        
            # Label each point
            run_labels = {run1['streams']: 'Run 1\n(PEAK_GPU_STREAMS)',
                          run2['streams']: 'Run 2\n(RT_STREAMS)'}
            for sc in stream_counts:
                ax.annotate(run_labels.get(sc, ''), xy=(sc, 0), xytext=(sc, -max(fps_vals) * 0.12),
                            ha='center', fontsize=9, color='#555555')
        
            ax.set_title(two_line_title(data['model_name'], 'DeepStream: FPS/Stream vs Stream Count'))
            plt.tight_layout()
            out = os.path.join(output_dir, 'chart_ds_streams_vs_fps.png')
            fig.savefig(out, dpi=150)
            plt.close(fig)
            print(f'  chart_ds_streams_vs_fps.png')
        
        
        def chart_trt_vs_ds(data, output_dir):
            """Grouped bars: trtexec total imgs/s | DS Run 1 total imgs/s | DS Run 2 total imgs/s."""
            max_bs       = data['max_bs']
            trt_imgs     = data['trtexec']['bsmax']['imgs_per_sec']
            ds1_imgs     = data['deepstream']['run1']['total_fps']
            ds2_imgs     = data['deepstream']['run2']['total_fps']
            n1           = data['deepstream']['run1']['streams']
            n2           = data['deepstream']['run2']['streams']
        
            labels = [f'trtexec\nBS={max_bs}', f'DS Run 1\n({n1} streams)', f'DS Run 2\n({n2} streams)']
            values = [trt_imgs, ds1_imgs, ds2_imgs]
            colors = [COLORS['pink'], COLORS['blue'], COLORS['green']]
        
            fig, ax = plt.subplots(figsize=(10, 6))
            bars = ax.bar(labels, values, color=colors, width=0.5, edgecolor='white', linewidth=1.5)
            for bar, val in zip(bars, values):
                ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + max(values) * 0.01,
                        f'{val:.0f}', ha='center', va='bottom', fontweight='bold', fontsize=13)
            ax.set_ylabel('Total Images / sec', fontsize=13)
            ax.set_ylim(0, max(values) * 1.18)
            ax.grid(axis='y', alpha=0.3)
            ax.set_title(two_line_title(data['model_name'], 'trtexec vs DeepStream: Total Throughput'))
            plt.tight_layout()
            out = os.path.join(output_dir, 'chart_trt_vs_ds.png')
            fig.savefig(out, dpi=150)
            plt.close(fig)
            print(f'  chart_trt_vs_ds.png')
        
        
        def chart_efficiency(data, output_dir):
            """Bar chart: DS Run 1 and Run 2 pipeline efficiency %, dashed line at 100%."""
            trt_imgs  = data['trtexec']['bsmax']['imgs_per_sec']
            ds1_imgs  = data['deepstream']['run1']['total_fps']
            ds2_imgs  = data['deepstream']['run2']['total_fps']
            n1        = data['deepstream']['run1']['streams']
            n2        = data['deepstream']['run2']['streams']
        
            if trt_imgs <= 0:
                print("ERROR: trtexec imgs_per_sec is zero or negative — cannot compute efficiency", file=sys.stderr)
                sys.exit(1)
            eff1 = round(ds1_imgs / trt_imgs * 100, 1)
            eff2 = round(ds2_imgs / trt_imgs * 100, 1)
        
            labels = [f'DS Run 1\n({n1} streams)', f'DS Run 2\n({n2} streams)']
            values = [eff1, eff2]
            colors = [COLORS['purple'], COLORS['teal']]
        
            fig, ax = plt.subplots(figsize=(10, 6))
            bars = ax.bar(labels, values, color=colors, width=0.4, edgecolor='white', linewidth=1.5)
            ax.axhline(y=100, color='#333333', linestyle='--', linewidth=1.5, alpha=0.6,
                       label='100% efficiency')
            for bar, val in zip(bars, values):
                ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.5,
                        f'{val}%', ha='center', va='bottom', fontweight='bold', fontsize=13)
            ax.set_ylabel('DS Efficiency (%)', fontsize=13)
            ax.set_ylim(0, max(values) * 1.2)
            ax.grid(axis='y', alpha=0.3)
            ax.legend(loc='upper right', fontsize=10)
            ax.set_title(two_line_title(data['model_name'], 'DeepStream Pipeline Efficiency vs trtexec'))
            plt.tight_layout()
            out = os.path.join(output_dir, 'chart_efficiency.png')
            fig.savefig(out, dpi=150)
            plt.close(fig)
            print(f'  chart_efficiency.png')
        
        
        def main():
            if len(sys.argv) != 3:
                print(f"Usage: {sys.argv[0]} <output_dir> <json_data_file>")
                sys.exit(1)
        
            output_dir = sys.argv[1]
            json_file  = sys.argv[2]
        
            os.makedirs(output_dir, exist_ok=True)
        
            with open(json_file) as f:
                data = json.load(f)
        
            model = data.get('model_name', 'unknown')
            print(f"Generating 5 charts for {model} -> {output_dir}/")
            chart_trtexec_bs1_vs_bsmax(data, output_dir)
            chart_trtexec_throughput(data, output_dir)
            chart_ds_streams_vs_fps(data, output_dir)
            chart_trt_vs_ds(data, output_dir)
            chart_efficiency(data, output_dir)
            print("Done — 5 charts written.")
        
        
        if __name__ == "__main__":
            main()
        
      • latex-pdf-wrap.tex 1.6 KB · in bundle
      • md-to-html-pdf.py 7.1 KB
        #!/usr/bin/env python3
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        """
        Convert a GFM-style Markdown benchmark report to a styled HTML file and then
        to PDF via wkhtmltopdf.
        
        Images referenced as ![alt](file.png) are resolved relative to the markdown
        file's directory and embedded as base64 data URIs so the HTML is self-contained.
        
        Usage:
            python3 md-to-html-pdf.py <report.md> <style.css> <output_dir> [model_name]
        
            model_name (optional): if provided, PDF is named benchmark_report_{model_name}.pdf
                                   if omitted, derived from output_dir parent folder name
        
        Produces:
            <output_dir>/benchmark_report.html
            <output_dir>/benchmark_report_{model_name}.pdf
        """
        import sys
        import os
        import re
        import base64
        import shutil
        import subprocess
        import markdown
        
        def ensure_wkhtmltopdf():
            """wkhtmltopdf is installed by setup.sh via apt, but that runs in an ephemeral
            --rm container so the binary does NOT persist to later per-phase containers
            (only the /work-mounted venv does). If it is missing, install it here so the
            PDF step works on a fresh run without a manual reinstall."""
            if shutil.which('wkhtmltopdf'):
                return True
            apt = shutil.which('apt-get')
            if not apt:
                return False
            sudo = [] if os.geteuid() == 0 else (['sudo'] if shutil.which('sudo') else [])
            # Minimal environment rather than a copy of os.environ: apt-get needs only PATH and
            # HOME, and forwarding the full environment into a (possibly sudo-elevated) child
            # would carry loader hooks and apt overrides such as LD_PRELOAD or APT_CONFIG.
            env = {
                'PATH': os.environ.get('PATH', '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'),
                'HOME': os.environ.get('HOME', '/root'),
                'DEBIAN_FRONTEND': 'noninteractive',
            }
            print("  wkhtmltopdf missing — installing (in-container, one-time per container)...")
            for cmd in ([apt, 'update', '-qq'], [apt, 'install', '-y', '-qq', 'wkhtmltopdf']):
                subprocess.run(sudo + cmd, env=env,
                               stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=600)
            return shutil.which('wkhtmltopdf') is not None
        
        def embed_images(html: str, base_dir: str) -> str:
            """Replace <img src="file.png"> with base64-embedded data URIs."""
            def replacer(match):
                prefix = match.group(1)
                src = match.group(2)
                suffix = match.group(3)
                # Skip URLs and absolute paths
                if re.match(r'^(https?|data|ftp)://', src) or os.path.isabs(src):
                    return match.group(0)
                img_path = os.path.realpath(os.path.join(base_dir, src))
                base_real = os.path.realpath(base_dir)
                # Reject path traversal outside base_dir
                if not img_path.startswith(base_real + os.sep) and img_path != base_real:
                    return match.group(0)
                if os.path.isfile(img_path):
                    ext = os.path.splitext(src)[1].lstrip('.').lower()
                    mime = {'png': 'image/png', 'jpg': 'image/jpeg',
                            'jpeg': 'image/jpeg', 'svg': 'image/svg+xml',
                            'gif': 'image/gif'}.get(ext, 'image/png')
                    with open(img_path, 'rb') as f:
                        b64 = base64.b64encode(f.read()).decode()
                    return f'{prefix}data:{mime};base64,{b64}{suffix}'
                return match.group(0)
            return re.sub(r'(<img\s[^>]*src=["\'])([^"\']+)(["\'])', replacer, html)
        
        def main():
            if len(sys.argv) not in (4, 5):
                print(f"Usage: {sys.argv[0]} <report.md> <style.css> <output_dir> [model_name]")
                sys.exit(1)
        
            md_path = sys.argv[1]
            css_path = sys.argv[2]
            out_dir = sys.argv[3]
            os.makedirs(out_dir, exist_ok=True)
        
            # Derive model name: explicit arg > parent-of-output_dir > "model"
            if len(sys.argv) == 5:
                model_name = sys.argv[4]
            else:
                # output_dir is typically models/{model_name}/reports/ — walk up two levels
                abs_out = os.path.abspath(out_dir)
                model_name = os.path.basename(os.path.dirname(abs_out)) or "model"
        
            base_dir = os.path.dirname(os.path.abspath(md_path))
        
            with open(md_path, encoding='utf-8') as f:
                md_text = f.read()
        
            # Strip YAML frontmatter
            md_text = re.sub(r'^---\n.*?\n---\n', '', md_text, count=1, flags=re.DOTALL)
        
            with open(css_path, encoding='utf-8') as f:
                css = f.read()
        
            # Convert markdown to HTML
            html_body = markdown.markdown(md_text, extensions=['tables', 'fenced_code'])
        
            # Wrap in full HTML document
            html = f"""<!DOCTYPE html>
        <html lang="en">
        <head>
        <meta charset="utf-8">
        <title>DeepStream Benchmark Report — {model_name}</title>
        <style>
        {css}
        @media print {{
          body {{ max-width: 100%; padding: 10px; }}
          img {{ max-width: 100%; page-break-inside: avoid; }}
          table {{ page-break-inside: avoid; }}
          h2 {{ page-break-after: avoid; }}
        }}
        </style>
        </head>
        <body>
        {html_body}
        </body>
        </html>"""
        
            # Embed images as base64
            html = embed_images(html, base_dir)
        
            html_out = os.path.join(out_dir, 'benchmark_report.html')
            pdf_out = os.path.join(out_dir, f'benchmark_report_{model_name}.pdf')
        
            with open(html_out, 'w', encoding='utf-8') as f:
                f.write(html)
            print(f"  HTML: {html_out}")
        
            # Ensure the PDF renderer is available (self-heal on fresh containers).
            if not ensure_wkhtmltopdf():
                print("  PDF generation skipped: wkhtmltopdf unavailable and could not be installed. "
                      "HTML report is complete.", file=sys.stderr)
                sys.exit(1)
        
            # Convert to PDF.
            # Intentionally NOT passing --enable-local-file-access: all images have already
            # been converted to base64 data: URIs by embed_images(), and the CSS is inlined
            # in <style>...</style>, so no file:// fetching is needed. Keeping it disabled
            # blocks a CSS/HTML-injection exfil vector if the upstream Markdown ever carries
            # untrusted content (e.g. an HF model card).
            result = subprocess.run(
                [
                    'wkhtmltopdf',
                    '--page-size', 'A4',
                    '--margin-top', '15mm',
                    '--margin-bottom', '15mm',
                    '--margin-left', '15mm',
                    '--margin-right', '15mm',
                    '--image-quality', '100',
                    '--no-outline',
                    html_out, pdf_out,
                ],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                shell=False,
                timeout=300,
            )
        
            if result.returncode == 0:
                print(f"  PDF:  {pdf_out}")
            else:
                print(f"  PDF generation failed: {result.stderr[:500]}", file=sys.stderr)
                sys.exit(1)
        
        if __name__ == '__main__':
            main()
        
      • md-to-pdf.sh 2.2 KB
        #!/usr/bin/env bash
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        # Convert GitHub-Flavored Markdown (with optional Mermaid diagrams) to PDF with
        # correct wrapping: listings for code, Lua filter for tables/inline paths, LaTeX header.
        #
        # Usage:
        #   ./md-to-pdf.sh <source.md> [output.pdf]
        # If output.pdf is omitted, writes <source>.pdf next to the source file.
        #
        # Requires: mmdc (Mermaid CLI), pandoc, pdflatex, packages: listings, xcolor, ragged2e.
        #
        # Do NOT replace this with plain "pandoc --highlight-style=..." — highlighted Verbatim
        # boxes do not wrap long lines; --listings + latex-pdf-wrap.tex + pandoc-wrap-tables.lua are required.
        set -euo pipefail
        SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
        
        SRC_INPUT="${1:?Usage: $0 <markdown.md> [output.pdf]}"
        if [[ "$SRC_INPUT" != /* ]]; then
          SRC="$(cd "$(dirname "$SRC_INPUT")" && pwd)/$(basename "$SRC_INPUT")"
        else
          SRC="$SRC_INPUT"
        fi
        [[ -f "$SRC" ]] || { echo "error: file not found: $SRC" >&2; exit 1; }
        
        SRC_DIR="$(dirname "$SRC")"
        
        if [[ -n "${2-}" ]]; then
          OUT="$2"
          if [[ "$OUT" != /* ]]; then
            OUT="$(pwd)/$OUT"
          fi
        else
          OUT="${SRC%.md}.pdf"
        fi
        
        STEM="$(basename "$SRC" .md)"
        INTERMEDIATE="${SRC_DIR}/${STEM}._pdf.md"
        IMG_DIR="${SRC_DIR}/mermaid_pdf/${STEM}"
        
        python3 "$SCRIPT_DIR/render-mermaid-for-pdf.py" \
          --img-dir "$IMG_DIR" \
          "$SRC" \
          "$INTERMEDIATE"
        
        pandoc "$INTERMEDIATE" \
          --from=gfm \
          --lua-filter="$SCRIPT_DIR/pandoc-wrap-tables.lua" \
          --include-in-header="$SCRIPT_DIR/latex-pdf-wrap.tex" \
          --pdf-engine=pdflatex \
          -V geometry:margin=1in \
          --listings \
          --resource-path="$SRC_DIR:$SCRIPT_DIR" \
          -o "$OUT"
        
        echo "Wrote $OUT"
        
      • mermaid-puppeteer-root.json 86 B
        {
          "args": ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
        }
        
      • mermaid-puppeteer.json 42 B
        {
          "args": ["--disable-dev-shm-usage"]
        }
        
      • pandoc-wrap-tables.lua 2.2 KB · in bundle
      • render-mermaid-for-pdf.py 7.4 KB
        #!/usr/bin/env python3
        
        # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
        # SPDX-License-Identifier: Apache-2.0
        #
        # Licensed under the Apache License, Version 2.0 (the "License");
        # you may not use this file except in compliance with the License.
        # You may obtain a copy of the License at
        #
        # http://www.apache.org/licenses/LICENSE-2.0
        #
        # Unless required by applicable law or agreed to in writing, software
        # distributed under the License is distributed on an "AS IS" BASIS,
        # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
        # See the License for the specific language governing permissions and
        # limitations under the License.
        
        """
        Expand ```mermaid ... ``` blocks in a Markdown file into PNG images via mmdc,
        producing a new .md suitable for pandoc -> PDF. Does not modify the source file.
        
        Full PDF pipeline (see docs/md-to-pdf.sh and docs/build-pdf.sh):
          1. This script: Mermaid -> PNG under docs/mermaid_pdf/<stem>/, replace blocks with ![...](...) links.
          2. pandoc --from=gfm --listings --lua-filter=pandoc-wrap-tables.lua
             --include-in-header=latex-pdf-wrap.tex --pdf-engine=pdflatex
        
        Use --listings (not --highlight-style): default highlighted Verbatim splits code into
        unbreakable tokens and overflows the page. The Lua filter wraps pipe tables and long
        path-like inline code; CodeBlock text is normalized for pdflatex (Unicode quotes, etc.).
        """
        from __future__ import annotations
        
        import argparse
        import os
        import re
        import subprocess
        import sys
        from pathlib import Path
        
        MERMAID_BLOCK = re.compile(
            r"^```mermaid\s*\n(.*?)^```\s*$",
            re.MULTILINE | re.DOTALL,
        )
        
        
        def render_one(
            mmdc: str,
            body: str,
            out_png: Path,
            width: int,
            scale: float,
            puppeteer_config: Path | None,
        ) -> None:
            out_png.parent.mkdir(parents=True, exist_ok=True)
            tmp = out_png.with_suffix(".mmd")
            tmp.write_text(body.strip() + "\n", encoding="utf-8")
            cmd = [
                mmdc,
                "-i",
                str(tmp),
                "-o",
                str(out_png),
                "-e",
                "png",
                "-b",
                "white",
                "-w",
                str(width),
                "-s",
                str(scale),
                "-q",
            ]
            if puppeteer_config is not None:
                cmd.extend(["-p", str(puppeteer_config)])
            r = subprocess.run(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                shell=False,
                timeout=120,
            )
            tmp.unlink(missing_ok=True)
            if r.returncode != 0:
                sys.stderr.write(r.stderr or r.stdout or "mmdc failed\n")
                raise RuntimeError(f"mmdc failed with code {r.returncode}")
        
        
        def main() -> None:
            ap = argparse.ArgumentParser()
            ap.add_argument("source", type=Path, help="Input .md path")
            ap.add_argument("output", type=Path, help="Output .md path")
            ap.add_argument(
                "--img-dir",
                type=Path,
                default=None,
                help="Directory for PNGs (default: next to output, mermaid_pdf/)",
            )
            ap.add_argument("--mmdc", default="mmdc", help="Path to mmdc binary")
            ap.add_argument("--width", type=int, default=1100)
            ap.add_argument("--scale", type=float, default=1.5)
            ap.add_argument(
                "--puppeteer-config",
                type=Path,
                default=None,
                help="JSON for Puppeteer (default: mermaid-puppeteer.json next to this script)",
            )
            args = ap.parse_args()
            # Optional: MERMAID_PDF_WIDTH / MERMAID_PDF_SCALE (e.g. build-pdf.sh for design doc)
            if os.environ.get("MERMAID_PDF_WIDTH"):
                args.width = int(os.environ["MERMAID_PDF_WIDTH"])
            if os.environ.get("MERMAID_PDF_SCALE"):
                args.scale = float(os.environ["MERMAID_PDF_SCALE"])
        
            script_dir = Path(__file__).resolve().parent
        
            # Two vetted Puppeteer configs ship alongside this script:
            #   - mermaid-puppeteer.json       : Chromium sandbox enabled. Used for
            #                                    non-root execution (the secure
            #                                    default for laptops, CI runners that
            #                                    run as a non-root user, etc.).
            #   - mermaid-puppeteer-root.json  : --no-sandbox / --disable-setuid-sandbox.
            #                                    Used only when this script runs as
            #                                    uid 0, because Chromium refuses to
            #                                    start with the setuid sandbox enabled
            #                                    when running as root (common inside
            #                                    container build environments).
            # Both configs also pass --disable-dev-shm-usage, which is a stability
            # workaround for small /dev/shm in containers (not a security flag).
            #
            # Selection is driven by the effective uid, never by user input. Any
            # --puppeteer-config that doesn't resolve to one of these two shipped
            # files is rejected. This prevents an attacker-supplied config from
            # introducing extra dangerous flags such as --remote-debugging-port
            # (would expose a control channel to the headless browser) or
            # --load-extension (would let arbitrary JS run in Chromium).
            sandboxed_pc = script_dir / "mermaid-puppeteer.json"
            root_pc = script_dir / "mermaid-puppeteer-root.json"
        
            is_root = hasattr(os, "geteuid") and os.geteuid() == 0
            default_pc = root_pc if is_root else sandboxed_pc
        
            allowed = {p.resolve() for p in (sandboxed_pc, root_pc) if p.exists()}
            if args.puppeteer_config is not None:
                requested = args.puppeteer_config.resolve()
                if requested not in allowed:
                    sys.stderr.write(
                        "Refusing --puppeteer-config: only the shipped configs are "
                        f"allowed ({sandboxed_pc.name}, {root_pc.name}). "
                        f"Got: {requested}\n"
                    )
                    sys.exit(2)
                default_pc = args.puppeteer_config
        
            puppeteer_config = default_pc if default_pc.is_file() else None
            if puppeteer_config is not None:
                uid_str = str(os.geteuid()) if hasattr(os, "geteuid") else "n/a"
                sys.stderr.write(
                    f"[render-mermaid-for-pdf] using puppeteer config: "
                    f"{puppeteer_config.name} (uid={uid_str})\n"
                )
        
            # Validate source path exists and is a regular file
            if not args.source.is_file():
                sys.stderr.write(f"ERROR: source markdown not found: {args.source}\n")
                sys.exit(1)
        
            text = args.source.read_text(encoding="utf-8")
            img_dir = args.img_dir
            if img_dir is None:
                img_dir = args.output.parent / "mermaid_pdf"
        
            n = 0
        
            out_parent = args.output.parent.resolve()
        
            def repl(m: re.Match[str]) -> str:
                nonlocal n
                n += 1
                body = m.group(1)
                png_name = f"diagram_{n:02d}.png"
                out_png = img_dir / png_name
                render_one(
                    args.mmdc,
                    body,
                    out_png,
                    args.width,
                    args.scale,
                    puppeteer_config,
                )
                try:
                    rel_to_md = out_png.resolve().relative_to(out_parent)
                except ValueError:
                    # --img-dir is outside the output directory; fall back to os.path.relpath
                    rel_to_md = Path(os.path.relpath(out_png.resolve(), out_parent))
                return f"\n![Mermaid diagram {n}]({rel_to_md.as_posix()})\n"
        
            new_text, count = MERMAID_BLOCK.subn(repl, text)
            args.output.write_text(new_text, encoding="utf-8")
            if count:
                print(f"Rendered {count} Mermaid diagram(s) into {img_dir}", file=sys.stderr)
        
        
        if __name__ == "__main__":
            main()
        
      • report-style.css 1.9 KB · in bundle
    • dsrun.sh 1.4 KB
      #!/usr/bin/env bash
      # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
      # SPDX-License-Identifier: Apache-2.0
      
      # Copyright (c) 2026, NVIDIA CORPORATION.  All rights reserved. Apache-2.0.
      #
      # Convenience wrapper: run a command INSIDE the DeepStream container with the working root
      # bind-mounted at /work. Every phase of this skill runs this way — nothing runs on the host.
      #
      #   bash .claude/skills/deepstream-import-vision-model/scripts/dsrun.sh '<command to run in-container>'
      #   e.g. bash .claude/skills/deepstream-import-vision-model/scripts/dsrun.sh 'build/.venv_optimum/bin/python -c "import torch;print(torch.__version__)"'
      #        bash .claude/skills/deepstream-import-vision-model/scripts/dsrun.sh 'make -C models/yolov8n/parser && ls models/yolov8n/parser/*.so'
      #
      # Env overrides: DS_IMAGE (default DeepStream 9.1), DS_GPU (default "--gpus all", set "" for CPU-only steps).
      # On PowerShell the -v token is "${PWD}:/work"; on cmd "%cd%:/work" — Claude Code sets it per host shell.
      # See references/windows.md.
      set -euo pipefail
      IMG="${DS_IMAGE:-nvcr.io/nvidia/deepstream:9.1-triton-multiarch}"
      GPU="${DS_GPU-"--gpus all"}"
      if [ "$#" -eq 0 ]; then echo "usage: bash .claude/skills/deepstream-import-vision-model/scripts/dsrun.sh '<in-container command>'" >&2; exit 2; fi
      exec docker run --rm $GPU --shm-size=16g -v "$PWD":/work -w /work \
        --entrypoint bash "$IMG" -lc "$*"
      
    • preflight.sh 4.2 KB
      #!/usr/bin/env bash
      # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
      # SPDX-License-Identifier: Apache-2.0
      
      # Copyright (c) 2026, NVIDIA CORPORATION.  All rights reserved.
      # Licensed under the Apache License, Version 2.0 (the "License").
      #
      # Preflight for deepstream-import-vision-model: verify the environment BEFORE running the
      # import loop. Everything runs THROUGH the DeepStream container (no host packages). Checks:
      #   1. docker daemon reachable            (host-mode only)
      #   2. the DeepStream image is pulled     (host-mode only)
      #   3. GPU visible inside the container
      #   4. the venv has the ONNX-export + report packages, and trtexec is present
      #
      # Usage:
      #   Host (Linux):  bash scripts/preflight.sh
      #   Through Docker (any OS — the portable way):
      #     docker run --rm --gpus all -v "$PWD":/work -w /work --entrypoint bash \
      #       nvcr.io/nvidia/deepstream:9.1-triton-multiarch \
      #       .claude/skills/deepstream-import-vision-model/scripts/preflight.sh
      set -u
      IMG="${1:-nvcr.io/nvidia/deepstream:9.1-triton-multiarch}"
      VENV="${2:-build/.venv_optimum}"
      PKGS="torch torchvision transformers onnx onnxruntime onnxscript huggingface_hub matplotlib numpy markdown reportlab"
      fail=0
      ok(){  echo "  [OK]      $1"; }
      warn(){ echo "  [WARN]    $1"; }
      bad(){  echo "  [MISSING] $1"; fail=1; }
      
      # Container-mode: when run INSIDE the container (Windows/macOS invoke it as
      #   docker run --gpus all -v <pwd>:/work -w /work <image> bash scripts/preflight.sh
      # ), the host docker/image checks are moot and there's no nested docker CLI — verify GPU +
      # venv + trtexec DIRECTLY. On a Linux host (no /.dockerenv) fall through to host-orchestration.
      if [ -f /.dockerenv ]; then
        echo "[preflight] container-mode (inside the container — verifying GPU + venv + trtexec directly)"
        echo "[preflight] 1/3 GPU (nvidia-smi)"
        nvidia-smi -L >/dev/null 2>&1 && ok "GPU visible in container" \
          || bad "no GPU in container — run with --gpus all (Windows: Docker Desktop WSL2 backend + NVIDIA driver)"
        echo "[preflight] 2/3 trtexec"
        [ -x /usr/src/tensorrt/bin/trtexec ] && ok "trtexec present (/usr/src/tensorrt/bin/trtexec)" \
          || bad "trtexec not found — is this the DeepStream/TensorRT image?"
        echo "[preflight] 3/3 python packages in $VENV"
        if [ -x "$VENV/bin/python" ]; then
          miss=$("$VENV/bin/python" -c "import importlib.util as u;print(' '.join(p for p in '$PKGS'.split() if u.find_spec(p) is None))" 2>/dev/null)
          if [ -z "${miss// /}" ]; then ok "all packages present"
          else bad "missing in venv: $miss  (run setup.sh to (re)build the venv)"; fi
        else
          bad "venv not found: $VENV/bin/python — run setup.sh first (creates build/.venv_optimum)"
        fi
        echo "[preflight] RESULT: $([ $fail -eq 0 ] && echo 'PASS — environment ready' || echo 'FAIL — fix [MISSING] items before running')"
        exit $fail
      fi
      
      echo "[preflight] 1/4 docker daemon"
      docker info >/dev/null 2>&1 && ok "docker reachable" || bad "docker not installed or daemon not running"
      
      echo "[preflight] 2/4 DeepStream image: $IMG"
      have_img=0
      if docker image inspect "$IMG" >/dev/null 2>&1; then ok "image present"; have_img=1
      else warn "image not pulled — run: docker pull $IMG"; fi
      
      if [ "$have_img" = 1 ]; then
        echo "[preflight] 3/4 GPU via docker --gpus all"
        docker run --rm --gpus all --entrypoint nvidia-smi "$IMG" -L >/dev/null 2>&1 \
          && ok "GPU visible in container" \
          || bad "no GPU through --gpus all (check driver + nvidia-container-toolkit)"
      
        echo "[preflight] 4/4 python packages in $VENV"
        if [ -x "$VENV/bin/python" ]; then
          miss=$(docker run --rm --entrypoint /work/"$VENV"/bin/python -v "$PWD":/work "$IMG" \
            -c "import importlib.util as u;print(' '.join(p for p in '$PKGS'.split() if u.find_spec(p) is None))" 2>/dev/null)
          if [ -z "${miss// /}" ]; then ok "all packages present"
          else bad "missing in venv: $miss  (run setup.sh)"; fi
        else
          bad "venv not found: $VENV/bin/python — run setup.sh first"
        fi
      else
        echo "[preflight] 3/4 GPU            — SKIPPED (image not pulled)"
        echo "[preflight] 4/4 python packages — SKIPPED (image not pulled)"
      fi
      
      echo "[preflight] RESULT: $([ $fail -eq 0 ] && echo 'PASS — environment ready' || echo 'FAIL — fix [MISSING] items before running')"
      exit $fail
      
    • requirements.txt 2.6 KB
      # Python dependencies for deepstream-import-vision-model (installed into build/.venv_optimum).
      # Run INSIDE nvcr.io/nvidia/deepstream:9.1-triton-multiarch via setup.sh — NOT on the host.
      # Release target: DeepStream 9.1 Triton image (Python 3.12); run the GPU validation matrix before signing.
      #
      # NOTE on torch: the default linux-x86_64 PyPI wheel for torch 2.13.0 bundles a CUDA 13 runtime
      # (nvidia-cudnn-cu13 / nccl-cu13 / cusparselt-cu13). It relies on NVIDIA driver compatibility rather
      # than the container CUDA toolkit; no special --index-url is needed. This replaces the previous
      # torch 2.6.0 / cu124 pin. 2.13.0 is the first torch release with zero open OSV advisories.
      # (Same torch/torchvision pins the sibling deepstream-eval-and-finetune skill uses.)
      #
      # VALIDATED end-to-end on 2026-08-04 in nvcr.io/nvidia/deepstream:9.1-triton-multiarch (H100):
      #   setup.sh resolved and installed this exact set; SafeTensors -> ONNX -> TRT 10.16 FP16
      #   dynamic-shape engine built and PASSED trtexec for both
      #     PekingU/rtdetr_r50vd  (default model)  65.0 qps, 15.37 ms
      #     hustvl/yolos-tiny                     171.9 qps,  5.81 ms
      #   ONNX Runtime confirmed a working dynamic batch dimension at batch 1/3/8.
      #   Still to confirm on the full release matrix: Jetson/aarch64 and the non-H100 GPU lineup.
      #
      # NOTE on the export path: SafeTensors -> ONNX goes through scripts/model/safetensors_to_onnx.py
      # (direct torch.onnx.export), NOT optimum-cli. optimum was removed because it pinned transformers
      # below 4.54.0 (and optimum-onnx below 4.58.0), while the transformers RCE (GHSA-29pf-2h5f-8g72)
      # is fixed only in 5.3.0 and the model-init arbitrary code execution (GHSA-fgcw-684q-jj6r) only in
      # 5.5.0 — so no transformers 4.x release cleared them. optimum 2.1.0 also removed the `onnx`
      # subcommand entirely, making that path a dead end regardless.
      
      # --- ONNX export (SafeTensors -> ONNX, via torch.onnx.export) ---
      torch==2.13.0
      torchvision==0.28.0        # torchvision 0.28.0 requires torch==2.13.0 exactly
      transformers==5.14.1
      onnx==1.22.0
      onnxruntime==1.27.0
      onnxscript==0.7.0          # required by the torch.onnx.export dynamo backend
      huggingface_hub==1.26.0    # transformers 5.14.1 requires >=1.5.0,<2.0
      tokenizers==0.22.2         # highest STABLE release inside transformers' >=0.22.0,<=0.23.0 range
                                 # (0.23.0 was never released as final; 0.23.1 exceeds the ceiling)
      safetensors==0.8.0         # transformers 5.14.1 requires >=0.8.0
      
      # --- report: charts + markdown->HTML (PDF via wkhtmltopdf, apt-installed in the container by setup.sh) ---
      matplotlib==3.10.9
      numpy==1.26.4
      markdown==3.10.2
      reportlab==4.5.1
      
  • tests
    • README.md 3 KB
      # deepstream-import-vision-model tests
      
      Unit / validation tests for the hardened scripts in this skill. These run
      locally with only `python3` (stdlib + `bash`) — no GPU, no network, no external
      package install required.
      
      ## What is covered
      
      | Test class | Script | What it verifies |
      |---|---|---|
      | `TestInstallScript` | `install.sh` | `--target` rejects `/`, `..`, empty, missing; dry-run previews the self-contained skill copy without writing to target |
      | `TestCleanupScript` | `scripts/model/cleanup.sh` | `MODEL_NAME` regex enforcement; shell meta-chars / slashes rejected; dry-run does not touch real files |
      | `TestHFScripts` | `scripts/model/hf-list-files.sh`, `hf-download-config.sh` | `HF_ORG` / `MODEL_NAME` / `DEST` validation |
      | `TestNGCScripts` | `scripts/model/ngc-list-files.sh`, `ngc-download.sh` | NGC arg validation; `DEST_DIR` refuses `""`, `/`, path-traversal |
      | `TestKittiDumpUsage` | `scripts/deepstream/ds-kitti-dump.sh` | Usage message printed when required args missing |
      | `TestEmbedImages` | `scripts/report/md-to-html-pdf.py` | Local images inlined as `data:` URIs; remote / absolute / traversal paths left alone (proves `--enable-local-file-access` is safe to drop) |
      | `TestPowerShellInstaller` | `install.ps1` (**both** skills) | Every source file is installed — asserts an exact file-set match, not just that `SKILL.md` exists; Claude + Codex targets; `-NoCursor`; reinstall is idempotent and does not nest; the missing-`SKILL.md` guard throws |
      
      ### Note on `TestPowerShellInstaller`
      
      This one exists because of a shipped bug: `install.ps1` used
      `Copy-Item -LiteralPath $SkillDir -Destination $dest -Recurse`, whose behaviour depends on whether
      the destination already exists, and which on **Windows PowerShell 5.1** created the directory tree
      without copying any files — an empty skill dir, no `SKILL.md`, and a skill the agent runtime
      silently refused to load. Nothing caught it because the suites only ever exercised `install.sh`.
      
      It needs a PowerShell, discovered in this order: `pwsh`, `powershell`, then the
      `mcr.microsoft.com/powershell` docker image if already pulled. With none available it **skips** —
      it will never fail on a machine without PowerShell. To enable it on a Linux dev box:
      
      ```bash
      docker pull mcr.microsoft.com/powershell:latest
      ```
      
      Caveat: the dev-box/container runner is PowerShell **7**. The original bug was 5.1-specific, so
      this pins the copy *contract* rather than reproducing every 5.1 quirk — run it on Windows for full
      fidelity.
      
      ## Run
      
      From the skill root:
      
      ```bash
      python3 -m unittest discover -s tests -v
      ```
      
      Or a single class:
      
      ```bash
      python3 -m unittest tests.test_hardened_scripts.TestInstallScript -v
      ```
      
      ## Adding tests
      
      Tests shell out to the real scripts and assert on exit codes + stderr/stdout,
      or import Python helpers directly. Keep them hermetic: use `tempfile.TemporaryDirectory()`,
      don't rely on network access, and don't shell out to tools that may not be
      installed on every dev machine (e.g. `trtexec`, `deepstream-app`, `wkhtmltopdf`,
      `mmdc`, `ngc`).
      
    • test_evals_contract.py 10.5 KB
      # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
      # SPDX-License-Identifier: Apache-2.0
      """Contract tests for ``evals/evals.json``.
      
      Nothing in this repo validates the skill eval files, so every past defect had to be
      caught by a human or by a remote NVSkills-Eval run. The 2026-08-12 Litmus dispatch
      (run 6beb1477-c4c2-416c-b28d-77314215aebb) reported two classes of defect that these
      tests pin down:
      
      * **Negation-blind guards** — a ``not_contains_pattern`` that matches a bare
        identifier fires when the response *names the thing it is declining to do*, so a
        correct refusal is graded as if it performed the forbidden action.
      * **Credential false positives** — ``[A-Za-z0-9+/]{40,}`` matched ordinary
        HuggingFace ``resolve/main/...`` URLs and the string ``token-classification``.
      
      ``ANTI_NEGATION_CORPUS`` and ``BENIGN_STRINGS`` are the regression fixtures: every
      sentence in them is something a *correct* run legitimately emits, so no
      ``not_contains_pattern`` in the suite may match any of them.
      """
      
      import json
      import re
      import unittest
      from pathlib import Path
      
      
      SKILL_ROOT = Path(__file__).resolve().parents[1]
      EVALS_PATH = SKILL_ROOT / "evals" / "evals.json"
      
      VALID_ASSERTION_TYPES = {
          "contains_pattern",
          "not_contains_pattern",
          "contains_phrase",
      }
      
      # Sentences a *correct* run emits. No negative guard may match any of these.
      ANTI_NEGATION_CORPUS = [
          "gpt2 is a language model; I will not run trtexec or an engine build for nvinfer.",
          "This is out of scope, so I will not build a TensorRT engine or start a DeepStream pipeline.",
          "Local path detected - skipping huggingface_hub download entirely.",
          "The model is already on disk, so hf_hub_download is not needed.",
          "Multiple variants found; I automatically choose model_fp16.onnx without prompting.",
          "I will not ask which variant to use - the fp16-first rule selects it deterministically.",
          "To clarify, I will run the full import pipeline now without asking which skill applies.",
          "No custom parser compilation is needed: the built-in TAO parser in "
          "libnvds_infercustomparser.so is reused, so no g++ or cmake invocation is required.",
          "ResNet-50 is a classifier, so I am not going to run deepstream-app or gst-launch-1.0.",
          "You do not need to install the SDK with trtexec; apt-get installs the deb.",
      ]
      
      # Strings the skill legitimately prints that must never be flagged as a credential.
      BENIGN_STRINGS = [
          "https://huggingface.co/onnx-community/yolov8n/resolve/main/onnx/model_fp16.onnx",
          "The config.json pipeline_tag is token-classification, so this is rejected.",
          "Export HF_TOKEN in your environment before running (never hardcode it).",
          "dataset digest sha256:a14983294ef15eedf15e8e8e68ae18da7cde7f1720e6634896c5f7c8b6ddbdc7",
          "Set TOKENIZERS_PARALLELISM=false to silence the warning.",
          "engine models/rtdetr_r50vd_dynamic_b16.engine built with trtexec --fp16",
      ]
      
      # Credential-shaped probes the guard must still catch.
      #
      # These are synthetic, but they cannot be written as literals: the repository secret
      # scanner reads source lines, so a fixture like the vendor prefix followed by 16
      # characters is flagged here as if it were a live key. That is the same
      # false-positive class these tests exist to prevent, so each probe is split across a
      # concatenation boundary — no single literal below matches a secret pattern — and
      # padded with a zero-entropy filler.
      _PAD = "0" * 8 + "a" * 8  # 16 chars, no entropy
      
      
      def _probe(*fragments: str) -> str:
          """Join innocuous fragments into one credential-shaped string at runtime."""
          return "".join(fragments) + _PAD
      
      
      SYNTHETIC_LEAKS = [
          _probe("Authorization: ", "Bearer ", "hf", "_"),
          _probe("export ", "HF", "_TOKEN=", "hf", "_"),
          _probe("NGC", "_API", "_KEY=", "nvapi", "-"),
          _probe("openai key ", "sk", "-"),
          _probe("pass", "word: "),
      ]
      
      CREDENTIAL_ASSERTION_MARKER = "credential"
      
      
      def _load():
          return json.loads(EVALS_PATH.read_text())
      
      
      class TestEvalsSchema(unittest.TestCase):
          @classmethod
          def setUpClass(cls):
              cls.data = _load()
              cls.cases = cls.data["evals"]
      
          def test_skill_name_matches_directory(self):
              self.assertEqual(self.data["skill_name"], SKILL_ROOT.name)
      
          def test_ids_are_unique_non_empty_strings(self):
              ids = [c["id"] for c in self.cases]
              for case_id in ids:
                  self.assertIsInstance(case_id, str, "eval ids must be strings")
                  self.assertTrue(case_id.strip())
              self.assertEqual(len(ids), len(set(ids)), "duplicate eval ids")
      
          def test_every_case_has_the_required_fields(self):
              for case in self.cases:
                  with self.subTest(case=case["id"]):
                      for field in ("name", "prompt", "expected_output", "assertions"):
                          self.assertIn(field, case)
                      self.assertTrue(case["assertions"], "case has no assertions")
      
          def test_assertion_types_are_known(self):
              for case in self.cases:
                  for assertion in case["assertions"]:
                      with self.subTest(case=case["id"], text=assertion["text"]):
                          self.assertIn(assertion["type"], VALID_ASSERTION_TYPES)
      
          def test_every_regex_compiles(self):
              for case in self.cases:
                  for assertion in case["assertions"]:
                      if not assertion["type"].endswith("_pattern"):
                          continue
                      with self.subTest(case=case["id"], text=assertion["text"]):
                          re.compile(assertion["pattern"])
      
      
      class TestBehaviorGrading(unittest.TestCase):
          """Litmus grades activation from the transcript, not from prose."""
      
          @classmethod
          def setUpClass(cls):
              cls.cases = _load()["evals"]
      
          def test_every_case_declares_should_trigger(self):
              for case in self.cases:
                  with self.subTest(case=case["id"]):
                      self.assertIn("should_trigger", case)
                      self.assertIsInstance(case["should_trigger"], bool)
      
          def test_every_case_has_expected_behavior(self):
              for case in self.cases:
                  with self.subTest(case=case["id"]):
                      behaviors = case.get("expected_behavior")
                      self.assertTrue(behaviors, "expected_behavior is missing or empty")
                      for behavior in behaviors:
                          self.assertIsInstance(behavior, str)
                          self.assertTrue(behavior.strip())
      
          def test_suite_contains_negative_cases(self):
              negatives = [c for c in self.cases if c["should_trigger"] is False]
              self.assertGreaterEqual(len(negatives), 3, "too few anti-trigger cases")
      
          def test_most_prompts_do_not_name_the_skill(self):
              """Discoverability must be measured, not assumed (PROMPT_NAMES_SKILL)."""
              named = [c for c in self.cases if SKILL_ROOT.name in c["prompt"]]
              self.assertLess(
                  len(named),
                  len(self.cases) / 2,
                  "over half the prompts name the skill outright, so discoverability is untested",
              )
      
      
      class TestNoNegationBlindGuards(unittest.TestCase):
          """The regression guard for the defect class Litmus reported.
      
          Each ``not_contains_pattern`` is run against sentences a correct run emits. A
          match means the assertion would fail the *right* answer.
          """
      
          @classmethod
          def setUpClass(cls):
              cls.cases = _load()["evals"]
      
          def test_no_negative_guard_fires_on_a_correct_response(self):
              for case in self.cases:
                  for assertion in case["assertions"]:
                      if assertion["type"] != "not_contains_pattern":
                          continue
                      if CREDENTIAL_ASSERTION_MARKER in assertion["text"].lower():
                          continue
                      pattern = re.compile(assertion["pattern"], re.IGNORECASE)
                      for sentence in ANTI_NEGATION_CORPUS:
                          with self.subTest(case=case["id"], text=assertion["text"]):
                              match = pattern.search(sentence)
                              if match is not None:
                                  self.fail(
                                      "negation-blind guard: matched "
                                      f"{match.group(0)!r} in a correct response: {sentence!r}"
                                  )
      
          def test_negative_guards_are_anchored_to_an_invocation_shape(self):
              """A bare identifier is what makes a guard negation-blind."""
              for case in self.cases:
                  for assertion in case["assertions"]:
                      if assertion["type"] != "not_contains_pattern":
                          continue
                      if CREDENTIAL_ASSERTION_MARKER in assertion["text"].lower():
                          continue
                      pattern = assertion["pattern"]
                      with self.subTest(case=case["id"], text=assertion["text"]):
                          self.assertTrue(
                              any(anchor in pattern for anchor in ("(^|\\n)", "\\?", "\\s")),
                              "guard must be anchored to a line start, a question, or a "
                              "command shape rather than matching a bare identifier",
                          )
      
      
      class TestCredentialGuard(unittest.TestCase):
          @classmethod
          def setUpClass(cls):
              cls.cases = _load()["evals"]
              cls.patterns = [
                  a["pattern"]
                  for c in cls.cases
                  for a in c["assertions"]
                  if a["type"] == "not_contains_pattern"
                  and CREDENTIAL_ASSERTION_MARKER in a["text"].lower()
              ]
      
          def test_the_suite_has_credential_guards(self):
              self.assertTrue(self.patterns)
      
          def test_all_credential_guards_are_identical(self):
              self.assertEqual(
                  len(set(self.patterns)),
                  1,
                  "credential guards have drifted apart; keep one shared pattern",
              )
      
          def test_credential_guard_does_not_fire_on_benign_strings(self):
              pattern = re.compile(self.patterns[0], re.IGNORECASE)
              for benign in BENIGN_STRINGS:
                  with self.subTest(benign=benign):
                      match = pattern.search(benign)
                      if match is not None:
                          self.fail(
                              f"false positive: matched {match.group(0)!r} in {benign!r}"
                          )
      
          def test_credential_guard_still_catches_real_leaks(self):
              pattern = re.compile(self.patterns[0], re.IGNORECASE)
              for leak in SYNTHETIC_LEAKS:
                  with self.subTest(leak=leak):
                      self.assertIsNotNone(
                          pattern.search(leak),
                          f"credential guard missed a leak: {leak!r}",
                      )
      
      
      if __name__ == "__main__":
          unittest.main()
      
    • test_hardened_scripts.py 13.3 KB
      #!/usr/bin/env python3
      
      # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
      # SPDX-License-Identifier: Apache-2.0
      #
      # Licensed under the Apache License, Version 2.0 (the "License");
      # you may not use this file except in compliance with the License.
      # You may obtain a copy of the License at
      #
      # http://www.apache.org/licenses/LICENSE-2.0
      #
      # Unless required by applicable law or agreed to in writing, software
      # distributed under the License is distributed on an "AS IS" BASIS,
      # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      # See the License for the specific language governing permissions and
      # limitations under the License.
      
      """
      Validation tests for the hardened scripts in deepstream-import-vision-model.
      
      Covers:
        - install.sh:                 --target validation (rejects /, .., empty, missing)
                                      dry-run previews self-contained skill copies
        - scripts/model/cleanup.sh:   MODEL_NAME regex enforcement, empty arg rejection,
                                      dry-run does not touch the filesystem
        - scripts/model/hf-list-files.sh / hf-download-config.sh:
                                      rejects injection characters in HF_ORG / MODEL_NAME
        - scripts/model/ngc-list-files.sh / ngc-download.sh:
                                      rejects injection characters in NGC args
                                      refuses invalid DEST_DIR (empty, /, containing ..)
        - scripts/deepstream/ds-kitti-dump.sh:
                                      usage message printed when args missing
        - scripts/report/md-to-html-pdf.py::embed_images:
                                      base64-inlines local images, rejects path traversal,
                                      leaves absolute/remote URLs alone
      
      Run:
          python3 -m unittest discover -s tests -v
      """
      from __future__ import annotations
      
      import base64
      import os
      import shutil
      import subprocess
      import sys
      import tempfile
      import unittest
      from pathlib import Path
      
      SKILL_DIR = Path(__file__).resolve().parent.parent
      SCRIPTS = SKILL_DIR / "scripts"
      INSTALL_SH = SKILL_DIR / "install.sh"
      
      
      def run_script(cmd, cwd=None, timeout=30):
          """Run a command and return (returncode, stdout, stderr)."""
          r = subprocess.run(
              cmd,
              stdout=subprocess.PIPE,
              stderr=subprocess.PIPE,
              text=True,
              shell=False,
              cwd=str(cwd) if cwd else None,
              timeout=timeout,
          )
          return r.returncode, r.stdout, r.stderr
      
      
      class TestInstallScript(unittest.TestCase):
          """install.sh TARGET validation (B6)."""
      
          def test_rejects_missing_target_arg(self):
              rc, out, err = run_script(["bash", str(INSTALL_SH)])
              self.assertNotEqual(rc, 0)
              self.assertIn("--target is required", out + err)
      
          def test_rejects_filesystem_root(self):
              rc, out, err = run_script(["bash", str(INSTALL_SH), "--target", "/", "--dry-run"])
              self.assertNotEqual(rc, 0)
              self.assertIn("invalid --target", out + err)
      
          def test_rejects_path_traversal(self):
              rc, out, err = run_script(
                  ["bash", str(INSTALL_SH), "--target", "/tmp/../etc", "--dry-run"]
              )
              self.assertNotEqual(rc, 0)
              self.assertIn("invalid --target", out + err)
      
          def test_rejects_nonexistent_target(self):
              rc, out, err = run_script(
                  ["bash", str(INSTALL_SH), "--target", "/does/not/exist", "--dry-run"]
              )
              self.assertNotEqual(rc, 0)
              self.assertIn("target directory not found", out + err)
      
          def test_dry_run_previews_all_skill_locations(self):
              with tempfile.TemporaryDirectory() as td:
                  rc, out, _ = run_script(
                      ["bash", str(INSTALL_SH), "--target", td, "--dry-run"]
                  )
                  self.assertEqual(rc, 0, f"install.sh --dry-run failed: {out}")
                  self.assertIn("[dry-run] cp -r", out)
                  # Single skill copied as real files (references pattern — no sub-skills)
                  self.assertIn(".claude/skills/deepstream-import-vision-model", out)
                  self.assertNotIn("nv-model-acquire", out)
                  self.assertNotIn("nv-engine-build", out)
                  self.assertNotIn("ds-run-pipeline", out)
                  self.assertNotIn("nv-import-vision-model-report", out)
                  # Cursor skill also installed
                  self.assertIn(".cursor/skills/deepstream-import-vision-model", out)
                  self.assertIn(".codex/skills/deepstream-import-vision-model", out)
                  # No agent directory — skill-only architecture
                  self.assertNotIn(".claude/agents", out)
                  # Nothing must be written in dry-run mode
                  self.assertEqual(os.listdir(td), [])
      
      
          def test_reinstall_from_installed_path_is_safe(self):
              with tempfile.TemporaryDirectory() as td:
                  installed = Path(td) / ".claude" / "skills" / "deepstream-import-vision-model"
                  shutil.copytree(SKILL_DIR, installed)
                  marker = installed / "SKILL.md"
                  rc, out, err = run_script([
                      "bash", str(installed / "install.sh"),
                      "--target", td, "--no-cursor",
                  ])
                  self.assertEqual(rc, 0, f"stdout={out} stderr={err}")
                  self.assertTrue(marker.exists(), "in-place reinstall must not delete its source")
                  self.assertIn("source and destination are identical", out)
      
      
      class TestCleanupScript(unittest.TestCase):
          """scripts/model/cleanup.sh MODEL_NAME validation (B8)."""
      
          CLEANUP = SCRIPTS / "model" / "cleanup.sh"
      
          def test_rejects_missing_arg(self):
              rc, out, err = run_script(["bash", str(self.CLEANUP)])
              self.assertNotEqual(rc, 0)
              self.assertIn("Usage", out + err)
      
          def test_rejects_shell_metachars(self):
              rc, _, err = run_script(["bash", str(self.CLEANUP), "bad;name", "--dry-run"])
              self.assertNotEqual(rc, 0)
              self.assertIn("MODEL_NAME must match", err)
      
          def test_rejects_slash(self):
              rc, _, err = run_script(["bash", str(self.CLEANUP), "bad/name", "--dry-run"])
              self.assertNotEqual(rc, 0)
              self.assertIn("MODEL_NAME must match", err)
      
          def test_accepts_valid_name_dry_run(self):
              with tempfile.TemporaryDirectory() as td:
                  rc, out, err = run_script(
                      ["bash", str(self.CLEANUP), "yolov8n", "--dry-run"], cwd=td
                  )
                  self.assertEqual(rc, 0, f"stdout={out} stderr={err}")
                  # No candidates exist in a fresh dir — all should be skipped, nothing removed
                  self.assertIn("skip (not present)", out)
      
          def test_dry_run_does_not_remove_present_paths(self):
              with tempfile.TemporaryDirectory() as td:
                  tdp = Path(td)
                  target = tdp / "build" / ".venv_yolov8n"
                  target.mkdir(parents=True)
                  (target / "marker").write_text("present")
                  rc, out, _ = run_script(
                      ["bash", str(self.CLEANUP), "yolov8n", "--dry-run"], cwd=td
                  )
                  self.assertEqual(rc, 0)
                  self.assertIn("[dry-run]", out)
                  self.assertTrue(target.exists(), "dry-run must not remove files")
                  self.assertTrue((target / "marker").exists())
      
      
      class TestHFScripts(unittest.TestCase):
          """HF helper script input validation (B4)."""
      
          LIST = SCRIPTS / "model" / "hf-list-files.sh"
          CONFIG = SCRIPTS / "model" / "hf-download-config.sh"
      
          def test_list_rejects_bad_org(self):
              rc, _, err = run_script(["bash", str(self.LIST), "bad;org", "model"])
              self.assertNotEqual(rc, 0)
              self.assertIn("invalid characters", err)
      
          def test_list_rejects_bad_model(self):
              rc, _, err = run_script(["bash", str(self.LIST), "org", "bad$model"])
              self.assertNotEqual(rc, 0)
              self.assertIn("invalid characters", err)
      
          def test_list_rejects_missing_args(self):
              rc, _, err = run_script(["bash", str(self.LIST)])
              self.assertNotEqual(rc, 0)
              self.assertIn("Usage", err)
      
          def test_config_rejects_path_traversal_in_dest(self):
              rc, _, err = run_script(
                  [
                      "bash",
                      str(self.CONFIG),
                      "org",
                      "model",
                      "relative/../etc/config.json",
                  ]
              )
              self.assertNotEqual(rc, 0)
              self.assertIn("'..'", err)
      
          def test_config_rejects_absolute_dest(self):
              rc, _, err = run_script(
                  [
                      "bash",
                      str(self.CONFIG),
                      "org",
                      "model",
                      "/tmp/config.json",
                  ]
              )
              self.assertNotEqual(rc, 0)
              self.assertIn("must be relative", err)
      
      
      class TestNGCScripts(unittest.TestCase):
          """NGC helper script input validation (B5)."""
      
          LIST = SCRIPTS / "model" / "ngc-list-files.sh"
          DOWNLOAD = SCRIPTS / "model" / "ngc-download.sh"
      
          def test_list_rejects_bad_org(self):
              rc, _, err = run_script(
                  ["bash", str(self.LIST), "bad;org", "team", "model", "v1"]
              )
              self.assertNotEqual(rc, 0)
              self.assertIn("invalid characters", err)
      
          def test_download_rejects_empty_dest(self):
              rc, _, err = run_script(
                  [
                      "bash",
                      str(self.DOWNLOAD),
                      "org",
                      "team",
                      "model",
                      "v1",
                      "",
                  ]
              )
              self.assertNotEqual(rc, 0)
      
          def test_download_rejects_traversal_dest(self):
              rc, _, err = run_script(
                  [
                      "bash",
                      str(self.DOWNLOAD),
                      "org",
                      "team",
                      "model",
                      "v1",
                      "/tmp/../etc",
                  ]
              )
              self.assertNotEqual(rc, 0)
              self.assertIn("invalid DEST_DIR", err)
      
          def test_download_rejects_fs_root_dest(self):
              rc, _, err = run_script(
                  [
                      "bash",
                      str(self.DOWNLOAD),
                      "org",
                      "team",
                      "model",
                      "v1",
                      "/",
                  ]
              )
              self.assertNotEqual(rc, 0)
              self.assertIn("invalid DEST_DIR", err)
      
      
      class TestKittiDumpUsage(unittest.TestCase):
          """scripts/deepstream/ds-kitti-dump.sh usage message (B7)."""
      
          KITTI = SCRIPTS / "deepstream" / "ds-kitti-dump.sh"
      
          def test_prints_usage_when_args_missing(self):
              rc, out, err = run_script(["bash", str(self.KITTI)])
              self.assertNotEqual(rc, 0)
              combined = out + err
              self.assertTrue(
                  "Usage" in combined or "unbound variable" in combined,
                  f"expected Usage or unbound-variable error, got: {combined!r}",
              )
      
      
      class TestEmbedImages(unittest.TestCase):
          """scripts/report/md-to-html-pdf.py::embed_images (B2).
      
          Images must be base64-inlined so wkhtmltopdf no longer needs
          --enable-local-file-access.
          """
      
          @classmethod
          def setUpClass(cls):
              # Import the module by loading its source directly to avoid hyphen-path issues.
              # md-to-html-pdf.py imports the third-party `markdown` package at module scope,
              # which isn't needed for embed_images(). Stub it so tests run without that dep.
              import importlib.util
              import types
      
              if "markdown" not in sys.modules:
                  stub = types.ModuleType("markdown")
                  stub.markdown = lambda text, **kw: text  # no-op for tests
                  sys.modules["markdown"] = stub
      
              src = SCRIPTS / "report" / "md-to-html-pdf.py"
              spec = importlib.util.spec_from_file_location("md_to_html_pdf", src)
              cls.mod = importlib.util.module_from_spec(spec)
              spec.loader.exec_module(cls.mod)
      
          def test_inlines_local_png(self):
              # 1x1 transparent PNG
              png = base64.b64decode(
                  b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
              )
              with tempfile.TemporaryDirectory() as td:
                  p = Path(td)
                  (p / "pic.png").write_bytes(png)
                  html = '<img src="pic.png" alt="x">'
                  out = self.mod.embed_images(html, str(p))
                  self.assertIn("data:image/png;base64,", out)
                  self.assertNotIn('src="pic.png"', out)
      
          def test_leaves_remote_url_alone(self):
              html = '<img src="https://example.com/pic.png" alt="x">'
              with tempfile.TemporaryDirectory() as td:
                  out = self.mod.embed_images(html, td)
                  self.assertEqual(html, out)
      
          def test_leaves_absolute_path_alone(self):
              html = '<img src="/opt/data/elsewhere.png" alt="x">'
              with tempfile.TemporaryDirectory() as td:
                  out = self.mod.embed_images(html, td)
                  # Should not be rewritten to a data: URI (absolute paths are skipped)
                  self.assertNotIn("data:", out)
                  self.assertIn('src="/opt/data/elsewhere.png"', out)
      
          def test_rejects_path_traversal(self):
              # src points outside base_dir — must not be embedded
              with tempfile.TemporaryDirectory() as td_base:
                  with tempfile.TemporaryDirectory() as td_outside:
                      secret = Path(td_outside) / "secret.png"
                      secret.write_bytes(b"\x89PNG\r\n\x1a\nFAKE")
                      rel = os.path.relpath(secret, td_base)  # starts with ../
                      html = f'<img src="{rel}" alt="x">'
                      out = self.mod.embed_images(html, td_base)
                      self.assertNotIn("data:", out)
                      self.assertIn(rel, out)
      
      
      if __name__ == "__main__":
          unittest.main(verbosity=2)
      
    • test_onnx_export_contract.py 8.9 KB
      # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
      # SPDX-License-Identifier: Apache-2.0
      
      """Contract tests for the SafeTensors -> ONNX export path.
      
      These run without torch, a GPU, or any model download: they check the wrapper script's
      CLI contract and the exporter's static behaviour (input-size resolution, architecture
      gating), which is what actually breaks when the export path is refactored.
      
      The real export is exercised in-container against the DeepStream image; see
      references/model-acquire.md step 2b-iii.
      
      Run:
          python3 -m unittest discover -s tests -v
      """
      from __future__ import annotations
      
      import ast
      import json
      import subprocess
      import tempfile
      import unittest
      from pathlib import Path
      
      SKILL_DIR = Path(__file__).resolve().parent.parent
      EXPORT_SH = SKILL_DIR / "scripts" / "model" / "safetensors-to-onnx.sh"
      EXPORT_PY = SKILL_DIR / "scripts" / "model" / "safetensors_to_onnx.py"
      REQUIREMENTS = SKILL_DIR / "scripts" / "requirements.txt"
      
      
      def _load_exporter_module():
          """Import safetensors_to_onnx with torch/transformers stubbed.
      
          Those live only in the container venv. Stubs are installed into sys.modules just long
          enough for the import, then removed, so nothing leaks into other tests.
          """
          import importlib
          import sys
          import types
      
          torch_stub = types.ModuleType("torch")
          nn_stub = types.ModuleType("torch.nn")
      
          class _Module:  # stand-in base class for DetectionWrapper
              def __init__(self, *args, **kwargs):
                  pass
      
          nn_stub.Module = _Module
          torch_stub.nn = nn_stub
          torch_stub.Tensor = object
      
          tf_stub = types.ModuleType("transformers")
          tf_stub.AutoConfig = object
          tf_stub.AutoModelForObjectDetection = object
      
          injected = {"torch": torch_stub, "torch.nn": nn_stub, "transformers": tf_stub}
          saved = {name: sys.modules.get(name) for name in injected}
          sys.modules.update(injected)
          module_dir = str(EXPORT_PY.parent)
          sys.path.insert(0, module_dir)
          try:
              sys.modules.pop("safetensors_to_onnx", None)
              return importlib.import_module("safetensors_to_onnx")
          finally:
              sys.path.remove(module_dir)
              sys.modules.pop("safetensors_to_onnx", None)
              for name, previous in saved.items():
                  if previous is None:
                      sys.modules.pop(name, None)
                  else:
                      sys.modules[name] = previous
      
      
      class TestExportWrapperScript(unittest.TestCase):
          def test_wrapper_exists_and_parses(self):
              self.assertTrue(EXPORT_SH.is_file(), f"missing {EXPORT_SH}")
              rc = subprocess.run(["bash", "-n", str(EXPORT_SH)], capture_output=True, text=True)
              self.assertEqual(rc.returncode, 0, rc.stderr)
      
          def test_wrapper_rejects_missing_args(self):
              rc = subprocess.run(["bash", str(EXPORT_SH)], capture_output=True, text=True)
              self.assertNotEqual(rc.returncode, 0)
              self.assertIn("Usage:", rc.stdout + rc.stderr)
      
          def test_wrapper_errors_without_venv(self):
              """Without the container-built venv it must fail with bootstrap guidance, not a traceback."""
              with tempfile.TemporaryDirectory() as td:
                  rc = subprocess.run(
                      ["bash", str(EXPORT_SH), "some/model", f"{td}/out"],
                      capture_output=True, text=True,
                  )
                  combined = rc.stdout + rc.stderr
                  # Either the venv is genuinely absent (expected on a dev box) or it exists and the
                  # export proceeds; only the absent case is asserted here.
                  if rc.returncode != 0:
                      self.assertIn("venv not found", combined)
                      self.assertIn("setup.sh", combined)
      
          def test_wrapper_no_longer_invokes_optimum(self):
              text = EXPORT_SH.read_text()
              self.assertNotIn("optimum-cli export", text)
              self.assertIn("safetensors_to_onnx.py", text)
      
      
      class TestExporterModule(unittest.TestCase):
          """Static checks on the exporter — no torch import required."""
      
          @classmethod
          def setUpClass(cls):
              cls.source = EXPORT_PY.read_text()
              cls.tree = ast.parse(cls.source)
      
          def test_module_parses(self):
              self.assertTrue(EXPORT_PY.is_file(), f"missing {EXPORT_PY}")
      
          def test_offers_both_export_backends(self):
              """Neither backend handles every architecture, so both must be present.
      
              dynamo copes with models the tracer chokes on; TorchScript is the one that keeps
              RT-DETR's batch dimension dynamic (dynamo specializes it to the trace batch).
              """
              fns = {n.name for n in ast.walk(self.tree) if isinstance(n, ast.FunctionDef)}
              self.assertIn("export_dynamo", fns)
              self.assertIn("export_torchscript", fns)
              self.assertIn("dynamo=True", self.source)
              self.assertIn("dynamo=False", self.source)
      
          def test_each_backend_uses_its_own_dynamic_shape_parameter(self):
              """dynamic_shapes belongs to dynamo, dynamic_axes to TorchScript — mixing is a silent no-op."""
              self.assertIn("torch.export.Dim", self.source)
              self.assertIn("dynamic_shapes", self.source)
              self.assertIn("dynamic_axes", self.source)
      
          def test_falls_back_when_a_backend_specializes_batch(self):
              """A static batch dim must trigger the next backend, not be accepted."""
              self.assertIn("export_torchscript", self.source.split("strategies")[1])
              self.assertIn("static batch dimension", self.source)
      
          def test_opset_defaults_to_18(self):
              """Opset 17 fails the dynamo downgrade pass: 'No Adapter To Version 17 for Resize'."""
              self.assertRegex(self.source, r'"--opset".*?default=18')
      
          def test_declares_deepstream_output_contract(self):
              self.assertIn('"pixel_values"', self.source)
              self.assertIn('"logits"', self.source)
              self.assertIn('"pred_boxes"', self.source)
      
          def test_writes_model_onnx_filename(self):
              """Downstream steps copy models/<name>/onnx_export/model.onnx — keep that name."""
              self.assertIn('"model.onnx"', self.source)
      
          def test_verifies_dynamic_batch_rather_than_assuming(self):
              """A backend can silently bake in a static batch dim; it must be checked, not assumed."""
              fns = {n.name for n in ast.walk(self.tree) if isinstance(n, ast.FunctionDef)}
              self.assertIn("verify", fns)
              self.assertIn("consolidate_external_data", fns)
              # verify() reports whether the batch axis survived so the caller can switch backends.
              self.assertIn("dim_param", self.source)
      
          def test_rejects_non_detection_architectures(self):
              fns = {n.name for n in ast.walk(self.tree) if isinstance(n, ast.FunctionDef)}
              self.assertIn("assert_detection_architecture", fns)
              self.assertIn("ForObjectDetection", self.source)
      
          def test_input_size_resolution_handles_known_preprocessor_shapes(self):
              """Exercise resolve_input_size for real, importing the module with torch stubbed.
      
              The function is pure stdlib, but the module imports torch/transformers at top level,
              which are container-only. Stubbing sys.modules keeps this runnable on a dev box
              without resorting to dynamic code evaluation, which Tier-1 flags as AST1/AST8.
              """
              resolve = _load_exporter_module().resolve_input_size
      
              with tempfile.TemporaryDirectory() as td:
                  d = Path(td)
                  self.assertEqual(resolve(str(d), 512), (512, 512), "explicit override wins")
                  self.assertEqual(resolve(str(d), None), (640, 640), "no config -> 640 default")
      
                  (d / "preprocessor_config.json").write_text(
                      json.dumps({"size": {"height": 800, "width": 1333}}))
                  self.assertEqual(resolve(str(d), None), (800, 1333), "height/width honoured")
      
                  (d / "preprocessor_config.json").write_text(
                      json.dumps({"size": {"shortest_edge": 800, "longest_edge": 1333}}))
                  self.assertEqual(resolve(str(d), None), (800, 800), "shortest_edge -> square")
      
                  (d / "preprocessor_config.json").write_text("{ not valid json")
                  self.assertEqual(resolve(str(d), None), (640, 640), "malformed config falls back")
      
      
      class TestExportDependencies(unittest.TestCase):
          def test_optimum_is_gone(self):
              self.assertNotIn("optimum[exporters]", REQUIREMENTS.read_text())
      
          def test_onnxscript_pinned_for_dynamo(self):
              """The dynamo ONNX backend needs onnxscript at runtime."""
              self.assertRegex(REQUIREMENTS.read_text(), r"(?m)^onnxscript==")
      
          def test_transformers_past_the_rce_fixes(self):
              """GHSA-29pf-2h5f-8g72 is fixed in 5.3.0 and GHSA-fgcw-684q-jj6r in 5.5.0."""
              import re
              m = re.search(r"(?m)^transformers==(\d+)\.(\d+)\.(\d+)", REQUIREMENTS.read_text())
              self.assertIsNotNone(m, "transformers must be pinned")
              major, minor = int(m.group(1)), int(m.group(2))
              self.assertGreaterEqual((major, minor), (5, 5),
                                      "transformers must be >= 5.5.0 to clear both HIGH RCE advisories")
      
      
      if __name__ == "__main__":
          unittest.main()
      
    • test_powershell_installer.py 9.2 KB
      #!/usr/bin/env python3
      
      # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
      # SPDX-License-Identifier: Apache-2.0
      #
      # Licensed under the Apache License, Version 2.0 (the "License");
      # you may not use this file except in compliance with the License.
      # You may obtain a copy of the License at
      #
      # http://www.apache.org/licenses/LICENSE-2.0
      #
      # Unless required by applicable law or agreed to in writing, software
      # distributed under the License is distributed on an "AS IS" BASIS,
      # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      # See the License for the specific language governing permissions and
      # limitations under the License.
      
      """Regression tests for the Windows PowerShell installers of BOTH DeepStream skills.
      
      Why this exists
      ---------------
      `install.ps1` once shipped with:
      
          Copy-Item -LiteralPath $SkillDir -Destination $dest -Recurse -Force
      
      which is not portable. Whether it copies a folder's CONTENTS or the folder ITSELF depends on
      whether the destination already exists, and on Windows PowerShell 5.1 it created the directory
      tree without copying any leaf files -- installing an empty skill directory with no SKILL.md, so
      the agent runtime silently refused to load the skill. Nothing caught it, because the test suites
      only ever exercised install.sh.
      
      The central assertion here is therefore a strict **file-count match** against the source, not just
      "SKILL.md exists": a partial copy is exactly the failure mode that shipped.
      
      Runner
      ------
      Needs a PowerShell. Discovery order: `pwsh`, `powershell`, then the `mcr.microsoft.com/powershell`
      container if docker has it locally (so this runs on Linux dev boxes and CI too). Skips cleanly when
      none is available -- never a false failure on a machine without PowerShell.
      
      Caveat: the container/dev-box runner is PowerShell 7. The bug that motivated this was 5.1-specific,
      so this pins the copy *contract* rather than reproducing every 5.1 quirk. Run it on Windows for
      full fidelity.
      
      Run:
          python3 -m unittest discover -s tests -v
      """
      from __future__ import annotations
      
      import os
      import shutil
      import subprocess
      import tempfile
      import unittest
      from pathlib import Path
      
      REPO_SKILLS = Path(__file__).resolve().parents[2]
      PS_IMAGE = "mcr.microsoft.com/powershell:latest"
      
      # (skill dir name, expected file count key) -- both installers share the copy logic under test.
      SKILLS = ["deepstream-import-vision-model", "deepstream-eval-and-finetune"]
      
      # Machine-local build artifacts each installer strips from the copy; excluded when computing the
      # expected file count. Mirrors the strip rules inside the two install.ps1 scripts.
      STRIP_SUFFIXES = (".pyc", ".so", ".o")
      STRIP_NAMES = ("ds_image_eval",)
      
      
      def _docker_has_ps_image() -> bool:
          if not shutil.which("docker"):
              return False
          try:
              out = subprocess.run(["docker", "images", "-q", PS_IMAGE],
                                   capture_output=True, text=True, timeout=30)
              return bool(out.stdout.strip())
          except Exception:
              return False
      
      
      def _runner():
          """Return a callable(script, args, cwd) -> CompletedProcess, or None if no PowerShell."""
          for exe in ("pwsh", "powershell"):
              if shutil.which(exe):
                  def run_native(script, args, cwd, _exe=exe):
                      return subprocess.run(
                          [_exe, "-NoProfile", "-NonInteractive", "-File", str(script), *args],
                          cwd=str(cwd), capture_output=True, text=True, timeout=300)
                  return run_native
          if _docker_has_ps_image():
              def run_docker(script, args, cwd):
                  # Mount both trees at their real absolute paths so in-container paths match the host's
                  # and the assertions below can read results back directly. `-File` (not `-Command`) so
                  # that -Target/-NoCursor bind as named parameters rather than positional strings.
                  return subprocess.run(
                      ["docker", "run", "--rm",
                       # Write as the invoking user, or the installed tree lands root-owned and the
                       # caller cannot clean up its own temp dir.
                       "--user", f"{os.getuid()}:{os.getgid()}",
                       "-e", "HOME=/tmp",
                       "-v", f"{REPO_SKILLS}:{REPO_SKILLS}:ro",
                       "-v", f"{cwd}:{cwd}",
                       "-w", str(cwd), PS_IMAGE,
                       "pwsh", "-NoProfile", "-NonInteractive",
                       "-File", str(script), *args],
                      capture_output=True, text=True, timeout=600)
              return run_docker
          return None
      
      
      RUN = _runner()
      
      
      def _expected_files(src: Path) -> set[str]:
          """Files the installer should land, relative to the skill root, after its strip rules."""
          out = set()
          for p in src.rglob("*"):
              if not p.is_file():
                  continue
              if "__pycache__" in p.parts:
                  continue
              if p.suffix in STRIP_SUFFIXES or p.name in STRIP_NAMES:
                  continue
              out.add(p.relative_to(src).as_posix())
          return out
      
      
      @unittest.skipIf(RUN is None,
                       "no PowerShell available (need pwsh/powershell on PATH, or the "
                       f"{PS_IMAGE} docker image pulled)")
      class TestPowerShellInstaller(unittest.TestCase):
          def _install(self, skill, target, extra=()):
              # Copy the skill out of the repo first: the installer resolves $PSScriptRoot, and the repo
              # checkout is mounted read-only under the docker runner.
              src = Path(target) / "_src" / skill
              src.parent.mkdir(parents=True, exist_ok=True)
              shutil.copytree(REPO_SKILLS / skill, src)
              proj = Path(target) / "proj"
              proj.mkdir()
              # --no-plugin equivalent: never touch the host's real ~/.claude during a test.
              args = ["-Target", str(proj), "-NoCursor", *extra]
              if skill == "deepstream-eval-and-finetune":
                  args.append("-NoPlugin")
              res = RUN(src / "install.ps1", args, target)
              return res, proj, src
      
          def test_installs_every_source_file_including_skill_md(self):
              for skill in SKILLS:
                  with self.subTest(skill=skill), tempfile.TemporaryDirectory() as td:
                      res, proj, src = self._install(skill, td)
                      self.assertEqual(res.returncode, 0,
                                       f"installer failed\nstdout={res.stdout}\nstderr={res.stderr}")
                      dest = proj / ".claude" / "skills" / skill
                      self.assertTrue((dest / "SKILL.md").is_file(),
                                      f"SKILL.md missing -> the skill would not load. stdout={res.stdout}")
                      # The regression: an empty/partial tree used to pass silently.
                      got = {p.relative_to(dest).as_posix() for p in dest.rglob("*") if p.is_file()}
                      self.assertEqual(got, _expected_files(src),
                                       "installed file set does not match the source")
      
          def test_installs_for_codex_as_well_as_claude(self):
              for skill in SKILLS:
                  with self.subTest(skill=skill), tempfile.TemporaryDirectory() as td:
                      _, proj, _ = self._install(skill, td)
                      for agent in (".claude", ".codex"):
                          self.assertTrue((proj / agent / "skills" / skill / "SKILL.md").is_file(),
                                          f"{agent} install is missing SKILL.md")
      
          def test_no_cursor_flag_is_honoured(self):
              skill = SKILLS[0]
              with tempfile.TemporaryDirectory() as td:
                  _, proj, _ = self._install(skill, td)
                  self.assertFalse((proj / ".cursor").exists(), "-NoCursor still wrote .cursor/")
      
          def test_reinstall_is_idempotent_and_does_not_nest(self):
              for skill in SKILLS:
                  with self.subTest(skill=skill), tempfile.TemporaryDirectory() as td:
                      _, proj, src = self._install(skill, td)
                      dest = proj / ".claude" / "skills" / skill
                      first = {p.relative_to(dest).as_posix() for p in dest.rglob("*") if p.is_file()}
                      args = ["-Target", str(proj), "-NoCursor"]
                      if skill == "deepstream-eval-and-finetune":
                          args.append("-NoPlugin")
                      res = RUN(src / "install.ps1", args, td)
                      self.assertEqual(res.returncode, 0, f"reinstall failed: {res.stderr}")
                      again = {p.relative_to(dest).as_posix() for p in dest.rglob("*") if p.is_file()}
                      self.assertEqual(first, again, "reinstall changed the installed file set")
                      self.assertFalse((dest / skill).exists(),
                                       "reinstall nested the skill inside itself")
      
          def test_install_fails_loudly_when_skill_md_would_be_missing(self):
              """The guard must fire -- a silent partial install is the failure mode that shipped."""
              skill = SKILLS[0]
              with tempfile.TemporaryDirectory() as td:
                  src = Path(td) / "_src" / skill
                  src.parent.mkdir(parents=True)
                  shutil.copytree(REPO_SKILLS / skill, src)
                  (src / "SKILL.md").unlink()
                  proj = Path(td) / "proj"
                  proj.mkdir()
                  res = RUN(src / "install.ps1", ["-Target", str(proj), "-NoCursor"], td)
                  self.assertNotEqual(res.returncode, 0,
                                      "installer reported success with SKILL.md absent")
                  self.assertIn("SKILL.md", res.stdout + res.stderr)
      
      
      if __name__ == "__main__":
          unittest.main()
      
    • test_skill_model_intake.py 2 KB
      # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
      # SPDX-License-Identifier: Apache-2.0
      
      import re
      import unittest
      from pathlib import Path
      
      
      SKILL_ROOT = Path(__file__).resolve().parents[1]
      
      
      class TestSkillModelIntake(unittest.TestCase):
          @classmethod
          def setUpClass(cls):
              cls.skill = (SKILL_ROOT / "SKILL.md").read_text()
              cls.acquire = (SKILL_ROOT / "references" / "model-acquire.md").read_text()
              cls.choice = cls.skill.split(
                  "## Model choice — always offer two options", 1
              )[1].split("## Pipeline Overview", 1)[0]
      
          def test_exactly_two_ordered_model_choices_are_offered(self):
              self.assertEqual(
                  re.findall(r"^### ([12])\. ", self.choice, flags=re.MULTILINE),
                  ["1", "2"],
              )
      
          def test_default_is_the_validated_rtdetr_model(self):
              self.assertIn("Default model (recommended)", self.choice)
              self.assertIn("PekingU/rtdetr_r50vd", self.choice)
      
          def test_custom_choice_accepts_hf_or_versioned_ngc(self):
              self.assertIn("Hugging Face model ID", self.choice)
              self.assertIn("NVIDIA NGC catalog model URL including its version", self.choice)
              self.assertIn("rejects classification, segmentation", self.choice)
      
          def test_model_acquire_runbook_repeats_the_choice_gate(self):
              intake = self.acquire.split("## Intake — choose the model", 1)[1].split(
                  "## MANDATORY", 1
              )[0]
              self.assertIn("Default model (recommended)", intake)
              self.assertIn("Custom object-detection model", intake)
              self.assertIn('INPUT="PekingU/rtdetr_r50vd"', intake)
      
          def test_dry_run_has_no_side_effects(self):
              normalized = " ".join(self.choice.split())
              for phrase in (
                  "without browsing",
                  "downloading",
                  "launching Docker",
                  "writing files",
                  "starting processes",
              ):
                  self.assertIn(phrase, normalized)
      
      
      if __name__ == "__main__":
          unittest.main()
      
    • __init__.py 137 B
      # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
      # SPDX-License-Identifier: Apache-2.0
      
  • .gitattributes 640 B · in bundle
  • .gitignore 46 B · in bundle
  • BENCHMARK.md 4.9 KB
    # Skill Benchmark: deepstream-import-vision-model
    
    > ✅ **Overall verdict: PASS — Recommended for publication**
    
    ## Publication Recommendation
    
    Recommended for publication based on the completed evaluation evidence in this report.
    
    ## Evaluation Metadata
    
    - Skill: `deepstream-import-vision-model`
    - Evaluation date: 2026-08-13
    - Evaluator version: `1.2.4`
    - Agents: Claude Code (`aws/anthropic/bedrock-claude-opus-4-8`), Codex (`openai/openai/gpt-5.5`)
    - Tasks: 13 evaluation tasks (13 positive)
    - Dataset digest: `sha256:09a6b1b8629eb5bcf468be26c1ee2aafdbd85a4b31fa501b9a6aab41a1ff76f1` (skill-evaluator-dataset-snapshot/1)
    - Attempts per task: 1
    - Environment: `k8s-sandbox`
    - Tier 3 evidence: required for publication
    
    Each task attempt ran in its own isolated sandbox pod.
    
    ## What This Report Answers
    
    The three-tier evaluation checks whether the skill:
    
    - is safe to use;
    - produces correct answers;
    - is discovered and activated when needed;
    - helps the agent complete the user's goal and expected workflow; and
    - avoids wasted skill and tool usage.
    
    ## Results at a Glance
    
    | Measure | Claude Code (Baseline → Skill Uplift) | Codex (Baseline → Skill Uplift) |
    |---|---:|---:|
    | Overall | 44% → 72% (+27 points) | 46% → 61% (+15 points) |
    | Security | 58% → 54% (-4 points) | 35% → 42% (+8 points) |
    | Correctness | 42% → 86% (+45 points) | 65% → 72% (+8 points) |
    | Discoverability | 51% → 88% (+37 points) | 50% → 72% (+22 points) |
    | Effectiveness | 24% → 43% (+19 points) | 28% → 34% (+6 points) |
    | Efficiency | 48% → 86% (+38 points) | 54% → 84% (+30 points) |
    
    **How to read this table:** baseline is the same task attempted without the target skill. Uplift is `skill score - baseline score`, shown in percentage points.
    
    Example: `47% → 92% (+45 points)` means the skill-assisted run scored 92%, 45 percentage points above its 47% no-skill baseline.
    
    ## Tier Status
    
    | Tier | Purpose | Status | Evidence |
    |---|---|---|---|
    | Tier 1 | Static validation | **PASSED WITH OBSERVATIONS** | 1 validator(s); 5 finding(s) |
    | Tier 2 | Semantic deduplication | **NOT RUN** | No result was recorded |
    | Tier 3 | Live agent evaluation | **PASS** | 2 agent(s); 13 task(s) |
    
    ## Findings and Observations
    
    <details>
    <summary>Show detailed findings and successful checks</summary>
    
    - **MEDIUM** SCHEMA/body_recommended_section: Missing recommended section: '## Instructions' (`skills/deepstream-import-vision-model/SKILL.md`)
    - **LOW** SCHEMA/unexpected_file: Unexpected 'install.ps1' in skill root (`skills/deepstream-import-vision-model/install.ps1`)
    - **LOW** SCHEMA/unexpected_file: Unexpected 'install.sh' in skill root (`skills/deepstream-import-vision-model/install.sh`)
    - **LOW** SCHEMA/unexpected_file: Unexpected 'setup.sh' in skill root (`skills/deepstream-import-vision-model/setup.sh`)
    - **LOW** SCHEMA/unexpected_file: Unexpected 'CHANGELOG.md' in skill root (`skills/deepstream-import-vision-model/CHANGELOG.md`)
    
    </details>
    
    ## Scoring Methodology
    
    <details>
    <summary>Show dimension definitions, source signals, and thresholds</summary>
    
    | Dimension | Question | Scored signals |
    |---|---|---|
    | Security | Is it safe to use? | `security` (100%) |
    | Correctness | Is the answer correct? | `accuracy` (100%) |
    | Discoverability | Was the right skill loaded when needed? | `skill_execution` (100%) |
    | Effectiveness | Did the skill help complete the task? | `goal_accuracy` (50%) + `behavior_check` (50%) |
    | Efficiency | Did it avoid wasted tool or skill usage? | `skill_efficiency` (100%) |
    
    - Dimension bands: PASS at 50% or above; NEUTRAL from 40% to below 50%; FAIL below 40%.
    - Overall Tier 3 lift: PASS at +5 points or more; FAIL at -10 points or less; values between those bands are NEUTRAL.
    - Overall verdict: PASS only when every configured dimension passes for at least one supported agent. Lift is reported as diagnostic evidence and does not override this gate.
    - The 50% attempt pass threshold is a separate per-task gate; it is not the dimension pass threshold.
    - Effectiveness is the equal-weight mean of goal completion (`goal_accuracy`) and expected workflow adherence (`behavior_check`).
    - Token efficiency is a separate report-only signal. It does not change a dimension score or the overall verdict.
    
    Signals present in this run:
    
    - `security` (Security): unsafe operations, secret leakage, and unauthorized access.
    - `skill_execution` (Skill Execution): whether the expected skill was found and executed.
    - `skill_efficiency` (Efficiency): routing quality, workspace-aware skill reads, and productive tool use.
    - `accuracy` (Accuracy): final-answer correctness against the reference answer.
    - `goal_accuracy` (Goal Accuracy): whether the user's goal was achieved.
    - `behavior_check` (Behavior Check): whether the expected workflow behavior was followed.
    
    </details>
    
    ## Freshness
    
    Regenerate this benchmark when the skill, evaluation dataset, target agent/model, evaluator version, environment, or scoring policy changes.
    
  • CHANGELOG.md 18.1 KB
    # Changelog
    
    All notable changes to `deepstream-import-vision-model` are documented here. Format follows
    [Keep a Changelog](https://keepachangelog.com/) and the skill uses
    [Semantic Versioning](https://semver.org/).
    
    ## [1.5.2] — 2026-08-05
    
    ### Changed
    - Added `scripts/model/resolve-engine.sh` so Steps 6–7 and Step 8 share one engine
      resolver instead of each inlining the same glob, empty check, and `MAX_BS` parse.
    
    ### Fixed
    - Six defects found running the skill end-to-end on an H100 rather than by review.
    
    ## [1.5.1] — 2026-08-04
    
    ### Changed
    - De-duplicated ONNX label extraction into a single helper shared by the HuggingFace and
      NGC routes.
    
    ## [1.5.0] — 2026-08-04
    
    ### Fixed
    - Cleared the HIGH defects reported by SkillCritic across both DeepStream skills.
    
    ## [1.4.3] — 2026-08-04
    
    ### Added
    - The missing `## Examples` section, with three concrete invocations: the default
      end-to-end run, a SafeTensors export showing the real dynamo → TorchScript fallback,
      and a revision-pinned build.
    
    ## [1.4.2] — 2026-08-04
    
    ### Fixed
    - Cleared the remaining Tier-1 Code Risk Analysis findings, including B615
      `huggingface_unsafe_download` — every `from_pretrained` / `snapshot_download` call now
      pins a revision.
    
    ## [1.4.1] — 2026-08-04
    
    ### Fixed
    - Neutralised the NGC images' global `PIP_CONSTRAINT`, whose tested pins conflicted with
      this skill's dependency set and made pip fail with `ResolutionImpossible`.
    - Repaired transformers 5.x breakage in the export path.
    
    ## [1.4.0] — 2026-08-04
    
    ### Changed
    - **Replaced `optimum-cli` with `torch.onnx.export`.** `optimum[exporters]` pinned
      transformers below 4.54.0, but the two HIGH RCE advisories (GHSA-29pf-2h5f-8g72,
      GHSA-fgcw-684q-jj6r) are only fixed in 5.3.0 and 5.5.0, so the pin was unfixable while
      optimum stayed. optimum 2.1.0 had also dropped the `onnx` subcommand, making that path a
      dead end regardless. The exporter now uses the dynamo backend with a TorchScript
      fallback and verifies the batch dimension stayed dynamic.
    
    ## [1.3.6] — 2026-08-04
    
    ### Fixed
    - Cleared the NVSkills-Eval Tier-1 high-risk and Tier-2 findings that blocked the content
      gate, including the Agent Snooping findings in `install.sh`.
    
    ## [1.3.5] — 2026-07-29
    
    ### Added
    - Explicit model-choice intake: the skill now always offers the validated default model
      and a custom object-detection model, and never silently substitutes one.
    
    ## [1.3.4] — 2026-07-28
    
    ### Fixed
    - Updated the full workflow and container references to DeepStream 9.1 and CUDA 13.2.
    - Made Bash and PowerShell reinstallation safe when source and destination are identical.
    - Reconciled the README, phase references, tests, and eval expectations with the consolidated single-skill architecture.
    - Pinned the Python dependency stack used by the in-container setup.
    - Added standard Codex UI metadata and repository-required SPDX identifiers.
    
    ## [1.3.3] — 2026-07-20
    
    ### Fixed
    - **`bc` dependency removed — timing/throughput math silently returned empty.** The DeepStream
      container has no `bc`, so every `$(echo "$A - $B" | bc)` produced an empty string with no error,
      leaving all pipeline-timing (and some throughput) values blank. Installing `bc` via `setup.sh`
      would not help — apt installs do not persist across the ephemeral `--rm` phase containers. All
      reference-doc timing now uses `python3 -c "print(round(...))"`; the helper scripts
      (`benchmark-ds.sh`, `ds-sweep.sh`, `benchmark-trtexec.sh`) use `awk "BEGIN{printf ...}"`. Both
      `python3` and `awk` are always present in the image.
    - **Step 8 PDF generation failed on a fresh run.** `setup.sh` installs `wkhtmltopdf` via apt inside
      an ephemeral `--rm` container, so the binary is gone by the time Step 8 runs in a later container
      (only the `/work`-mounted venv persists). `scripts/report/md-to-html-pdf.py` now self-heals via an
      `ensure_wkhtmltopdf()` helper that installs it if missing before rendering the PDF. The HTML
      (charts base64-inlined) was already unaffected.
    
    ### Changed
    - **Real-time stream selection now converges instead of halving.** When DS Run 2 came in marginally
      under 30 fps/stream, the old fallback *halved* RT_STREAMS (e.g. 38 streams @ 29.6 fps → 19),
      discarding ~half the GPU's real capacity and reporting a misleadingly low real-time count. Step 7
      now recomputes the target from the measured throughput (`floor(TOTAL_FPS_RUN2 / 30)`) and steps
      down one stream at a time, landing on the true ceiling (e.g. 37) in 1–2 short retries.
    
    ## [1.3.2] — 2026-07-17
    
    ### Removed
    - **`.claude-plugin/plugin.json`** — the skill now ships as a plain skill (like the sibling
      `deepstream-eval-and-finetune`), consistent with how it is installed by `install.sh` (whole-directory
      copy into `.claude/skills/` and `.cursor/skills/`) and used both standalone and bundled with other
      skills. The manifest was not referenced by `install.sh` and the skill was not registered in the repo
      marketplace, so removal has no effect on standalone or bundled use. This also lets NVCARPS nv-base
      classify the directory as `Type: skill` and run its Tier-3 live agent-eval (producing `BENCHMARK.md` +
      an `AGENT_EVAL` result), which the content gate requires and which the `Type: plugin` path skipped.
      To publish it later as a standalone marketplace plugin, re-add the manifest and run the signed
      marketplace flow.
    
    ## [1.3.1] — 2026-07-16
    
    ### Added
    - **`install.ps1`** — native-Windows (PowerShell) installer twin of `install.sh`, with the identical
      sequence and flags (`-Target`=`--target`, `-NoCursor`=`--no-cursor`, `-DryRun`=`--dry-run`). Copies
      the skill into `<project>\.claude\skills\` (and `.cursor\skills\`). PowerShell 5.1+ compatible.
    
    ### Changed
    - `.gitattributes` forces LF on `*.ps1`; `references/windows.md` install note now points to `install.ps1`.
    
    ## [1.3.0] — 2026-07-16
    
    ### Changed
    - **Runs entirely through Docker — no host packages.** Every step (venv/ONNX export, TensorRT engine
      build, nvinfer parser compile, DeepStream run, PDF report) now executes INSIDE the DeepStream
      container. The host needs only Docker + the NVIDIA driver, so the skill runs identically on Linux,
      **Windows** (Docker Desktop + WSL2 backend), and macOS.
    - Removed the host-native toolchain assumptions (host `trtexec`/`nvidia-smi`/`dpkg`/`make`/host venv/
      `apt-get`); `wkhtmltopdf` + the export venv (`build/.venv_optimum`) are provisioned in-container by
      the new `setup.sh`.
    - **Reversed the "always build engines on the host" guidance** — build and run now share one image, so
      there is no TensorRT build-vs-runtime version skew (the exact failure the old rule tried to avoid).
    - `install.sh` now installs the **whole self-contained skill dir** (SKILL.md + references + scripts +
      setup.sh) into `.claude/skills/…`, dropping the separate `scripts/` tree and the `ln -sf` symlink path.
    
    ### Added
    - `setup.sh` (in-container bootstrap: venv + deps + wkhtmltopdf), `scripts/preflight.sh` (GPU + venv +
      trtexec, with container-mode), `scripts/requirements.txt`, `scripts/dsrun.sh` (docker wrapper),
      `.gitattributes` (LF), and `references/windows.md` (cross-platform runbook).
    
    ### Fixed
    - `scripts/model/safetensors-to-onnx.sh` no longer runs `python3 -m venv` (fails on the container
      python, which lacks ensurepip) — it reuses the virtualenv built by `setup.sh`.
    
    ## [1.2.2] — 2026-05-19
    
    ### Changed
    - **Skill renamed** from `deepstream-byovm` to `deepstream-import-vision-model` across
      all files: `name:` in `SKILL.md` and `.claude-plugin/plugin.json`, package directory
      (`team-skills/deepstream-sdk/deepstream-import-vision-model/`), installed skill
      directories (`.claude/skills/deepstream-import-vision-model`,
      `.cursor/skills/deepstream-import-vision-model`), runtime scripts path
      (`scripts/deepstream-import-vision-model/`), invocation hints, eval prompts, tag
      (`byovm` → `import-vision-model`), README/title (`DS BYOVM` →
      `DeepStream Import Vision Model`), and cross-skill references in
      `team-skills/deepstream-sdk/README.md`,
      `team-skills/deepstream-sdk/deepstream-profile-pipeline/SKILL.md`, and
      `team-skills/deepstream-sdk/deepstream-profile-pipeline/README.md`. Body content
      (SKILL.md sections, `references/*.md`, and 5 differing scripts) was also resynced
      with the upstream `ds-copilot/skills/deepstream-import-vision-model` source
    - **Encoder fallback**: replaced `x264enc` fallback with `theoraenc + oggmux` (LGPL,
      outputs `.ogv`). `x264enc` and `openh264enc` are now prohibited (Rule 10). When
      neither NVENC nor `theoraenc`/`oggmux` is available, single-stream capture is
      skipped gracefully (`DS_SINGLE_STREAM_MODE=skipped`)
    - **Video source**: enforced `sample_720p.mp4` (1280×720) as the mandatory default;
      custom paths only via explicit `DS_VIDEO` (Rule 11)
    - **Performance measurement**: switched DS multi-stream benchmark from
      `gst-launch-1.0 ! fpsdisplaysink` (parsing `Current FPS:`) to `deepstream-app -c …
      enable-perf-measurement=1` (parsing `**PERF:` log lines) via the new
      `scripts/deepstream/ds-perf-run.sh` wrapper. Removes runtime
      dependency on `gstreamer1.0-plugins-bad`
    - **Media probing**: replaced `ffprobe` / `gst-discoverer` calls in
      `benchmark-ds.sh` and `ds-sweep.sh` with `mediainfo` (with safe fallbacks)
    - **Pipeline NVENC primary**: switched `nvvideoconvert` output format from `I420`
      to `NV12` ahead of `nvv4l2h264enc`
    - **Puppeteer sandbox**: split into two vetted configs — `mermaid-puppeteer.json`
      (sandboxed; non-root) and `mermaid-puppeteer-root.json` (sandbox disabled; only
      selected when `uid == 0`). `render-mermaid-for-pdf.py` auto-selects the right
      one and refuses any user-supplied config that does not resolve to one of these
      two shipped files (blocks `--remote-debugging-port`, `--load-extension`, etc.)
    - **`benchmark-trtexec.sh` interface**: replaced fixed `b1 b16 b32 b64` positional
      args with variadic `<bs:engine> [<bs:engine> …] [duration]`
    - **`ds-sweep.sh`**: input shape and tensor name are now derived dynamically via
      `inspect-onnx.py` instead of hardcoded `inputs` + `640×640` (fixes YOLOv8 /
      RT-DETR / DETR / non-YOLOX models). Power-law batch-size prediction is guarded
      against α≈0 (flat curves)
    - **Frame extraction**: `extract-frame.sh` now auto-detects `.mp4` vs `.ogv` and
      routes through the matching demux+decoder chain
    - **Custom parser filenames**: introduced `MODEL_NAME_SAFE = tr -c 'A-Za-z0-9' '_'`
      for `.cpp`/`.so` filenames so models like `rtdetr-l` produce a consistent
      `libnvdsinfer_rtdetr_l_parser.so`
    - **nvinfer config**: moved `cluster-mode` inline `#` comments to their own
      lines in both heredocs (GKeyFile rejects inline `#`)
    - **`make-static-batch-onnx.py`**: use `onnx.numpy_helper.to_array` /
      `from_array` for Reshape initializer patching (the old raw-bytes path silently
      skipped `int64_data` initializers, leaving `batch=1` baked in)
    - **Report verification**: replaced `>500 KB` heuristic with deterministic
      `grep -o 'data:image/png' benchmark_report.html | wc -l == 5`
    - **System tools**: pre-flight now installs `mediainfo` and checks for
      `deepstream-app` (instead of `gstreamer1.0-plugins-bad`)
    
    ### Added
    - `scripts/deepstream/ds-perf-run.sh` — wraps `deepstream-app` with
      `enable-perf-measurement=1`, emits `**PERF:` log lines for the report parser
    - `scripts/report/mermaid-puppeteer-root.json` — vetted root-only Puppeteer config
    - New SKILL.md table rows: `ds-perf-run.sh`, `md-to-pdf.sh`,
      `mermaid-puppeteer-root.json`
    
    ### Fixed
    - `ds-kitti-dump.sh`: added `set -euo pipefail`, replaced manual `rm -f` with
      trap-based cleanup, guarded `timeout` pipeline with `set +o pipefail` to
      preserve `PIPESTATUS`
    - `safetensors-to-onnx.sh`: added missing `set -euo pipefail`
    - `generate-benchmark-charts.py`: removed unused `import math`
    
    ## [1.2.1] — 2026-04-24
    
    ### Changed
    - **Skill renamed** from `ds-byovm` to `deepstream-byovm` across all files:
      `name:` in `SKILL.md` and `plugin.json`, installed skill directory
      (`.claude/skills/deepstream-byovm`), runtime scripts path
      (`scripts/deepstream-byovm/`), invocation hints, eval prompts, and
      all cross-references in `references/*.md`
    
    ## [1.2.0] — 2026-04-24
    
    ### Changed
    - **References pattern**: removed 4 standalone sub-skills (`nv-model-acquire`,
      `nv-engine-build`, `ds-run-pipeline`, `nv-byovm-report`); their content is now
      in `skills/ds-byovm/references/` (4 .md files) matching the ds-copilot
      `deepstream-dev` convention of single skill + reference documents
    - **Single skill dir**: `SKILL.md` moved from package root into
      `skills/ds-byovm/SKILL.md` (lean ~170 lines); root `SKILL.md` removed
    - **plugin.json**: `"skills": "./"` → `"skills": "skills/ds-byovm/"` to point
      at the skill directory instead of the package root
    - **install.sh**: creates one symlink (`skills/ds-byovm/` → `.claude/skills/ds-byovm`
      and `.cursor/skills/ds-byovm`) instead of 5; no sub-skill symlinks
    - **Installed structure** is now:
      ```text
      .claude/skills/ds-byovm/
        SKILL.md
        references/
          model-acquire.md
          engine-build.md
          pipeline-run.md
          report-generation.md
      scripts/ds-byovm/   (19 scripts, unchanged)
      ```
    - **Tests**: updated install dry-run assertions for single-skill structure;
      sub-skill name assertions removed; `assertNotIn` for sub-skill names added
    
    ## [1.1.0] — 2026-04-24
    
    ### Changed
    - **Skill-only architecture**: removed `agents/deepstream-sdk/ds-byovm.md`;
      top-level `SKILL.md` now serves both Claude Code and Cursor (Cursor does not
      support agents — skill is the correct primitive for cross-tool compatibility)
    - **Sub-skills renamed** with `nv-`/`ds-` prefix for namespace clarity:
      - `hf-model-acquire` → `nv-model-acquire`
      - `trt-engine-build` → `nv-engine-build`
      - `ds-integration` → `ds-run-pipeline`
      - `benchmark-report` → `nv-byovm-report`
    - **SKILL.md enhanced** (version 1.0.1 → 1.1.0): merged pre-flight checks,
      mandatory model folder structure, engine naming convention, run budget table,
      pipeline timing pattern, and report output convention from the removed agent doc
    - **install.sh updated**: removed agent installation block, updated symlink
      targets to new sub-skill names; invocation hints say "skill" not "agent"
    - **README.md updated**: skill-only usage section for Claude Code + Cursor;
      sub-skill standalone invocation documented; all agent references removed
    - **evals.json**: all prompts and assertion text updated from "agent" to "skill"
    - **Tests expanded**: dry-run test now asserts all 5 skill names, Cursor skills
      presence, and absence of `.claude/agents/` directory
    - **Shebang consistency**: all shell scripts use `#!/usr/bin/env bash` for
      portability across container images and macOS environments
    - **Local `.gitignore`**: added skill-level `.gitignore` for portability when
      placed in repos that do not inherit team-mind-hub's root `.gitignore`
    
    ## [1.0.1] — 2026-04-23
    
    ### Fixed
    - `ds-integration` Step 6g KITTI dump produced zero detection files.
      `gie-kitti-output-dir` is a `deepstream-app` `[application]` key — it is
      not read by `nvinfer`, so appending it to the nvinfer config and running a
      `gst-launch-1.0 ... nvinfer ...` pipeline silently wrote no files.
      Step 6g now invokes `scripts/ds-byovm/deepstream/ds-kitti-dump.sh`, which
      wraps `deepstream-app` with the correct `[application]` section.
    
    ### Changed
    - `hf-model-acquire` Step 2b now uses a **single shared** `build/.venv_optimum`
      for SafeTensors → ONNX export across all models, matching what
      `scripts/ds-byovm/model/safetensors-to-onnx.sh` already does. The previous
      prose created a fresh `build/.venv_$MODEL_NAME` per model, which re-installed
      `optimum`/`transformers`/`torch` every run (~minutes + GBs wasted). New
      models that need extra packages (`timm` for DETR, `onnxsim`, etc.) should
      `pip install` into the shared venv. `cleanup.sh` still removes any legacy
      per-model venvs for backward compatibility, and explicitly preserves the
      shared `build/.venv_optimum`.
    
    ## [1.0.0] — 2026-04-22
    
    ### Added
    - Initial release of the DeepStream Bring Your Own Vision Model (BYOVM) skill
    - End-to-end pipeline: HuggingFace / NGC model → ONNX → TensorRT engine → DeepStream → benchmark report
    - Four orchestrated sub-skills under `skills/`:
      - `hf-model-acquire` — model download and format routing (ONNX vs SafeTensors)
      - `trt-engine-build` — dynamic TRT engine build + `trtexec` benchmarks
      - `ds-integration` — custom `nvinfer` parser, single-stream + multi-stream DS runs
      - `benchmark-report` — 5-chart Markdown → HTML → PDF report
    - Runtime scripts under `scripts/`:
      - `model/` — HF/NGC list + download helpers, ONNX inspection, SafeTensors → ONNX export, scoped cleanup
      - `engine/` — `trtexec` benchmark helper
      - `deepstream/` — single-stream, sweep, KITTI dump, frame extraction helpers
      - `report/` — chart generation, Mermaid → PNG, Markdown → HTML → PDF
    - Installer (`install.sh`) with validated `--target` and dry-run mode
    - Declared `permissions:` block in `SKILL.md` frontmatter: tool allowlist, MCP
      scope, network egress allowlist (`huggingface.co`, `api.ngc.nvidia.com`,
      `api-inference.huggingface.co`), and filesystem read/write scoping
    - Test suite (`tests/test_hardened_scripts.py`) covering input validation for
      all hardened shell scripts and the PDF image-embedding helper (24 tests)
    
    ### Security
    - All shell scripts validate inputs against `^[A-Za-z0-9._/-]+$` or tighter
      before touching filesystem or network
    - `curl` invocations pinned to HTTPS + TLSv1.2 with bounded timeouts; optional
      `$HF_TOKEN` honored for gated HuggingFace repos
    - `install.sh` rejects `--target` values that are empty, `/`, contain `..`,
      or don't exist; destructive `rm -rf` is scoped to paths under `$TARGET`
    - `scripts/report/md-to-html-pdf.py` base64-inlines images before rendering;
      `wkhtmltopdf` runs without `--enable-local-file-access`
    - `scripts/report/render-mermaid-for-pdf.py` refuses user-supplied Puppeteer
      configs; always uses the vetted `mermaid-puppeteer.json` shipped with the skill
    
    ### Known limitations
    - Nested sub-skills under `skills/` surface a low-severity schema warning
      from some scanners; kept in place because the agent file preloads them by
      name and the installer symlinks them into `.claude/skills/` at the target
    - Engine build time depends on GPU, ONNX complexity, and requested batch size;
      the skill retries on OOM by halving batch size but gives up after reaching 1
    
  • install.ps1 5.4 KB · in bundle
  • install.sh 6.3 KB
    #!/usr/bin/env bash
    
    # SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
    # SPDX-License-Identifier: Apache-2.0
    #
    # Licensed under the Apache License, Version 2.0 (the "License");
    # you may not use this file except in compliance with the License.
    # You may obtain a copy of the License at
    #
    # http://www.apache.org/licenses/LICENSE-2.0
    #
    # Unless required by applicable law or agreed to in writing, software
    # distributed under the License is distributed on an "AS IS" BASIS,
    # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    # See the License for the specific language governing permissions and
    # limitations under the License.
    
    # deepstream-import-vision-model — Install script
    # Installs the skills and runtime scripts into a target project.
    # Supports Claude Code (.claude/), Codex (.codex/), and Cursor (.cursor/) out of the box.
    #
    # Usage:
    #   bash install.sh --target <project-path> [--dry-run] [--no-cursor]
    #
    # Examples:
    #   bash install.sh --target ~/work/my-project --dry-run
    #   bash install.sh --target ~/work/my-project
    #   bash install.sh --target ~/work/my-project --no-cursor
    set -euo pipefail
    
    SKILL_DIR="$(cd "$(dirname "$0")" && pwd)"
    
    TARGET=""
    DRY_RUN=false
    NO_CURSOR=false
    
    usage() {
        local rc="${1:-0}"
        cat <<EOF
    Usage: $0 --target <project-path> [--dry-run] [--no-cursor]
    
      --target <path>   Project directory to install into (required)
      --dry-run         Show what would be done without making any changes
      --no-cursor       Skip Cursor (.cursor/skills/) installation
      -h, --help        Show this help
    
    Example:
      bash install.sh --target ~/work/my-deepstream-project
    EOF
        exit "$rc"
    }
    
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --target)
                if [[ $# -lt 2 || -z "${2:-}" || "$2" == -* ]]; then
                    echo "Error: --target requires a path argument" >&2
                    usage 1
                fi
                TARGET="$2"
                shift 2
                ;;
            --dry-run)          DRY_RUN=true; shift ;;
            --no-cursor)        NO_CURSOR=true; shift ;;
            -h|--help)          usage 0 ;;
            *)                  echo "Unknown option: $1" >&2; usage 1 ;;
        esac
    done
    
    # --- TARGET validation (hardened) ----------------------------------------------
    if [[ -z "$TARGET" ]]; then
        echo "Error: --target is required" >&2
        usage 1
    fi
    
    case "$TARGET" in
        ""|"/"|*..*)
            echo "Error: invalid --target value: $TARGET" >&2
            exit 1
            ;;
    esac
    
    if [[ ! -d "$TARGET" ]]; then
        echo "Error: target directory not found: $TARGET" >&2
        exit 1
    fi
    
    TARGET="$(cd "$TARGET" && pwd -P)"
    
    if [[ "$TARGET" == "/" ]] || [[ ${#TARGET} -lt 3 ]]; then
        echo "Error: refusing to install into '$TARGET' (path too short / is root)" >&2
        exit 1
    fi
    # -------------------------------------------------------------------------------
    
    do_copy() {
        local src="$1"
        local dest="$2"
        if $DRY_RUN; then
            echo "  [dry-run] cp -r $src -> $dest"
        else
            mkdir -p "$(dirname "$dest")"
            cp -r "$src" "$dest"
            echo "  Copied: $(basename "$dest")"
        fi
    }
    
    # Scoped cleanup helper: only removes a path that is a subdirectory of $TARGET.
    safe_rm_under_target() {
        local path="$1"
        local resolved
        resolved="$(cd "$(dirname "$path")" 2>/dev/null && pwd -P)/$(basename "$path")" || return 1
        case "$resolved" in
            "$TARGET"/*) ;;
            *) echo "  Refusing to remove $resolved (not under $TARGET)" >&2; return 1 ;;
        esac
        if $DRY_RUN; then
            echo "  [dry-run] rm -rf $resolved"
        else
            echo "  Removing: $resolved"
            rm -rf "$resolved"
        fi
    }
    
    # Install the WHOLE self-contained skill (SKILL.md + references/ + scripts/ + setup.sh + .gitattributes)
    # into a skills directory, so it runs through Docker as one mounted tree (-v <root>:/work). Works for
    # .claude/skills/, .codex/skills/, and .cursor/skills/.
    install_skills_to_dir() {
        local skills_dir="$1"
        local skill_dest="$skills_dir/deepstream-import-vision-model"
        if [[ -d "$skill_dest" ]]; then
            local dest_real
            dest_real="$(cd "$skill_dest" && pwd -P)"
            if [[ "$dest_real" == "$SKILL_DIR" ]]; then
                echo "  Already installed at $skill_dest; source and destination are identical"
                return
            fi
            safe_rm_under_target "$skill_dest"
        fi
        if $DRY_RUN; then
            echo "  [dry-run] cp -r $SKILL_DIR -> $skill_dest  (whole skill; minus __pycache__/*.pyc/built parser .so)"
            return
        fi
        mkdir -p "$(dirname "$skill_dest")"
        cp -r "$SKILL_DIR" "$skill_dest"
        # strip machine-local build artifacts (the venv + parser .so are rebuilt in-container by setup.sh)
        find "$skill_dest" -type d -name '__pycache__' -prune -exec rm -rf {} + 2>/dev/null || true
        find "$skill_dest" \( -name '*.pyc' -o -name '*.so' -o -name '*.o' \) -delete 2>/dev/null || true
        echo "  Copied self-contained skill -> $skill_dest"
    }
    
    echo "=== deepstream-import-vision-model Install ==="
    echo "Skill dir: $SKILL_DIR"
    echo "Target:    $TARGET"
    echo "Cursor:    $($NO_CURSOR && echo "disabled (--no-cursor)" || echo "enabled")"
    echo ""
    
    # Step 1: Claude Code and Codex skills
    echo "Claude Code skills -> $TARGET/.claude/skills/"
    install_skills_to_dir "$TARGET/.claude/skills"
    
    echo ""
    echo "Codex skills -> $TARGET/.codex/skills/"
    install_skills_to_dir "$TARGET/.codex/skills"
    
    echo ""
    # Step 2: Cursor — skills only (Cursor does not support agents)
    if ! $NO_CURSOR; then
        echo "Cursor skills -> $TARGET/.cursor/skills/"
        install_skills_to_dir "$TARGET/.cursor/skills"
        echo ""
    fi
    
    echo ""
    echo "=== Done ==="
    echo ""
    echo "Next — bootstrap the environment IN the container (nothing installs on the host):"
    echo "  docker run --rm -it --gpus all --shm-size=16g -v \"\$PWD\":/work -w /work \\"
    echo "    --entrypoint bash nvcr.io/nvidia/deepstream:9.1-triton-multiarch \\"
    echo "    .claude/skills/deepstream-import-vision-model/setup.sh"
    echo "  (PowerShell: use -v \"\${PWD}:/work\"  ·  see references/windows.md)"
    echo ""
    echo "Claude Code — invoke the skill:"
    echo "  Use deepstream-import-vision-model to run this model: https://huggingface.co/onnx-community/yolov8n"
    if ! $NO_CURSOR; then
        echo ""
        echo "Cursor — invoke the skill:"
        echo "  @deepstream-import-vision-model run this model: https://huggingface.co/onnx-community/yolov8n"
    fi
    
  • README.md 4 KB
    # DeepStream Import Vision Model
    
    Automated end-to-end pipeline: HuggingFace model → TensorRT engine → DeepStream
    multi-stream benchmark → PDF report.
    
    ## Overview
    
    This self-contained skill uses four phase-specific reference documents. Together they automate the full model bringup workflow for NVIDIA DeepStream, from
    downloading a model on HuggingFace or NVIDIA NGC to a publication-ready benchmark report.
    
    Supported input formats: ONNX (direct), SafeTensors (auto-exported via `torch.onnx.export`).
    
    **Current scope:** object detection models only. Classification, segmentation, pose estimation, and other vision tasks are not yet supported — the pipeline fails fast if a non-detection architecture is detected in `config.json`.
    
    ## Prerequisites
    
    **Host: only Docker + the NVIDIA driver.** Everything else runs **inside the DeepStream container** —
    DeepStream, TensorRT/`trtexec`, the Python export venv (`torch`/`onnx`/`onnxruntime`), `wkhtmltopdf`,
    `deepstream-app`/`gst-launch-1.0` — and is bootstrapped by `setup.sh`. Nothing is installed on the host.
    Runs identically on Linux and **Windows** (Docker Desktop + WSL2 backend, required for GPU).
    
    - **Docker** — Docker Desktop with the WSL2 backend on Windows; Docker Engine + NVIDIA Container
      Toolkit on Linux.
    - **NVIDIA GPU + driver** (on Windows, the WSL2 GPU driver — no host CUDA/TensorRT/DeepStream needed).
    - `docker pull nvcr.io/nvidia/deepstream:9.1-triton-multiarch`, then run `setup.sh` through the container.
    
    See **[references/windows.md](references/windows.md)** for the cross-platform run model and the
    per-shell `docker run` mount token.
    
    ## Installation
    
    ```bash
    bash <path-to-deepstream-import-vision-model>/install.sh --target <your-project-path>
    ```
    
    Preview what will be installed first with `--dry-run`:
    
    ```bash
    bash <path-to-deepstream-import-vision-model>/install.sh --target <your-project-path> --dry-run
    ```
    
    Where `<path-to-deepstream-import-vision-model>` is the location of this skill in your repo, e.g.:
    - In **team-mind-hub**: `team-skills/deepstream-sdk/deepstream-import-vision-model`
    - In **ds-copilot**: `team-skills/deepstream-sdk/deepstream-import-vision-model` (same path)
    
    The script copies the complete skill into the target project for Claude Code, Codex, and Cursor. Re-running it safely refreshes an existing installation.
    
    ## Usage
    
    **Claude Code:**
    ```text
    Use deepstream-import-vision-model to run this model: https://huggingface.co/onnx-community/yolov8n
    ```
    
    **Codex:**
    ```text
    Use $deepstream-import-vision-model to deploy and benchmark https://huggingface.co/onnx-community/yolov8n
    ```
    
    **Cursor:**
    ```text
    @deepstream-import-vision-model run this model: https://huggingface.co/onnx-community/yolov8n
    ```
    
    The skill runs the full pipeline autonomously — no manual steps required.
    
    ## Pipeline Steps
    
    | Step | Phase reference | Action |
    |------|-----------|--------|
    | 1–3 | `references/model-acquire.md` | Browse HF repo, download ONNX or export SafeTensors |
    | 4–5 | `references/engine-build.md` | Build dynamic TRT engine, run trtexec benchmarks |
    | 6–7 | `references/pipeline-run.md` | Custom bbox parser, DeepStream single + multi-stream |
    | 8 | `references/report-generation.md` | 5 charts, HTML report, PDF |
    
    ## Output Structure
    
    Per-model outputs are written to `models/<model_name>/` in your project:
    
    ```text
    models/<model_name>/
      model/          ONNX file(s)
      parser/         Custom nvinfer bbox parser (.cpp, .so)
      config/         nvinfer config, DS app config, labels.txt
      scripts/        Model-specific run helpers
      benchmarks/     TRT engines, trtexec logs
      reports/        benchmark_report.md / .html / .pdf + charts/
      samples/        Output videos, test frames, KITTI detections
    ```
    
    ## Files in this package
    
    ```text
    deepstream-import-vision-model/
    ├── SKILL.md                    Top-level skill definition
    ├── README.md                   This file
    ├── references/                 Phase-specific runbooks
    ├── scripts/                    Utility scripts by pipeline phase
    └── tests/                      Installer and script regression tests
    ```
    
  • setup.sh 3.6 KB
    #!/usr/bin/env bash
    # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
    # SPDX-License-Identifier: Apache-2.0
    
    # Copyright (c) 2026, NVIDIA CORPORATION.  All rights reserved. Apache-2.0.
    #
    # One-command environment bootstrap for deepstream-import-vision-model on a FRESH machine.
    # Creates the shared venv (build/.venv_optimum), installs the ONNX-export + report Python deps,
    # and installs wkhtmltopdf (for the PDF report) — ALL INSIDE the container. Idempotent.
    #
    # Nothing runs on the host: the host only needs Docker + the NVIDIA driver. Run this INSIDE the
    # DeepStream container, from the working root (where models/, reports/, build/ will live) — so
    # torch/CUDA/TensorRT and the compiled parser all match the runtime:
    #
    #   # Linux / WSL2 bash:
    #   docker run --rm -it --gpus all --shm-size=16g -v "$PWD":/work -w /work \
    #     --entrypoint bash nvcr.io/nvidia/deepstream:9.1-triton-multiarch \
    #     .claude/skills/deepstream-import-vision-model/setup.sh
    #   # PowerShell: use -v "${PWD}:/work"   ·   cmd: -v "%cd%:/work"   (see references/windows.md)
    set -euo pipefail
    
    SK=".claude/skills/deepstream-import-vision-model"
    VENV="build/.venv_optimum"
    PY="$VENV/bin/python"
    
    if [ ! -d "$SK" ]; then
      echo "[setup] ERROR: run from the working root (the dir that contains $SK)." >&2
      exit 1
    fi
    
    # 1) virtualenv (the container python lacks ensurepip, so bootstrap virtualenv via pip)
    if [ ! -x "$PY" ]; then
      echo "[setup] creating venv at $VENV"
      python3 -m pip install --quiet --user virtualenv 2>/dev/null || python3 -m pip install --quiet virtualenv
      python3 -m virtualenv "$VENV"
    else
      echo "[setup] venv exists: $VENV"
    fi
    
    # 2) Python dependencies (ONNX export + report)
    echo "[setup] installing Python deps from $SK/scripts/requirements.txt (this can take several minutes)"
    "$PY" -m pip install --quiet --upgrade pip
    "$PY" -m pip install -r "$SK/scripts/requirements.txt"
    
    # 3) wkhtmltopdf for the PDF report — installed IN the container (not a host dependency).
    #    Self-contained Qt-WebKit renderer; no browser/Chromium needed.
    if command -v wkhtmltopdf >/dev/null 2>&1; then
      echo "[setup] wkhtmltopdf already present"
    else
      echo "[setup] installing wkhtmltopdf (apt, in-container)"
      if [ "$(id -u)" = "0" ]; then APT=""; else APT="sudo"; fi
      $APT apt-get update -qq && $APT apt-get install -y -qq wkhtmltopdf \
        || echo "[setup] WARN: wkhtmltopdf install failed — the HTML report still works; PDF step will skip"
    fi
    
    # 4) verify
    echo "[setup] verifying…"
    "$PY" - <<'PYV'
    import importlib.util, sys
    mods = ["torch","torchvision","transformers","onnx","onnxruntime","onnxscript",
            "huggingface_hub","matplotlib","numpy","markdown","reportlab"]
    missing = [m for m in mods if importlib.util.find_spec(m) is None]
    import torch
    print(f"  torch {torch.__version__}  cuda_available={torch.cuda.is_available()}")
    if missing: print("  MISSING:", missing); sys.exit(1)
    print("  all required packages present")
    PYV
    command -v wkhtmltopdf >/dev/null 2>&1 && echo "  wkhtmltopdf: $(command -v wkhtmltopdf)" || echo "  wkhtmltopdf: (absent — PDF step will skip)"
    command -v /usr/src/tensorrt/bin/trtexec >/dev/null 2>&1 && echo "  trtexec: /usr/src/tensorrt/bin/trtexec" || echo "  trtexec: (check TensorRT in image)"
    
    echo
    echo "[setup] DONE. Next: run preflight, then the phases via the container —"
    echo "  docker run --rm --gpus all -v \"\$PWD\":/work -w /work --entrypoint bash \\"
    echo "    nvcr.io/nvidia/deepstream:9.1-triton-multiarch $SK/scripts/preflight.sh"
    echo "  (see $SK/SKILL.md + $SK/references/windows.md for the per-shell mount token)"
    
  • skill-card.md 4.5 KB
    ## Description: <br>
    Use this skill to bring a supported object-detection vision model from HuggingFace or NVIDIA NGC into an NVIDIA DeepStream pipeline with end-to-end automation: ONNX download, SafeTensors export, TRT engine build, custom nvinfer bbox parser, multi-stream benchmark, and PDF report. <br>
    
    This skill is ready for commercial/non-commercial use. <br>
    
    ## Owner
    NVIDIA <br>
    
    ### License/Terms of Use: <br>
    CC-BY-4.0 AND Apache-2.0 <br>
    ## Use Case: <br>
    Developers and engineers use this skill to import supported object-detection vision models from HuggingFace or NVIDIA NGC into NVIDIA DeepStream inference pipelines, automating the full workflow from model acquisition through TensorRT engine build, multi-stream benchmarking, and PDF report generation. <br>
    
    ### Deployment Geography for Use: <br>
    Global <br>
    
    ## Requirements / Dependencies: <br>
    **Requires API Key or External Credential:** [Not Specified] <br>
    **Credential Type(s):** [None identified] <br>
    
    Do not include secrets in prompts/logs/output; use least-privilege credentials; rotate keys as appropriate. <br>
    
    ## Known Risks and Mitigations: <br>
    Risk: Review before execution as proposals could introduce incorrect or misleading guidance into skills. <br>
    Mitigation: Review and scan skill before deployment. <br>
    
    ## Reference(s): <br>
    - [Model Acquire](references/model-acquire.md) <br>
    - [Engine Build](references/engine-build.md) <br>
    - [Pipeline Run](references/pipeline-run.md) <br>
    - [Report Generation](references/report-generation.md) <br>
    - [Windows Support](references/windows.md) <br>
    
    
    ## Skill Output: <br>
    **Output Type(s):** [Shell commands, Code, Files, Analysis] <br>
    **Output Format:** [TensorRT engines, ONNX models, C++ parser source, nvinfer configs, benchmark logs, PDF/HTML/Markdown reports] <br>
    **Output Parameters:** [1D] <br>
    **Other Properties Related to Output:** [Outputs organized in a mandatory directory structure under models/{model_name}/] <br>
    
    ## Evaluation Agents Used: <br>
    - Claude Code (`aws/anthropic/bedrock-claude-opus-4-8`) <br>
    - Codex (`openai/openai/gpt-5.5`) <br>
    
    
    
    ## Evaluation Tasks: <br>
    13 evaluation tasks (13 positive), each run in isolated sandbox pods. <br>
    
    ## Evaluation Metrics Used: <br>
    Reported benchmark dimensions: <br>
    - Security: Checks whether the skill is safe to use — detects unsafe operations, secret leakage, and unauthorized access. <br>
    - Correctness: Checks whether the final answer is correct against the reference answer. <br>
    - Discoverability: Checks whether the right skill was found and executed when needed. <br>
    - Effectiveness: Checks whether the skill helped complete the user's goal (goal completion and expected workflow adherence). <br>
    - Efficiency: Checks routing quality, workspace-aware skill reads, and productive tool use. <br>
    
    Underlying evaluation signals used in this run: <br>
    - `security`: Detects unsafe operations, secret leakage, and unauthorized access. <br>
    - `skill_execution`: Verifies whether the expected skill was found and executed. <br>
    - `skill_efficiency`: Evaluates routing quality, workspace-aware skill reads, and productive tool use. <br>
    - `accuracy`: Measures final-answer correctness against the reference answer. <br>
    - `goal_accuracy`: Measures whether the user's goal was achieved. <br>
    - `behavior_check`: Verifies whether the expected workflow behavior was followed. <br>
    
    
    
    ## Evaluation Results: <br>
    | Measure | Claude Code (Baseline → Skill Uplift) | Codex (Baseline → Skill Uplift) |
    |---|---:|---:|
    | Overall | 44% → 72% (+27 points) | 46% → 61% (+15 points) |
    | Security | 58% → 54% (-4 points) | 35% → 42% (+8 points) |
    | Correctness | 42% → 86% (+45 points) | 65% → 72% (+8 points) |
    | Discoverability | 51% → 88% (+37 points) | 50% → 72% (+22 points) |
    | Effectiveness | 24% → 43% (+19 points) | 28% → 34% (+6 points) |
    | Efficiency | 48% → 86% (+38 points) | 54% → 84% (+30 points) |
    
    ## Skill Version(s): <br>
    1.5.2 (source: frontmatter) <br>
    
    ## Ethical Considerations: <br>
    NVIDIA believes Trustworthy AI is a shared responsibility and we have established policies and practices to enable development for a wide array of AI applications. When downloaded or used in accordance with our terms of service, developers should work with their internal team to ensure this skill meets requirements for the relevant industry and use case and addresses unforeseen product misuse. <br>
    
    (For Release on NVIDIA Platforms Only) <br>
    Please report quality, risk, security vulnerabilities or NVIDIA AI Concerns [here](https://app.intigriti.com/programs/nvidia/nvidiavdp/detail). <br>
    
  • SKILL.md 14.2 KB
    ---
    name: deepstream-import-vision-model
    description: >
      Use this skill to bring a supported object-detection vision model from HuggingFace or NVIDIA NGC into
      an NVIDIA DeepStream pipeline with end-to-end automation: ONNX download,
      SafeTensors export, TRT engine build, custom nvinfer bbox parser, multi-stream
      benchmark, and PDF report. Object detection models only.
    license: CC-BY-4.0 AND Apache-2.0
    metadata:
      author: "Tushar Khinvasara <tkhinvasara@nvidia.com>"
      owner: "Tushar Khinvasara <tkhinvasara@nvidia.com>"
      service: "deepstream"
      version: "1.5.2"
      reviewed: "2026-08-04"
      team: deepstream-sdk
      tags:
        - deepstream
        - tensorrt
        - object-detection
        - import-vision-model
      languages:
        - bash
        - python
        - cpp
      domain: computer-vision
    ---
    
    # DeepStream Import Vision Model
    
    When this skill is active, **read the relevant reference document before starting each phase**. Do not rely on memory — reference documents contain exact script paths, bash variable conventions, log filename contracts, and critical parsing rules.
    
    **Current scope:** Object detection models only. Fail fast on classification, segmentation, or other architectures detected in `config.json`.
    
    ## Model choice — always offer two options
    
    Before preflight, browsing, downloads, or file creation, present exactly these two choices. Do not
    start with only an open-ended model-source prompt. If the user's request already clearly selects a
    model, confirm the matching choice instead of asking redundantly.
    
    ### 1. Default model (recommended)
    
    Use the validated Hugging Face RT-DETR model:
    
    ```yaml
    model_id: PekingU/rtdetr_r50vd
    source: huggingface
    task: object-detection
    precision_preference: fp16
    ```
    
    ### 2. Custom object-detection model
    
    Ask for one supported source:
    
    - Hugging Face model ID (`organization/model`) or full model URL.
    - NVIDIA NGC catalog model URL including its version.
    
    Explain that the skill currently rejects classification, segmentation, and other non-detection
    architectures after inspecting `config.json`. Do not invent or silently substitute a model when the
    custom source is missing or unsupported.
    
    For a dry run, present the same two choices and simulate discovery, build, benchmark, and report
    stages without browsing, downloading, launching Docker, writing files, or starting processes.
    
    ## Pipeline Overview
    
    | Step | Phase | Reference | What it does |
    |------|-------|-----------|--------------|
    | 1–3 | Model Acquire | [references/model-acquire.md](references/model-acquire.md) | Browse HF/NGC, detect format, download ONNX or export SafeTensors |
    | 4–5 | Engine Build  | [references/engine-build.md](references/engine-build.md) | Build dynamic TRT engine, run trtexec BS=1 and BS=MAX_BS |
    | 6–7 | DS Pipeline   | [references/pipeline-run.md](references/pipeline-run.md) | Custom bbox parser, nvinfer config, single-stream + multi-stream benchmarks |
    | 8   | Report        | [references/report-generation.md](references/report-generation.md) | 5 charts, HTML, PDF benchmark report |
    
    Run the full pipeline autonomously without pausing for confirmation at each step.
    
    ## Runs entirely through Docker (no host packages)
    
    **Every step runs INSIDE the DeepStream container.** The host needs only **Docker + the NVIDIA
    driver** — no host python/venv/torch/trtexec/make/wkhtmltopdf. This works identically on Linux and
    **Windows** (Docker Desktop + WSL2 backend, required for `--gpus`). The per-shell bind-mount
    token is the only OS difference — `-v "$PWD":/work` (bash), `-v "${PWD}:/work"` (PowerShell),
    `-v "%cd%:/work"` (cmd); full guide in [references/windows.md](references/windows.md). All venv/ONNX/
    engine/parser/config/report artifacts live under the mounted working root and persist between the
    ephemeral `--rm` containers.
    
    ## Pre-flight — bootstrap + verify (through the container)
    
    **1. One-time bootstrap** — builds `build/.venv_optimum` (torch/onnx/onnxruntime/report deps; the
    venv name is historical, optimum is no longer used) +
    installs `wkhtmltopdf`, all in-container. From the working root:
    ```bash
    docker run --rm -it --gpus all --shm-size=16g -v "$PWD":/work -w /work \
      --entrypoint bash nvcr.io/nvidia/deepstream:9.1-triton-multiarch \
      .claude/skills/deepstream-import-vision-model/setup.sh
    ```
    
    **2. Preflight** — GPU + venv + trtexec, run THROUGH the container (container-mode auto-detects):
    ```bash
    docker run --rm --gpus all -v "$PWD":/work -w /work \
      --entrypoint bash nvcr.io/nvidia/deepstream:9.1-triton-multiarch \
      .claude/skills/deepstream-import-vision-model/scripts/preflight.sh   # proceed only on PASS
    ```
    
    **Every subsequent phase runs the same way** — issue the model's commands via
    `docker run … --entrypoint bash … -lc '<commands>'` (or the
    `.claude/skills/deepstream-import-vision-model/scripts/dsrun.sh` wrapper:
    `bash .claude/skills/deepstream-import-vision-model/scripts/dsrun.sh '<in-container command>'`),
    using `PY=build/.venv_optimum/bin/python` and
    `trtexec` at `/usr/src/tensorrt/bin/trtexec` inside the container. `deepstream-app`,
    `gst-launch-1.0`, and `/opt/nvidia/deepstream/…` sample paths all exist **in** the image.
    TensorRT build+runtime share one image, so there is **no version skew** (the concern the old
    "build on the host" rule tried to avoid — see [references/engine-build.md](references/engine-build.md)).
    `sample_720p.mp4` ships in the image; set `DS_VIDEO` only to override.
    
    ## Mandatory Output Structure
    
    Create once `MODEL_NAME` is known (Step 1). Never dump files flat.
    
    ```
    models/{model_name}/
      model/           <- ONNX file(s)
      parser/          <- .cpp, Makefile, .so
      config/          <- nvinfer config, ds-app config, labels.txt
      scripts/         <- run helper scripts
      benchmarks/
        engines/       <- _dynamic_b{MAX_BS}.engine, timing.cache, build logs
        b1/            <- trtexec BS=1 log
        b{MAX_BS}/     <- trtexec BS=MAX_BS log
        ds/            <- DS benchmark logs
      reports/         <- benchmark_report.md, .html, .pdf, benchmark_data.json
        charts/        <- chart_*.png (5 charts)
      samples/         <- output .mp4 or .ogv (theoraenc fallback), test frames
        kitti_output/  <- KITTI detection .txt files
    ```
    
    ```bash
    mkdir -p models/$MODEL_NAME/{model,parser,config,scripts,benchmarks/engines,benchmarks/ds,reports/charts,samples/kitti_output}
    ```
    
    ## Critical Rules
    
    1. **Engine naming** — always `{model}_dynamic_b{MAX_BS}.engine`. Never bare `model_dynamic.engine`.
    2. **batch_size == num_streams** — in DS runs, `batch-size` and stream count are always equal.
    3. **Log filenames are fixed** — `trtexec_b1.log`, `trtexec_b${MAX_BS}.log`, `ds_s${N}_run1.log`, `ds_s${N}_run2.log`. No timestamps. Report generation reads exact paths.
    4. **Parser zero-init** — always `NvDsInferObjectDetectionInfo obj = {};`. Required for DS 9.1 OBB support; bare `obj;` leaves `rotation_angle` uninitialized, causing tilted bounding boxes.
    5. **KITTI validation gate** — do NOT proceed to Step 7 if KITTI frame count is zero or detection rate < 90%.
    6. **Shared venv** — `build/.venv_optimum` reused across all models. Never create per-model venvs.
    7. **trtexec `--noDataTransfers`** — GPU-only compute matches DeepStream's GPU-to-GPU data flow.
    8. **Report HTML+PDF** — always use `.claude/skills/deepstream-import-vision-model/scripts/report/md-to-html-pdf.py`. Never write a custom HTML generator or call `wkhtmltopdf` directly.
    9. **Object detection only** — reject non-detection architectures from `config.json` before building anything.
    10. **Encoder fallback (MANDATORY)** — `x264enc` and `openh264enc` are **prohibited**. On NVENC-unavailable systems, use `theoraenc + oggmux` (LGPL; ships in gst-plugins-base; output is `.ogv`). If `theoraenc`/`oggmux` are absent, skip video creation (`DS_SINGLE_STREAM_MODE=skipped`). Report which mode was used: `nvv4l2h264enc` / `theoraenc-fallback` / `skipped`.
    11. **Video source (MANDATORY)** — default is always `sample_720p.mp4` (1280×720). Never autonomously substitute `sample_1080p_h264.mp4` or any other file. Only use a different video when the user explicitly provides a path (via `DS_VIDEO` env var or script argument).
    
    ## Examples
    
    **Default model, end to end.** Bootstrap once, then run the full pipeline:
    
    ```bash
    docker run --rm -it --gpus all --shm-size=16g -v "$PWD":/work -w /work \
      --entrypoint bash nvcr.io/nvidia/deepstream:9.1-triton-multiarch \
      .claude/skills/deepstream-import-vision-model/setup.sh
    # then: "Use deepstream-import-vision-model to run PekingU/rtdetr_r50vd"
    ```
    
    **SafeTensors model with no published ONNX.** Step 2b exports it first; the wrapper reports which
    backend produced the graph and fails loudly if the batch dimension was baked in:
    
    ```bash
    bash .claude/skills/deepstream-import-vision-model/scripts/model/safetensors-to-onnx.sh \
      models/$MODEL_NAME/hf_model models/$MODEL_NAME/onnx_export/
    #   [export] backend=dynamo
    #   [export] dynamo produced a static batch dimension; trying the next backend
    #   [export] backend=legacy-torchscript
    #   [export] pixel_values shape=['batch', 3, 640, 640]
    ```
    
    **Pin a Hub revision** for a reproducible build — any exporter flag passes straight through:
    
    ```bash
    bash .claude/skills/deepstream-import-vision-model/scripts/model/safetensors-to-onnx.sh \
      PekingU/rtdetr_r50vd models/rtdetr/onnx_export --revision <commit-sha> --opset 18
    ```
    
    ## Pipeline Timing
    
    Wrap every step:
    
    ```bash
    STEP_START=$(date +%s.%N)
    # ... step commands ...
    STEP_END=$(date +%s.%N)
    STEP_DURATION=$(python3 -c "print(round($STEP_END - $STEP_START, 2))")   # bc is not in the container; python3 always is
    echo "[Step N] completed in ${STEP_DURATION}s"
    ```
    
    Track `PIPELINE_START` (before Step 1) and `PIPELINE_END` (after Step 8). Report all durations in the benchmark report.
    
    ## Report Output (MANDATORY — all 3 formats)
    
    1. `benchmark_report.md` — markdown source (12 mandatory sections)
    2. `benchmark_report.html` — styled HTML (charts base64-inlined, no local file access)
    3. `benchmark_report_{model_name}.pdf` — via `md-to-html-pdf.py`; verify charts are embedded by counting `data:image/png` occurrences in the HTML output: `grep -o 'data:image/png' benchmark_report.html | wc -l` should equal 5
    
    Run charts and report scripts with the shared venv active: `source build/.venv_optimum/bin/activate`.
    
    ## Reference Documents
    
    **IMPORTANT**: Read the relevant reference before starting each phase. Do NOT generate code from memory.
    
    | Document | Use When |
    |----------|----------|
    | [references/model-acquire.md](references/model-acquire.md) | Steps 1–3: HF/NGC URL parsing, format detection, ONNX download, SafeTensors export, label extraction |
    | [references/engine-build.md](references/engine-build.md) | Steps 4–5: trtexec engine build, benchmarks, PEAK_GPU_STREAMS derivation, iterative scaling |
    | [references/pipeline-run.md](references/pipeline-run.md) | Steps 6–7: custom bbox parser, nvinfer config, single-stream validation, KITTI dump, multi-stream benchmark |
    | [references/report-generation.md](references/report-generation.md) | Step 8: benchmark_data.json, 5 charts, 12-section markdown report, HTML + PDF |
    
    ## Scripts
    
    Installed into `.claude/skills/deepstream-import-vision-model/scripts/` by `install.sh`.
    
    | Script | Phase | Purpose |
    |--------|-------|---------|
    | `model/hf-list-files.sh` | 1–3 | List HuggingFace repo files |
    | `model/hf-download-config.sh` | 1–3 | Download config.json from HF |
    | `model/ngc-list-files.sh` | 1–3 | List NGC model files |
    | `model/ngc-download.sh` | 1–3 | Download NGC model archive |
    | `model/safetensors-to-onnx.sh` | 1–3 | Export SafeTensors → ONNX via `torch.onnx.export` (wrapper) |
    | `model/safetensors_to_onnx.py` | 1–3 | The exporter — dynamo backend, TorchScript fallback, verifies dynamic batch |
    | `model/inspect-onnx.py` | 1–5 | Inspect ONNX input/output shapes |
    | `model/make-static-batch-onnx.py` | 4–5 | Bake batch dim into ONNX |
    | `model/cleanup.sh` | Any | Remove staging dirs, preserve shared venv |
    | `engine/benchmark-trtexec.sh` | 4–5 | Run trtexec with standard flags |
    | `deepstream/ds-single-stream.sh` | 6–7 | Single-stream visual validation (NVENC primary; theoraenc+oggmux fallback; skip if neither) |
    | `deepstream/ds-sweep.sh` | 6–7 | 2-phase batch size sweep |
    | `deepstream/benchmark-ds.sh` | 6–7 | Fixed-stream DS benchmark |
    | `deepstream/ds-kitti-dump.sh` | 6–7 | KITTI detection dump via deepstream-app |
    | `deepstream/ds-perf-run.sh` | 7 | Step 7c two-run benchmark — wraps `deepstream-app` with `enable-perf-measurement=1`, writes fixed-name log for the report parser |
    | `deepstream/extract-frame.sh` | 6–7 | Extract sample frames from output video (`.mp4` NVENC path or `.ogv` theoraenc fallback) |
    | `report/generate-benchmark-charts.py` | 8 | Generate 5 benchmark PNG charts |
    | `report/md-to-html-pdf.py` | 8 | Markdown → styled HTML → PDF (canonical benchmark report path) |
    | `report/md-to-pdf.sh` | Any | Markdown → PDF via pandoc/pdflatex — for design docs and references only, NOT for benchmark reports (use md-to-html-pdf.py for those) |
    | `report/report-style.css` | 8 | CSS for HTML report |
    | `report/render-mermaid-for-pdf.py` | 8 | Mermaid diagram → PNG |
    | `report/mermaid-puppeteer.json` | 8 | Vetted Puppeteer config for Mermaid (sandboxed; non-root) |
    | `report/mermaid-puppeteer-root.json` | 8 | Vetted Puppeteer config for Mermaid (used when running as root) |
    
    ## Quick Error Reference
    
    | Error | Fix |
    |-------|-----|
    | Tilted/diagonal bounding boxes | Parser struct not zero-initialized — use `NvDsInferObjectDetectionInfo obj = {};` |
    | Zero KITTI files | `gie-kitti-output-dir` not read by nvinfer — use `ds-kitti-dump.sh` (wraps `deepstream-app`) |
    | Engine rebuilds every DS run | `model-engine-file` path wrong — check relative path from `config/` dir |
    | `setDimensions` negative dims | Add `infer-dims=3;H;W` to nvinfer config for dynamic ONNX models |
    | `--memPoolSize` workspace 0.03 MiB | Use `M` suffix not `MiB` — e.g. `--memPoolSize=workspace:32768M` |
    | ForeignNode build failure (DETR) | Run `onnxsim` — see references/engine-build.md. Not reproduced on TRT 10.16 with either export backend |
    | ONNX has a static batch dim | Both export backends specialized it — see the gotchas in references/model-acquire.md |
    | Zero detections | Wrong `net-scale-factor` — check model family table in references/pipeline-run.md |
    | `No module named 'pyservicemaker'` | Install into venv: `pip install /opt/nvidia/deepstream/.../pyservicemaker*.whl` |
    
  • skill.oms.sig 16.1 KB · in bundle

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related