Skip to content

📦 WP-121b — fix(data): a segmentation ring only an oriented reading actually needs

Engineering log · Phase 12 — Keypoint detection, v0.5.0

🔗 Depends on WP-121 · Needed by WP-131

⬅️ WP-121 · Roadmap · WP-122 ➡️

WP-121b — a segmentation ring only an oriented reading actually needs

Surfaced during Phase 12's own dogfooding rather than invented: validating a fuse-augmentations Task.KEYPOINTS COCO export against WP-121's reader (an offline check of the user's own upstream package, ahead of wiring a fixture from it) found that every annotation in the export parsed to zero instances. Root cause traced two layers deep. The proximate one was upstream -- fuse-augmentations' CocoWriter never emitted a segmentation field for Task.KEYPOINTS, even though the outline polygon it would need was already computed for every shape regardless of task -- and was fixed there, by the user, not here (AGENTS.md sec. 6/7: no fix to a dependency's own source from this repo). The deeper one was this repo's own: _parse_annotation (WP-014's original reader) required a parseable segmentation ring to retain any annotation at all, in every mode -- plain, oriented, and WP-121's own keypoints reading alike -- so the upstream bug's fix alone was not sufficient; a keypoints-only or plain-detect-only export genuinely has no ring to give, by design, and the reader treated that as identical to a crowd or RLE annotation it should legitimately drop.

The fix, scoped precisely. Two kinds of "no ring" are not the same claim. A segmentation value that is present but unusable -- an RLE crowd dict, an empty list, a ring under three points -- is never a real countable instance, in any mode; that half of WP-014's original policy is untouched, and it is exactly what TestKeypointParsing's own existing fixture already exercises (one RLE annotation, no iscrowd flag set, expected to still be dropped) -- a first draft that relaxed on "ring is None" alone broke that test immediately (KeyError reading ann["keypoints"] off an annotation the keypoints branch was never meant to see), which is what forced the narrower raw_segmentation is not None or self._oriented condition actually landed. A segmentation key that is absent entirely is the other case, and it is fatal only for oriented=True, which has no other source for the rotated box (_oriented_targets derives both boxes and rboxes from the same quadrilateral ring); a plain or keypoints reading now keeps the box instead. _build_targets collapses the whole image's polygons to [] the moment any one retained instance lacks a ring, rather than leaving a ragged per-instance mix -- Targets.polygons's own documented contract is "0 (no masks) or N," never in between.

Why this does not reopen the silent-empty-mask hole. instance_mask_targets already raises ValueError when len(target.polygons) != len(target.boxes) -- a guard written for exactly this shape of mismatch, already in place before this fix, for a different reason (WP-099b's YOLO-layout path, which never carries rings at all). Relaxing the parse-time gate does not need a new guard; it needs this existing one to still be reachable, and it is: requesting mask_targets=True against a ring-less image raises there precisely as it does for a genuinely maskless dataset.

Verification. tests/data/test_coco.py::TestRinglessAnnotations -- a plain reading keeps both ringless boxes with polygons == []; an oriented reading against the same file still returns zero instances, locking in that oriented mode's requirement did not loosen; instance_mask_targets against the same ringless targets still raises, naming "detection-only annotation cannot supervise masks." Every existing CocoDetectionDataset caller re-run as a regression sweep -- test_coco.py, test_coco_eval.py, test_annotations.py, test_build_dota_tiles.py, test_overfit_batch.py, test_datamodule.py -- 175 passed. coco.py's own module doctests and mypy --strict both clean. Full make gate green (2322 passed, 37 skipped, 27/27 goldens), run against the project's actually-pinned fuse-augmentations wheel -- deliberately not the newer local checkout the upstream fix and the keypoints-fixture wiring both need, since that checkout's widened Shape vocabulary moves class_names(ClassMode.SHAPE)'s span for every task (det/obb/seg included) and would silently drag the frozen *_num_categories goldens along with it. That vocabulary-scoping gap is a separate, still-open finding, reported back rather than worked around: class_names() has no parameter to restrict its span to a shape family, so det/obb/seg cannot currently pin "geometric shapes only" against a fuse-augmentations version new enough to carry keypoints at all. The keypoints-fixture wiring this fix unblocks (tests/fixtures/synthetic.py::generate_keypoints_fixtures, a small fixed animal subset for an overfit-friendly category count) is validated end-to-end against a local dev-only override but held uncommitted pending that upstream resolution and the resulting pin bump.