# 📦 WP-189 — `refine(viz): plotting and overlay helpers move into the package`

> Engineering log · [Phase 17 — R1 pose-objective parity](https://borda.github.io/lucid-YOLO/engineering/phase-17-pose-objective-parity/)
>
> 🔗 Depends on [WP-188](https://borda.github.io/lucid-YOLO/work_packages/wp-188/) · Needed by WP-191
>
> ⬅️ [WP-188](https://borda.github.io/lucid-YOLO/work_packages/wp-188/) · [Roadmap](https://borda.github.io/lucid-YOLO/ROADMAP/) · [WP-192](https://borda.github.io/lucid-YOLO/work_packages/wp-192/) ➡️


## WP-189 — the drawing code moves into the package, and the dependency it already had is declared

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

**Four notebooks would have carried four copies.** Recorded 2026-09-21. [WP-188](https://borda.github.io/lucid-YOLO/work_packages/wp-188/) made `notebooks/*.py` gate-run sources, and the first four notebooks each want two figures: a run's curves and a checkpoint's predictions over a few images, labels beside them. Both existed — `scripts/plot_training.py` drew the first and `scripts/draw_predictions.py` the second — and a notebook cannot import a script, so each notebook would have re-spelled the drawers inline. That is the failure the [WP-067](https://borda.github.io/lucid-YOLO/work_packages/wp-067/#wp-067) entry names from the other direction: a notebook nothing tests rots, and drawing code copied into four of them rots four ways at once, each copy free to draw the rotated box as its envelope again. So the functional core of both scripts moved to `src/lucid_yolo/_viz/` — `curves.py` for the panels and the CSV reader, `overlay.py` for the four per-task drawers, the renderer table and the checkpoint-to-axes path — and each script is now its argument parse, its backend selection and its write. The notebook entry points are two: `plot_curves(metrics)` returns the report's figure titled by the run directory, and `show_predictions(checkpoint, images, annotations)` loads the checkpoint once, draws one panel per image, and lays the labels under the predictions when an annotation file is given; a notebook that already holds the trained module draws each panel with `predict_and_draw(module, image, options, axes=ax)` and `draw_ground_truth(ax, targets, task)` instead, the skeleton for a pose grid travelling in `RenderOptions.skeleton` so the four tasks share one call shape. Every public name the scripts published stays importable from them as a re-export, so `scripts/_tests/test_draw_predictions.py` keeps its command-level cases and the drawing cases moved to `tests/viz/`, where the code they assert now lives.

**Ground truth is dashed, unfilled, and read by the training reader.** The new drawer, `draw_ground_truth(axes, targets, task)`, draws the label layer in the task's own geometry — boxes, instance rings, rotated quadrilaterals through the same `rboxes_to_polygons` the predicted rings use, boxes with a hollow ring at each visible point — and never fills or writes a score, so a prediction drawn solid on the same axes stays tellable from its target without a legend. What it takes is a `Targets`, which is what `CocoDetectionDataset` yields per image in the reading mode the task needs (`oriented` for `obb`, `keypoints` for `keypoints`); the file is not parsed a second time. The dataset needed two accessors for that — `file_names`, the companion of `image_ids` for a caller who arrives with a file rather than an id, and `targets_at(index)`, the precomputed targets without the image decode `__getitem__` performs — and both are the WP's one edit outside `_viz`, the scripts and the tests.

**Private, and matplotlib is a runtime dependency — because it already was.** The [WP-067](https://borda.github.io/lucid-YOLO/work_packages/wp-067/#wp-067) paragraph kept the drawers out of the package on the claim that `matplotlib` sits in the `dev` group and "a wheel a consumer installs pulls no plotting stack". Checked here rather than repeated: `pytorch-lightning[extra]`, in `[project].dependencies`, requires `matplotlib>3.1` (`importlib.metadata.requires("pytorch-lightning")` lists it under `extra == "extra"`), so every consumer install has pulled matplotlib since that extra was declared, and `torchmetrics` imports it the moment it finds it installed. The claim was false before this row and the row changes nothing about what a consumer receives. What it does change is the declaration: `matplotlib>=3.8` moves from the `dev` group to `[project].dependencies`, on the jsonargparse and tqdm precedent already in that list — a direct import is not a transitive dependency — and because [WP-168](https://borda.github.io/lucid-YOLO/work_packages/wp-168/)'s release guard reads declarations, not the transitive graph: `check_dependency_tiers` refused the tree with `_viz` importing a group-only distribution, and five `scripts/_tests/test_release_guard.py` cases (`test_happy_path_passes`, `test_main_without_tag_falls_back_to_head`, `test_metadata_only_leaves_gate_command_unrun`, `test_certifying_run_invokes_gate_exactly_once`, `test_metadata_only_verdict_refuses_to_call_tag_shippable`) failed on the live tree until it moved. The package is still `_viz`, underscore first, and the underscore is now an API contract rather than a dependency one: nothing else in `lucid_yolo` imports it, nothing outside it imports matplotlib, and the notebooks are its only callers, so its signatures can move with them. `tests/viz/test_import_surface.py` pins that from two sides. The dynamic side imports the shipped surface — `lucid_yolo`, `lucid_yolo.predict`, `lucid_yolo.eval.checkpoint`, `lucid_yolo.data` — in a fresh interpreter and asserts `lucid_yolo._viz` never entered `sys.modules`; the static side reads every shipped module's source and refuses an import line naming matplotlib or `_viz` outside the `_viz` subtree, which is the check that catches a function-local import no import of the surface executes. Neither asserts matplotlib absent from `sys.modules` after the import: torchmetrics loads it (`torchmetrics/utilities/plot.py`), and that is theirs.

**No backend is selected in the package.** The plan had `_viz` keep the scripts' `matplotlib.use("Agg")` at module top. It does not: `use("Agg")` is a process-wide setting, and a notebook importing a package that makes it is switched away from its inline display — figures returned from a cell would stop rendering. So the backend selection stays in the two scripts and the two test conftests, which are the headless callers, and the package imports `pyplot` unconditionally with no `use()` before it. For the same reason `build_run_figure`, `plot_curves`, `predict_and_draw` and `show_predictions` return their figure open: the script saves and closes, a notebook displays, and closing inside the package would serve the first by breaking the second. The `svg.hashsalt` moved with the backend line, since it is the committed figure's concern and the same kind of global.

