📦 WP-189 — refine(viz): plotting and overlay helpers move into the package¶
Engineering log · Phase 17 — R1 pose-objective parity
🔗 Depends on WP-188 · Needed by WP-191
WP-189 — the drawing code moves into the package, and the dependency it already had is declared¶
Four notebooks would have carried four copies. Recorded 2026-09-21. 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 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 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'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.