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

> Engineering log · [Phase 12 — Keypoint detection, v0.5.0](https://borda.github.io/lucid-YOLO/engineering/phase-12-keypoints/)
>
> 🔗 Depends on [WP-121](https://borda.github.io/lucid-YOLO/work_packages/wp-121/) · Needed by [WP-131](https://borda.github.io/lucid-YOLO/work_packages/wp-131/)
>
> ⬅️ [WP-121](https://borda.github.io/lucid-YOLO/work_packages/wp-121/) · [Roadmap](https://borda.github.io/lucid-YOLO/ROADMAP/) · [WP-122](https://borda.github.io/lucid-YOLO/work_packages/wp-122/) ➡️


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

<a id="wp-121b"></a>

Surfaced during Phase 12's own dogfooding rather than invented: validating a fuse-augmentations `Task.KEYPOINTS` COCO export against [WP-121](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/wp-014/)'s original reader) required a *parseable* `segmentation` ring to retain any annotation at all, in **every** mode -- plain, oriented, and [WP-121](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/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](https://borda.github.io/lucid-YOLO/work_packages/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.

