Skip to content

📦 WP-152 — feat(cli): predict_keypoints closes the inference surface

Engineering log · Phase 13 — Contributor admission, augmentation guard and keypoint parity, v0.6.0

🔗 Depends on WP-151 · Needed by WP-152b, WP-153

⬅️ WP-151 · Roadmap · WP-152b ➡️

WP-152 — the fall-through that answered plausibly

lucid-predict dispatched on the checkpoint's own task through three branches and a final else. That else was written as detection, with a comment explaining why it was not an elif task == "detect": an unknown task must be refused, and the refusal belongs to the library, which names the task the checkpoint actually carries. Sound reasoning, and it stopped being true the moment a fourth task existed. A keypoints checkpoint reached that branch, predict_image accepted it — the module's task was checked against "detect" and raised — so the command failed with a message about detection rather than silently answering. The failure mode was loud, then, but the capability was simply absent: 0.5.0 released a task with no shipped way to run one of its checkpoints on an image.

predict_keypoints closes it, and the composition is predict_segmentation's rather than predict_image's for the reason WP-151 gives for the export graph: the point stem is dense over anchors, so the selected decode path has to report the source anchor of every surviving row. _decode_with_points is _decode_with_coefficients with the point stem substituted, including the rule that matters most — detections, indices and the dense tensor all come from one branch, chosen in one place, so pairing the E2E decode's rows with the dense branch's points is unrepresentable rather than merely unlikely. That pairing would produce a plausible pose of a different object.

The letterbox inverse is applied once to a flattened point list. inverse_map takes (N, 2), and the points are (N, K, 2), so the instance and point axes are folded together for the call and restored after. Folding rather than looping per instance is not a performance choice: one call means one mapping, so no row can take a different inverse from its neighbour. This is also why the fourth entry point landing before the augmentation swap matters — Letterbox.inverse_map now has four consumers rather than three, so the delegation Phase 14 performs on that class is validated against four rather than re-validated later.

K is read from the tensor, everywhere. KeypointPrediction.num_keypoints reports keypoints.shape[1], the report writes it into info, and the empty-prediction path returns an empty stack that still carries the point axis — so a caller reading K off an image with nothing in it still learns the schema. The head's point count is a constructor argument (A64) and COCO's 17 is one instantiation; a function that assumed 17 would silently mis-shape a 15-point letter schema or a 12-point animal one.

The report gains a key rather than changing shape. keypoints: "xy-pairs" appears exactly when the records carry a point set, in the way boxes: "obb-longedge-rad" already appears only on oriented reports, so the three shipped report shapes stay byte-identical. The layout is named because COCO's own keypoint field is a flat triplet list carrying a visibility flag, and this one is neither flat nor triplets: nothing in this project predicts visibility, so writing a third column would be inventing a number. The stdout line prints the point count rather than the points — at K = 17 that is 34 coordinates on one terminal line, which nobody reads.

The skeleton is a caller argument with no default, and that is the honest answer. draw_keypoints draws each detection's box, its markers, and any edges the caller names. Which points connect to which is dataset metadata, exactly as the left/right flip pairs are: the head predicts K points and knows nothing about what they mean. Baking COCO's human topology in would make a 15-point letter skeleton or a 12-point animal one confidently, visibly wrong — a figure that looks authoritative and is not. With no skeleton the points are drawn alone, which is the correct figure for a schema nobody has named. The marker radius is fixed rather than scaled to the box, because a pose figure is read by whether a point sits on its feature, and a marker that grew with the object would hide exactly the small-object error worth seeing.

The dispatch test is parametrised over all four tasks with all four entry points stubbed, so a wrong dispatch shows up as another stub recording the call. Its keypoint case is the one that did not exist before this row, and it is the test that would have caught the gap a release earlier had the parametrisation been written to the task list rather than to the three branches that happened to be there.