# WP-204 — inference, export and Python API

> Engineering log · [Phase 19 — Codebase audit remediation](https://borda.github.io/lucid-YOLO/engineering/phase-19-codebase-audit/)
>
> 🔗 Depends on [WP-203](https://borda.github.io/lucid-YOLO/work_packages/wp-203/) · Needed by none
>
> ⬅️ [WP-203](https://borda.github.io/lucid-YOLO/work_packages/wp-203/) · [Roadmap](https://borda.github.io/lucid-YOLO/ROADMAP/) · [WP-205](https://borda.github.io/lucid-YOLO/work_packages/wp-205/) ➡️


<a id="wp-204"></a>

## WP-204 — one decode composition, one single-head model base, an export command and a Python API, consolidated in one commit

**Batch 4 of the codebase-audit roadmap.** The audit of 2026-10-21 found the per-task decode composed three times (prediction, evaluation, export), four near-identical single-head model classes with four deploy views, an exporter reachable only from a test, and no documented way to use a checkpoint from Python. The gate is `make gate`, as for every package (AGENTS.md sec. 1, D30); it will be run before the commit is made, and this page claims no result for it.

**Decode.** `decode/per_task.py` holds `decode_with_coefficients`, `decode_with_keypoints` and `gather_keypoint_sets`, and `decode/common.py` the constants `MAX_DETECTIONS` (300), `EVAL_CONF_THRESHOLD`, `DEFAULT_IOU_THRESHOLD` and `COCO_IMG_SIZE`, one `gather_by_anchor` that clamps a padding index and zeroes its row, and `points_to_letterboxed_original`. `RotatedTopKDecoder` pads to `k` rows and returns anchor indices, as `TopKDecoder` does. `predict.py` replaces four copied preambles with one `_Prepared` and one indexed decode; `export.py` and `eval/coco_eval.py` call the same functions, and `gather_keypoints` delegates to `gather_by_anchor` under its own parameter names, so a call by keyword still binds. A real-case oracle compared 42 outputs of random-initialised modules before and after — the four `predict_*` functions at two canvas sides, both decode paths and two confidence thresholds, the evaluator's batch prediction for three tasks, and the export graphs — and 41 were `torch.equal`; the one difference is the oriented export below.

**Export.** The three single-head models share a private `_SingleHeadModel` with one `deploy()` view, `_DeployedO2O`, in place of four `_Deployed*` classes; across five scales and four tasks the state-dict keys, module names, parameter counts and FLOPs are identical, and the 50 goldens pass unchanged. `variant` on the four task models is now `str | ScaleSpec`, which `export.graph_from_module(module, canvas)` needs to rebuild a checkpoint at its own multipliers; it checks the keys strictly, and a training-only `rle_loss.` prefix is the one thing it drops. `lucid-export` is the fifth command (`cli/export.py`) and needs the `onnx` package, which torch 2.14.1's legacy exporter imports unconditionally after the trace: with `onnx` hidden from the import system `export_graph` failed with `OnnxExporterError: Module onnx is not installed!`, so the dependency is the `export` extra and `onnxruntime` stays dev-only. The wheel-smoke action loops over five commands. Four export docstring paragraphs that described the pre-batch behaviour were corrected.

**Python API.** `eval/coco_score.py` factors the COCO scoring that `detect_eval.run` and `pose_eval.run` each carried into `score_coco`, which returns a `CocoScore` and prints nothing; both runners are thin wrappers, and stdout, exit code and JSON of six cases were identical before and after. The four `predict_*` functions take a file path or a decoded `(3, H, W)` tensor through one `letterbox_image_tensor`, and equal results were asserted for the file and both tensor forms. The package re-exports the loader, the predictors and the scorer lazily; `import lucid_yolo` was measured at 2062 µs and 64 modules against 164 µs and 53, with torch and Lightning still unloaded. `load_eval_module` reads a checkpoint once and builds the module from the payload through Lightning's public steps, so the two-read design its docstring justified is gone, and the user-facing explanation in `docs/TRAINING.md` is rewritten. No register row recorded that rationale. `lucid-yolo fit --ckpt_path` resume was tested through the command: step and epoch, the EMA shadow and count, the MuSGD momentum and the progressive-loss weight all continue.

**Documentation.** The training page splits into a recipe and an [opt-in CUDA performance page](https://borda.github.io/lucid-YOLO/advanced/cuda-performance/), and gains sections on resuming, using a checkpoint from Python and exporting to ONNX. The API tabs gain an export page and the decode helpers, and `inherited_members: [forward, deploy]` keeps those two methods rendering under `Detector`, `OrientedDetector` and `KeypointDetector` after the base-class move. The README lists five commands and a "Not here, and why" table.

**Limits and deferred.**

- **The `coco_eval.py` split moved to [WP-205](https://borda.github.io/lucid-YOLO/work_packages/wp-205/).** Separating the streaming scorers and the keypoint scoring from the bbox and segm functions was planned and not done; its test patch of `coco_eval.MeanAveragePrecision` must move with `_new_metric`.
- **Three literals stay.** `_DEFAULT_TOPK = 300` in `models/heads/detect.py` and `models/heads/obb.py`, and `_DEFAULT_IMG_SIZE = 640` in `models/build.py`, cannot import the shared constants: `models/` sits below `decode/` and any `lucid_yolo.decode` import runs `decode/__init__`, which imports `models.heads.detect` while still partially initialised. Two tests pin them equal through public signatures.
- **A segmentation export still refuses canvases under 300 anchors** (96 px a side and below). The shared gather would now give such a row an empty mask rather than fail, so lifting the refusal is possible; it is a behaviour decision for the principal.
- **No dynamic batch or canvas** in the exported graph, and no format other than ONNX at opset 17 through the legacy exporter.
- **Tensor predict takes one image.** Each image letterboxes to its own size and inverse, so a batch does not follow from the single-image path, and a batch is refused.
- **CS-10 behaviour change.** The oriented ONNX graph on a canvas under 300 anchors returns `(B, 300, 7)` instead of `(B, A, 7)`; rows `0..A-1` are byte-identical and the rest are zero. `predict_oriented` and `lucid-predict` are unchanged on every canvas, because their confidence filter drops the padding rows. The three callers of `o2o_rotated_topk` outside this batch's files stay unpadded and unaffected.
- **Scoring engines differ.** `score_coco` defaults to `faster_coco_eval` and `lucid-eval` to `auto`, which uses `hotcoco` when usable and `faster_coco_eval` otherwise; `score_coco` accepts only those two names, so a Python caller passes `eval_backend="hotcoco"` explicitly to match the command.
- **Checked on CPU only for the new paths.** The tensor predict input and the single-read loader were exercised on CPU, and the loader against Lightning 2.6.6, not the 2.4 floor.

No frozen golden is touched.

