π Assumption Register¶
Anywhere the papers underdetermine the implementation, the chosen assumption, its public source, and its validation plan are recorded here before the corresponding code lands. Rule: any new gap discovered during implementation gets an entry before the code merges. Assumption revisions after a release ship as PATCH (if within golden tolerances) or the next 0.MINOR (if results move).
Adding a row also raises the floor. _ASSUMPTION_FLOOR in scripts/lint/audit_docs_present.py counts the rows this register is allowed to hold and never fall below; the docs-present hook reports a shrink against it. It is a ratchet: raise it to the new row count in the same commit that adds the row, never lower it. _DECISION_FLOOR (DECISIONS.md) and _WP_FLOOR (ROADMAP.md) work the same way and want the same edit. The floor sat at 26 against 73 rows until WP-168, which is 47 rows of slack β a register can only be certified by a number somebody keeps current.
Status legend, rewritten 2026-09-06 to describe what the column actually holds β the previous wording defined revised as "superseded" and validated as "validation plan executed and passed", and the register contradicts both:
openβ the assumption governs, and the evidence that would settle it is not in yet: either the code has not landed, or the validation it names needs a run nobody has done. Not "unused": mostopenrows are shipping code.activeβ in the codebase, with its validation plan run as far as an offline gate can take it.validatedβ reserved for the rows whose validation was a measurement against an independent oracle or a paired run, rather than a unit test that pins the choice. It is a strong claim and only a handful of rows carry it.revisedβ the assumption's value changed after code shipped against the old one, and the row is the current one either way. It does not mean superseded: the sole row carrying it is in force today. Most revisions do not use this status at all β a revised row usually keepsactiveoropenand records the change inline, dated, in its own Assumption cell.
Two consequences of that last point, both load-bearing when reading the column: a row's status does not tell you whether it has been revised (several active and open rows carry dated inline revisions, and several say VALIDATED inline while staying active), and the four statuses are not a lifecycle β nothing progresses open β active β validated on a schedule. Read the Assumption and Validation cells; the status is a coarse filter, not a summary. Counts are deliberately not stated here: they move with every row that lands, and a tally written into prose is the thing that goes stale β scripts/lint/audit_docs_present.py is what counts the register, and it counts rows rather than statuses.
| ID | Gap in papers | Assumption | Public source | Validation | Status |
|---|---|---|---|---|---|
| A1 | Box IoU loss variant unnamed | CIoU | R9, R10 | Ablation-neutral check at the smoke tier | open |
| A2 | TAL alpha/beta not restated | alpha=1, beta=6 | R4 | Overfit + ablations-tier trend | open |
| A3 | C3k2/C2PSA sub-block internals beyond Fig. S2 | Bottleneck/PSA internals per YOLO11 lineage; nested C3k inner-bottleneck count 1 (_C3K_INNER_UNITS, revised 2026-08-01 from 2 by WP-023 iteration 2 β at depth=1.0 the l/x variants carry two nested units per c3k=True stage and n=2 overshot the R1 Table 7 FLOP budget; n=1 lands all five scales within +/-5% FLOPs while keeping params within +/-2%) |
R11, R1 Fig. S2 | Param/FLOP gate (Phase 2) β tests/models/test_param_flops.py::test_det_vs_table7 passes all 5 scales |
active |
| A4 | SPPF shortcut exact form | Input added to output of pooling stack | R3 sec. 4 | Param gate; ablate if mismatch | open |
| A5 | NewtonβSchulz iteration count | 5 | R7, R8 | Orthogonality unit test | open |
| A6 | Muon step scaling | 0.2 * sqrt(max(A,B)) update-RMS scaling | R7 Eq. 4 (verbatim: 0.2 * O * sqrt(max(A,B))) | Toy convergence test | active |
| A7 | muon_w+sgd_w != 1 semantics | Independent additive gains | R1 Tables S4/S7 | Documented; sensitivity note | open |
| A8 | LR schedule shape (lrf semantics) | Per-step linear decay to final LR = lr0*lrf, preceded by a linear warmup over warmup_epochs (default 3) from lr0/warmup_steps (revised 2026-08-03 by WP-072: warmup added β Det-smoke attempt 1 ran the constant-LR deferral and plateaued at val mAP50-95 3.96 vs the >25 criterion; overfit-100 golden keeps the schedule off, lrf: 1.0/warmup_epochs: 0) |
gap; decay convention widely restated in third-party YOLO-application literature; warmup is generic from-scratch practice (A31 lineage) | tests/optim/test_schedule.py endpoints; Det-smoke attempt 3 VALIDATED 2026-08-06 (warmup + decay to lr0*lrf over the full 50-epoch budget, monotonic val descent, acceptance met); trend-neutral in paired ablations-tier runs |
active |
| A9 | One-to-one output tuple (...,6) | [x1,y1,x2,y2,score,class]; seg appends K coefficients | R1 sec. 3.2.1 | Eval round-trip test | open |
| A10 | Resize semantics | Letterbox, aspect-preserving. Since WP-155 the fit, the forward matrix and the resample are R21's β letterbox_geometry, letterbox_matrix, and a letterbox-only Compose segment that runs one grid_sample from the source canvas to the letterboxed one in place of an F.interpolate followed by an F.pad. What this row is actually about stays local: Letterbox.forward_affine and Letterbox.inverse_map keep their signatures and their semantics, and the inverse stays closed-form (inv_r, -pad * inv_r) rather than a numerical inversion of the forward matrix, because the round trip below has to be exact and a numerical inversion leaves a residual in it. Delegating moved pixels and not geometry, which is the whole content of the boundary here: letterbox_image_mean moved 0.5024 β 0.4994 and not one coordinate golden did (measured and re-frozen by WP-155b) |
YOLO-lineage convention in third-party literature (e.g. R11); the letterbox segment itself is R21 (ADR-005) | Bbox/mask round-trip test β tests/data/test_letterbox.py |
open |
| A11 | Anchor center placement | (i+0.5)*stride | R4, R5 | Assignment unit test | open |
| A12 | Weight-decay exclusions | 1D params excluded | Standard practice (e.g. R7 discussion) | Documented | open |
| A13 | From-scratch loss gains; coordinate frame of the L1 term the legacy DFL gain field scales | Pretrain-style gains (7.5/0.5/6.0); the L1 term is measured in stride units β the head's native ltrb parameterization β not pixels (revised 2026-08-04 by WP-078: with pixel-frame L1 the term ran 8-32x large and was 97.4% of the val objective at Det-smoke attempt 2's endpoint, starving classification β mAP50-95 6.3, mar_100 24% despite well-localized boxes; decomposition arithmetic reproduces the logged 200.7 val total exactly) | R1 Table S2; head ltrb frame per R4/R6 lineage | test_detection_loss.py::test_l1_stride_normalization_divides_by_anchor_stride; Det-smoke attempt 3 VALIDATED 2026-08-06 β stride-frame L1 moved classification from 1.7% to 23.0% of the objective and val mAP50-95 from 6.28 to 25.30 at half the epoch budget |
active |
| A14 | Prototype count K | K=32 | YOLACT R16 | Param/FLOP gate vs Table S9 | open |
| A15 | Proto spatial resolution | 160x160 at 640 input (2x upsample of P3) | YOLACT convention R16 | Param/FLOP gate; mask-quality check | open |
| A16 | Coefficient activation + instance-mask loss | tanh coefficients; per-pixel BCE on box-cropped masks, box-area normalized | YOLACT R16 | Overfit micro-set mask IoU | open |
| A17 | Aux semantic head structure | A single 1x1 nn.Conv2d from the fused-feature width (ProtoFusion.out_channels) to nc raw logits, evaluated at F_proto's own resolution with no resampling, carrying the A30 prior-probability bias init (it is a dense sigmoid classifier over a mostly-background map β exactly A30's setting, unlike the tanh coefficient stems). forward returns None whenever self.training is False β keyed on module mode, never on grad mode β so the branch is provably absent at eval and export, and the fused-parameter gate has exactly one convolution to account for |
R1 sec. 3.4.1 ("training-only branch") | Fused-model param gate proves removal; tests/models/test_aux_semantic.py pins the eval-mode None, the single-conv parameter count, and the prior init |
active |
| A18 | Proto-generation stack internals | Three 3x3 ConvBNAct units at the fusion width, 2x nearest upsample, one 3x3 ConvBNAct, 1x1 convolution to K raw prototypes | YOLACT R16; R1 Eq. 9 | Param/FLOP gate | active |
| A19 | Rotated IoU loss variant | ProbIoU | R1 sec. 3.4.3 lineage; R17 | OBB-ablations trend (Table 11 ranking) | open |
| A20 | Angle branch structure and hidden width (R1 sec. 3.4.3 says only "separate branch ... to predict the orientation angle"; Fig. S2 draws one stem shape and no widths, and Table S11 reports whole-model sizes from which a branch width can only be inferred) | A third per-level stem beside the box and class stems on each head branch, shaped like them β two depthwise-separable units then a 1x1 β and emitting one scalar per location. Widened 2026-08-11 by WP-062: the hidden width is max(16, channels // 2) (_angle_stem_width), not A28's shared max(16, channels // 3), which the Table S11 gate rejected. Both divisors were measured on all five scales as the ratio of the isolated angle-branch cost to the increment Table S11 implies over Table 7 once the 80-to-15 class saving is credited back β // 3: n 0.76, s 0.98, m 0.66, l 0.66, x 0.63; // 2: n 1.22, s 1.62, m 1.09, l 1.09, x 1.04. Table S11 rounds params to 0.1 M, so that implied increment carries +/-59% at n and +/-21% at s and neither scale can discriminate; at m and l (+/-8%) and x (+/-3.5%) it can, and it selects // 2. Neither divisor is uniformly right β // 3 undershoots the three large scales, // 2 overshoots s at 1.62 β so the published angle cost is not one fixed fraction of the level width, and this row stays open: a rule chosen where the evidence is sharp, not a claim of Table S11 parity |
R1 sec. 3.4.3 ("separate branch"), R1 Fig. S2 (stem shape); the width itself: gap, inferred from R1 Table S11 | test_param_flops.py::test_obb_vs_tableS11 at the table's 1024 px and 15 classes (params +/-3.5%, FLOPs +/-5%); test_obb_head.py::test_angle_stem_parameter_count_is_exact pins each level term by term, ::test_angle_branch_uses_a_wider_stem_than_the_box_and_class_stems pins the split against being tidied away |
open |
| A21 | DOTA crop overlap (R1 sec. 4.5.3 says only "overlapping 1024x1024 crops") | Overlap is a parameter of tile_windows, defaulting to the 200 px this row has recorded since the register was written, so the assumption still governs. Flagged 2026-08-11 by WP-057: 200 px is attested by neither cited source β R18 sec. 4 publishes its own protocol as 1024 px patches "with a stride set to 512", i.e. a 512 px overlap, and R13 (MMRotate, arXiv:2204.13317) fixes the long-edge-135 angle definition but names no crop overlap at all. The value is therefore a sensitivity item for the OBB tier: re-running the tier at overlap 512 against the default 200 is the check that settles it. Window placement is overlap-agnostic by construction β the last window of each axis sits flush against the far edge β so full coverage holds for any overlap in [0, patch) and the sweep costs only tiles, not code |
R18 sec. 4 (1024 px patch, stride 512); R13 (angle definition only, no overlap); the 200 px value itself: gap | tests/data/test_tiling.py::test_coverage_no_gaps β no-gap coverage over sizes that divide evenly, leave a remainder or are shorter than the patch, including the A21 defaults on a DOTA-sized image; OBB-tier sensitivity run pending (WP-063/088) |
active |
| A22 | Scalar weight of R1 Eq. 15's angle term in the total objective (R1 states the term and its internal lambda, but never its weight against the box and classification terms) |
0.25, revised 2026-08-12 by WP-093 from the 1.0 this project assumed. The inherited value does not merely underperform, it destabilises: on the oriented overfit slice at 100 epochs across five seeds, angle_gain = 1.0 cleared the 0.9 floor on 1 of 5 with a run-to-run spread of 0.667 and a worst seed of 0.3077, while 0.25 cleared 4 of 5 with spread 0.096. Mean angular error on elongated targets halves, 25.47 to 11.85 degrees. The failure is specific and mechanical: sin^2(2 d_theta) is zero at both 0 and 90 degrees, so for an elongated box the term scores a quarter turn as perfect and only the rotated IoU term objects, and its gradient 2 sin(4 d_theta) reverses at 45 degrees. R1 intends this to be harmless β "elongated boxes receive smaller omega_i and remain primarily constrained by the rotated IoU loss" β but at the paper's own lambda = 3 a 2:1 box still carries omega = 0.948, a five percent reduction, so the stated intent is not delivered by the stated formula in that band. The remedy is this gain and not lambda, which R1 does state and ablates (Table 11: lambda = 5 scores worse than omitting the term entirely). Dose-response is not monotone per seed β seed 3 is best at 1.0 and worst at 0.5 β which is why a gain was chosen on aggregate behaviour rather than by picking the best-looking seed |
R1 Eq. 15 (the term and lambda); the weight itself: gap |
test_obb_training.py::test_the_angle_gain_default_is_the_registered_a22_value pins the value, since every other test passes gains explicitly and the default was previously unread. Measured lead-side, 35 training runs over four paired campaigns, five seeds each: gains 1.0 / 0.5 / 0.25 / 0.0 cleared 1 / 4 / 4 / 5 of 5 with mean elongated angular error 25.47 / 19.09 / 11.85 / 12.49 degrees. Still open at the OBB-smoke and DOTA tiers, where aspect ratios reach 5:1 and 10:1 and omega genuinely falls (0.750, 0.555) β the regime this fixture's 2:1 targets do not cover |
revised |
| A23 | Rotated decode normalization (R1 Eq. 13 makes the predicted angle the raw pre-activation, so the head can emit any real number at any magnitude, and R1 never says where that number is brought back into a range) | Normalization happens after the decode, not in the head: decode_rboxes ends in rotated_geom.canonicalize, returning the unique long-edge representative with w >= h and theta on [-pi/4, 3*pi/4) (R13's long-edge-135 definition, WP-055's conventions). Two consequences are deliberate. The dense (B, A, 5) output is canonicalized rather than only the ranked detections, so a caller that decodes without ranking β a loss, an assigner, an export graph β cannot reach an un-normalized angle by a shorter path. And because theta and theta + pi describe the same rectangle, the decode is continuous across that identification: two raw values a half turn apart yield the same box rather than two boxes |
R13 (angle definition); the placement after the decode: gap | test_obb_head.py::test_decode_canonicalizes_adversarial_raw_angles over raw magnitudes far outside any range, ::test_decode_is_continuous_across_the_half_turn_identification, ::test_angle_output_is_unsquashed_beyond_the_legacy_range (100 radians out of the stem, evidence the Eq. 12 squashing is gone rather than merely unobserved) |
active |
| A24 | Rotated evaluation IoU and its scoring protocol (R1 Tables 10-11 report rotated mAP50-95 on DOTA-v1.0 val and define neither the IoU, the matching rule, the recall interpolation, the detection cap nor the class-averaging convention) | Exact polygon intersection: Sutherland-Hodgman clipping of one quadrilateral against the other plus the shoelace area, vectorized in torch on rboxes_to_polygons corners (eval/dota_eval.py, WP-063). Clipping is edge-inclusive, matching WP-055's points_in_rboxes (A25); a shared edge or corner bounds zero area and scores 0 either way. Working dtype follows the input β no float64, because evaluation runs on MPS, the constraint A41 records for ProbIoU. Conditioning is fixed structurally instead: IoU is translation-invariant, so each pair is re-centred on its own midpoint before its corners are expanded. The order is load-bearing and measured β worst error against a float64 shapely evaluation over 300 overlapping pairs is 1.4e-07 at every offset from 0 to 1e6 when re-centring precedes expansion, against 2.7e-05 at 1e4 and 1.4e-03 at 1e6 when it follows. Zero-area, negative-extent and reversed-winding boxes clamp to zero area and yield IoU 0 rather than NaN. Averaging follows R12's COCO conventions: ten thresholds 0.50 to 0.95 step 0.05, classes without ground truth excluded from the mean β refined to non-difficult ground truth, an all-difficult class having an empty recall denominator. Reported as map, map_50, map_75, mar_300, the first three sharing coco_eval.py's names. The small/medium/large breakdown is deliberately absent: COCO's area ranges are an R12 constant with no oriented counterpart, and inventing one would be a threshold without a source |
R18 sec. 4 and its devkit (difficult convention); R12 (threshold grid, zero-GT exclusion); R13 (long-edge convention); R31 (the oracle); the polygon kernel and the re-centring order: gap | tests/eval/test_dota_eval.py::TestRotatedIou::test_vs_oracle β 600 random pairs across the canonical angle range against a shapely oracle at 1e-4, half near-coincident so the near-parallel regime is reached; ::test_vs_oracle_edge_cases covers 15 named pairs including both range bounds of the square fold, zero-area and degenerate lines; lead-side re-measurement with the oracle reading back the same float32 values the kernel receives, holding 1.4e-07 to 2.3e-07 at offsets 0, 1e3, 1e4 and 1e6. OBB-tier confirmation pending WP-088 |
active |
| A25 | STAL/TAL containment for rotated GT (R1 sec. 3.3.3 states STAL generically over box dimensions and never restates it for oriented boxes) | Point-in-rotated-rect containment, edge-inclusive, inherited from points_in_rboxes; the STAL clamp applied to the rotated (w, h) at the same s_min = 8, s_ref = 16 the axis-aligned path uses, R1 giving no separate oriented thresholds. Revised 2026-08-11 (WP-061) with two scoping facts the original wording left implicit and that are now load-bearing: the rotated box decides candidacy only β the IoU, the alignment metric t = s^alpha Β· u^beta, target_boxes and align_weights all keep running on the axis-aligned gt_boxes, so entry (b, n) of the two must describe the same object and the oriented path is not end-to-end until rotated IoU lands in the metric (WP-062/088); and gt_rboxes is per call rather than per GT, so a batch is wholly oriented or wholly axis-aligned, an axis-aligned object inside an oriented batch encoding as theta = 0 |
R1 sec. 3.3.3 (generic formulation); the oriented reading and the carried-over thresholds: gap | test_rotated_assign.py::test_tiny_rotated_gt (a 6x6 GT at 45 deg: vanilla TAL 0 candidates, STAL 4), ::test_rotation_excludes_envelope_corners (a 16x16 GT at 45 deg drops the four envelope corners the axis-aligned test keeps), ::test_axis_aligned_assignment_is_bit_identical. Lead-side check across the edit, on a scene with anchors exactly on GT boundaries: 20 assignment tensors, 6144 elements, bit-identical |
open |
| A28 | Detection-head stem structure and hidden width (Fig. S2 shows the stem pair, not widths) | Both box and class stems are depthwise-separable (two DepthwiseConv+ConvBNAct units + 1x1 output), shared hidden width max(16, channels // 3) (_stem_width); no num_classes floor (WP-023 iteration 1). Revised 2026-08-01 from the original full-conv box stem (channels // 4, two 3x3 convs) + class stem (max(num_classes, channels // 2)): the WP-023 param gate flagged the head as ~7-16x the reference budget (the box stem alone was ~3.7x the class stem despite emitting 4 vs num_classes channels, and the num_classes floor bloated the n/s head). The lightweight symmetric form lands all five scales within tolerance |
R1 Fig. S2; YOLOv10/R6 lightweight-head lineage | Param/FLOP gate (Phase 2) β test_det_vs_table7 |
active |
| A29 | R1 Table 7 param/FLOP counting convention | GFLOPs = 2x fvcore MACs (conventional one-multiply-plus-one-add FLOPs; raw MACs land ~48% low). Params count the full trained checkpoint (both dual-head branches); FLOPs count the deployed NMS-free inference model only (backbone+neck+one-to-one head β the one-to-many branch is training-only and never executed at E2E inference). Determined empirically by the WP-023 gate (trying raw-MAC and both-branch FLOPs is measurement, not iteration) | R6 (dual-assignment inference); R1 Table 7 | Param/FLOP gate (Phase 2) β test_det_vs_table7 |
active |
| A26 | Blueprint prescribes hand-annotated permissive fixture images; none exist | Test fixtures AND [DATA]-WP stand-ins are generated synthetically with fuse-augmentations (R21), seeded. Since WP-155β157 that package is also this project's augmentation engine (ADR-005), so this row no longer names a test-only dependency: one pin moves the fixtures and the training-time behaviour together, and a bump has to be read against both. The goldens keep the two apart by kind rather than by hope β the two generator-derived goldens, fixture_checksums.json and data_checksums.json, carry freezable: false and are never copied into a frozen set, while the pure-code goldens are (WP-154c). Fixtures are byte-identical per seed on a given platform (revised 2026-08-01: libm last-bit rounding differs across OS/architecture, so cross-platform reproducibility is asserted via structural metrics with tolerance in the golden harness, not byte hashes β observed macOS arm64 vs ubuntu x86_64 CI divergence); det/seg 16 scenes with boxes+polygons, OBB rotated scenes. Real COCO/DOTA remain required for tier runs (Phases 6β8); synthetic stand-ins never substitute for tier acceptance |
R21 | tests/fixtures/test_fixtures_load.py (same-platform byte determinism); goldens/fixture_checksums.json (cross-platform structural) |
active |
| A27 | MuSGD hybrid: momentum-state sharing and Nesterov placement unspecified | one momentum buffer per param; both branch updates derived from Nesterov-adjusted g + mu*m; Muon branch per R7 Eq. 4 scaling | R1 3.3.1; R7 Eq. 4; R8 | WP-033 toy convergence golden; Ablations-tier MuSGD-vs-SGD trend | active |
| A30 | Head initialization unspecified (R1 silent on init) | Classification-output 1x1 conv bias initialized to -log((1-pi)/pi), pi=0.01 (RetinaNet prior-probability init). Without it the summed TAL-normalized BCE opens at ~1.8M nats and the first MuSGD step kills the network (observed on Det-smoke launch 2026-08-02: loss 6.2e5 -> exact-zero dead predictor in 3 steps, reproduced on CPU and MPS) | R24 sec. 5.1 | test_head.py::test_cls_bias_prior_init; Det-smoke stable-training criterion |
active |
| A31 | From-scratch stabilization beyond init not restated | Global gradient-norm clipping at 10.0 via the trainer for tier/overfit runs; generic deep-learning practice, not lineage-specific. 30-step real-COCO check: descending loss, box predictions bounded within image scale. The warmup half of this gap landed with the A8 schedule (WP-072) | gap; standard practice | Det-smoke stable-training criterion | active |
| A33 | Batch transport precision unspecified (R1 silent; engineering-efficiency secondary goal) | Collated image batches cross the DataLoader worker boundary quantized to uint8 (round(x*255); restored code/255 on the destination device at the consumer's precision) β post-augmentation pixels rounded to 1/255 steps, worst-case round-trip error 1/510. Images originate as 8-bit JPEGs, so only sub-8-bit interpolation residue is lost; 4x smaller IPC keeps the in-flight worker queue inside containerized shm budgets (observed Colab sandbox ceiling). Per-sample pipeline numerics (and the data goldens, measured pre-collate) unchanged |
gap; 8-bit transport is standard CV-pipeline practice | tests/data/test_coco.py quantization round-trip bound; Det tier training criteria |
active |
| A34 | Coefficient stem internals unspecified | Mirrors the class stem: two depthwise-separable units then 1x1; no prior-probability bias | R1 Fig. S2 head symmetry + R16 | Phase 7 param/FLOP gate vs Table S9 | active |
| A35 | Proto-fusion projection form and upsample mode unspecified | Bare 1x1 nn.Conv2d projections with no normalization or activation (the literal reading of "projection"); nearest-neighbour upsampling to X_1's spatial size, matching the neck's existing top-down convention |
R1 Eq. 8 + this repository's neck convention | Phase 7 param/FLOP gate vs Table S9 | active |
| A32 | Train-time resampling filter unspecified (R1 silent on interpolation) | The fused affine+letterbox warp (WP-070) resamples the training geometric base once with plain bilinear grid_sample β no antialiasing on the train-time downscale (grid_sample has no antialias mode; the prior two-step path antialiased its letterbox stage). Box/polygon arithmetic is unchanged (identical composed affine); only image pixels differ. Measured 4.4x geometric-path speedup (17.6 -> 4.0 ms/sample, 1280->640, single thread). Revised 2026-09-03 by WP-155: the validation/eval letterbox no longer keeps antialias=True either. Delegating the resample to fuse replaced the antialiased F.interpolate plus F.pad with one grid_sample from the source canvas to the letterboxed one, whose antialias is off by default and whose opt-in prefilter is a Gaussian mipmap rather than torch's area-weighted kernel β measured not to engage at this producer's downscale, so enabling it recovers nothing. The assumption's content is unchanged and its reach widened: non-antialiased resampling is now the whole pipeline's filter rather than the train path's deviation from the eval path, and the two paths differ only in warp count. The eval-path cost is one measured number, letterbox_image_mean 0.5024 -> 0.4994 against a 0.002 tolerance, geometry bit-unchanged |
gap; cv2-lineage resizes are conventionally non-antialiased | goldens/data_checksums.json (recomputed 2026-08-02); goldens/aug_invariants.json letterbox_image_mean (re-frozen 2026-09-03, WP-155b); Det-ablations trend vs Det-smoke |
active |
| A36 | Auxiliary-loss numerics the papers leave open (A16 fixes the instance-mask objective's form; R1 sec. 3.4.1 names the aux branch's supervision as BCE+Dice without a formulation) | (i) The instance-mask box-area normalizer is clamped to a minimum of 1.0 (one pixel), so a degenerate or sub-pixel box yields a finite contribution instead of dividing by ~0. (ii) The auxiliary soft Dice is taken on sigmoid probabilities as 1 - (2*sum(p*t) + s) / (sum(p) + sum(t) + s) with smoothing s = 1.0 in both numerator and denominator, computed per (B, C) pair over the spatial dims only and then averaged over B and C β per-pair-then-mean stops one large class dominating the batch, and the smoothing makes an empty prediction against an empty target score exactly 0 rather than 0/0. (iii) Both auxiliary terms carry unit coefficients: total = bce + dice; any overall gain belongs at the composition site, not inside the term |
gap | tests/losses/test_mask_loss.py, tests/losses/test_semantic_aux.py; Phase 7 overfit micro-set mask-IoU check |
active |
| A37 | Mask-decode numerics the papers leave open (Eq. 7 gives the assembly and A16 fixes the cropping principle and the coefficient activation; neither fixes how an assembled mask becomes binary, at which resolution, or how it leaves the letterboxed canvas) | (i) The binarization threshold is 0.5 on the sigmoid probabilities. (ii) Probabilities are bilinearly upsampled (align_corners=False) from the prototype grid (A15) to the letterboxed input resolution before cropping and thresholding β thresholding first would snap every mask boundary onto the prototype grid, and no later step recovers the lost sub-cell position. (iii) Decode crops to the predicted box using the same A11 half-open pixel-centre membership rule the training loss applies to the ground-truth box, so the model is scored under the geometry it was trained under. (iv) The inverse letterbox for a binary mask resamples nearest-neighbour, implemented by mapping each original-image pixel centre through the same Letterbox.forward_affine scale and padding whose inverse maps the boxes back (A10) β not through an independently derived ratio: a crop-then-resize would implicitly scale by the rounded content size over the original size rather than by r, leaving masks and boxes on subtly different grids with only the segm score moving |
gap | tests/eval/test_segment_decode.py (boundary between prototype cells, crop window, threshold cut, letterbox round trip, box/mask frame agreement); Phase 7 segm mAP gate |
active |
| A38 | Weight of each segmentation term in the total, and which branch's mask coefficients they supervise (R1 gives no gain for either term, and never says whether the training-only one-to-many branch's coefficients are supervised at all) | (i) mask_gain = 2.5, semantic_gain = 0.5 on the two pre-gain terms. Both terms are per-pixel means and open at ~0.72-0.77 (mask: the BCE of an unbiased mask logit) and ~1.01-1.09 (semantic: its Dice sits at ~1 while the A30-biased classifier predicts ~0.01 everywhere) β measured at initialization at both the overfit scale (4 classes / 320 px, detection total 200.0) and the COCO scale (80 classes / 640 px, detection total 7214.4). At init the two terms are therefore 2-3 orders of magnitude below the detection total whatever gain they carry, so the balance point is the converged regime, where the detection total falls to single digits: the gains are set there, and are small integers rather than the detection terms' 7.5/6.0, which scale summed, unnormalized quantities and would be a category error to copy onto a per-pixel mean. 2.5 puts the instance-mask term β the objective the segmentation task exists for β clearly above the auxiliary one; 0.5 matches cls_gain and marks the training-only branch as a shaper of shared features rather than an objective in its own right. (ii) The mask term supervises both branches' coefficients, each against its own assignment, combined with the same alpha split the box terms use (R1 Eq. 2-3): the one-to-one coefficients are the ones the deployed segmentation decode reads, so supervising the one-to-many branch alone β the branch whose dense assignment supplies the positives β would ship a never-trained coefficient stem, while summing the two under one weight would pull the mask supervision against the progressive ramp instead of with it |
gap | scripts/overfit_micro.py --task seg β measured 2026-08-07: train mask IoU 0.8146 over 592 instances (floor 0.7), decoded through the deployed one-to-one path, so the both-branch reading of (ii) is what that number rests on; tests/ptl/test_seg_training.py (terms live, gradients reach every branch, targets gathered by the assignment); Seg-smoke segm mAP |
active |
| A39 | Fate of DOTA's per-object difficult flag (R18 defines the field β "1 for difficult, 0 for not difficult" β and neither it nor R1 says whether such instances are trained on or scored) |
Not decided at load: keep_difficult is a required keyword argument of dota_targets / load_dota_targets, so every call site states its choice and none inherits a silent default. The flag is parsed and carried on every DotaObject either way, so both policies stay available downstream, and check_data.py counts every object line regardless (the published 188,282 total does not break difficult instances out). The tier-level policy β what the training path feeds and what the val protocol ignores β lands with WP-088 and WP-063 respectively and revises this row then |
R18 annotation format | tests/data/test_dota_parse.py (flag round-trips; both policies produce their expected instance counts with every modality still aligned); tier policy pending WP-063/088 |
open |
| A40 | Training-time augmentation of rotated boxes (R1 lists the augmentations in Table S3 and R18 gives the crop-time rule for a partial object; neither says what a shear, a canvas edge or a mosaic quadrant does to an oriented annotation during training) | Three choices, all in rotated_aug.py (WP-058). (i) Warp: a general affine sends a rectangle to a parallelogram, so a rotated box is expanded to its four corners, those corners go through the same matrix the image does, and a canonical long-edge box is re-fitted to them β R18's own prescription for its cropped parts ("described as an oriented bounding box with 4 vertices ... with a fitting method"). The fit is exact under a similarity (rotation, uniform scale, translation) and approximate under shear, with per-corner residual h * sin(s/2) for an edge-perpendicularity deviation s; flip and mosaic placement are isometries and stay exact. Every transform re-canonicalizes on the way out, which also closes the WP-013 defect where a mirrored theta > pi/4 left the long-edge range. (ii) Clipped instances are dropped, not flagged: WP-057 follows R18 in flagging a below-threshold part difficult and keeping it, but that is dataset preparation and Targets carries no difficult field at training time, so augmentation drops instead β by exactly the axis-aligned path's existing rule (min_box_size on the clipped envelope's sides, min_visibility on clipped-over-pre-clip envelope area), so one policy governs both modalities. The clipper and the orientation-preserving re-fit are WP-057's, not a second implementation. (iii) boxes is recomputed as the envelope of the rotated geometry whenever rboxes are present, rather than warped independently: after a rotation the envelope of a warped envelope is the envelope of nothing real, and WP-061's assigner reads the two modalities as one instance. This narrows WP-056's load-time meaning (envelope of the hand-drawn quad) to "envelope of the fitted rectangle" from the first geometric transform onward |
R18 sec. 4 (fitting method, and the 0.7 flag rule this diverges from); R1 Table S3 (the augmentation list); the drop/flag split itself: gap | tests/data/test_rotated_aug.py β ::test_roundtrip (the DoD: a transform composed with its inverse returns the rotated boxes to tolerance), canonical output swept over the whole angle range through flip/affine/mosaic/mixup, similarity exact vs the derived shear bound, envelope/rotated-box consistency, and one-mask-drops-every-modality; OBB-tier confirmation pending WP-088 |
active |
| A41 | ProbIoU's behaviour on a degenerate box (R17 derives B_D for non-singular Gaussians and never says what a zero-area box does; both of its denominators vanish there) |
Box sides are floored at min_side = 1e-4 in whatever coordinate frame the caller works in, before w, h become the variances w^2/12, h^2/12 (probiou.py, WP-059). The value is deliberately not load-bearing: B_D depends on the floor only logarithmically, one decade moving it by exactly ln 10 (measured: 5.57, 7.87, 10.18, 12.48 for floors 1e-2 β¦ 1e-5 against a fixed normal box), so the pair saturates at L1 = 1 for any choice in the usable window and the choice is which order of magnitude, not which number. The window is bounded below at ~1e-10, where the product of four floored sides underflows float32 and a degenerate-against-degenerate pair returns NaN (measured: 1e-12 does), and above at ~1e-3, where the floor would start to move genuinely small boxes in a normalized frame; 1e-4 sits six decades above the first bound and one below the second, and is inert in a pixel frame either way. A clamped side takes zero gradient, so the loss abandons a collapsed box rather than chasing it. Two related numerical choices are documented in the module rather than here, being properties of the same equations rather than open gaps: the Hellinger loss's radicand is floored at 1e-20 inside its square root (whose derivative is infinite at zero, so a coincident pair would backpropagate 0 * inf = NaN; the coincident case takes an exact-zero branch and never observes the floor), and the working precision follows the input dtype β float64 internals are unavailable because a regression loss runs on the training device and D12c puts training on MPS, which has no float64 |
R17 (silent); the floor value itself: gap | tests/losses/test_probiou.py β zero-area, zero-width, zero-height, below-the-floor and negative-sided boxes stay finite forwards and backwards, against both a normal box and another degenerate one; ::test_clamped_sides_take_no_gradient; the logarithmic floor dependence is what makes the value a non-item. OBB-tier confirmation pending WP-088 |
active |
| A42 | The angle loss on a degenerate target (R1 Eq. 15 forms omega from ln(w*/h*), which is undefined when either side is zero, and R1 never says what a collapsed target does) |
Both target sides are floored at min_side = 1e-4 before the ratio is taken (angle_loss.py, WP-060) β numerically the same floor probiou.py uses, but not for A41's reason: A41 rests on B_D depending on the floor only logarithmically, and no part of that argument transfers to a log-ratio. The consequence is the honest reading of the formula rather than a choice R1 supports: a target collapsed in one dimension floors to an extreme ratio and its omega decays towards zero, so it is scored as elongated and left to the rotated IoU loss; a target collapsed in both dimensions floors to ratio 1 and receives omega = 1, i.e. full square-object weight, on a box with no orientation to supervise. That is the exposure this row records. It is bounded β such a target contributes at most q_i / S to a term whose own gain is A22's 1.0 β and it is unreachable through the shipped data path, where WP-057 drops or flags sub-pixel instances long before assignment. The floor is also what keeps the backward pass finite: unfloored, ln(w/h) goes to inf and its gradient to NaN |
R1 (silent on degenerate targets); the floor value itself: gap | test_angle_loss.py::test_degenerate_targets_stay_finite_forwards_and_backwards covers one-sided and two-sided collapse, forwards and backwards; ::test_omega_profile pins the undegenerate profile at 1.000, 0.948, 0.750, 0.555, 0.369 for 1:1 through 20:1. Whether a real OBB run ever presents one is unmeasured until WP-088 |
active |
| A43 | Whether the STAL surrogate may leave the long-edge convention (R1 Eq. 4-6 clamps each dimension independently and says nothing about oriented boxes, so nothing in the paper forbids the short edge overtaking the long one) | surrogate_rboxes passes its clamped output through canonicalize, which swaps the pair and turns theta by pi/2 whenever h overtakes w -- a 10x4 box clamped to s_ref = 16 becomes 16x10 turned a quarter, the same rectangle. Without it the surrogate would hand every downstream consumer an rbox violating the w >= h invariant rotated_geom documents as enforced. Free of consequence for candidacy, which is what the surrogate is for: containment is invariant under the swap, measured identical candidate sets from either representative |
R1 Eq. 4-6 (dimension-wise clamp); the oriented re-canonicalization: gap | test_rotated_assign.py::test_rotated_surrogate_clamps_per_edge; lead-side check that a 10x4 box at s_ref = 16 returns 16x10 at theta + pi/2, is its own canonical form, and yields the same candidates either way |
active |
| A44 | How the angle composes with the ltrb regression (R1 gives Eq. 13 for the angle and the "separate branch" wording for where it comes from, but never says how that scalar and the four distances become one oriented box) | The minimal composition the "separate branch" wording implies: the ltrb distances are decoded by decode_ltrb unchanged β the same function, not a rotated variant of it β the resulting xyxy box supplies the centre and the two extents, and theta then rotates that box about its own centre (decode_rboxes, WP-062). The angle branch therefore adds orientation to the existing box regression rather than reinterpreting it, and a predicted theta of 0 reproduces the axis-aligned decode exactly, which is the cheapest available check that the two paths have not drifted. Extents are passed through unclamped, matching the axis-aligned decode: an untrained head emitting r < -l yields a negative extent in both paths, and clamping only the oriented one would hide the divergence rather than fix it. The alternative readings this forecloses β distances measured in the rotated frame, or the anchor offset turned by theta before the box is formed β are not excluded by anything in R1 and would change what the box regression means; the oriented tier is what can distinguish them |
R1 sec. 3.4.3, R1 Eq. 13 (both silent on the composition); the composition itself: gap | test_obb_head.py::test_zero_angle_decode_reproduces_the_axis_aligned_box against decode_ltrb on the same inputs; end-to-end through the deployed view in ::test_deployed_view_decodes_end_to_end_into_oriented_detections. Whether it is R1's composition is unmeasurable without a trained oriented tier (WP-088) |
open |
| A45 | Oriented output tuple (the A9 analogue: R1 sec. 3.2.1 fixes neither layout, and the oriented one has no published form at all) | Width 7, [cx, cy, w, h, theta, score, class] (RBOX_DET_WIDTH): the five-column long-edge rotated box of rotated_geom followed by the same score and class columns A9's axis-aligned tuple ends with, so the two layouts differ only in their box columns. Selection is not recomputed here β o2o_rotated_topk delegates to o2o_topk_with_indices and gathers theta by the anchor indices that helper reports, the rule A9's segmentation coefficients already follow. A second ranking would be free to disagree with the first and pair one anchor's orientation with another anchor's box: a detection with a correct centre, a correct score and a silently wrong heading |
R1 sec. 3.2.1 (dense one-to-one output, no tuple layout); the layout itself: gap, by analogy with A9 | test_obb_head.py::TestRotatedTopk::test_emits_the_oriented_detection_tuple, ::test_pairs_each_angle_with_its_own_anchor (the anti-drift gate), ::test_returns_every_anchor_when_fewer_than_k (node ids moved under TestRotatedTopk by WP-128) |
open |
| A46 | Recall interpolation, on both instruments (R1 names no averaging convention, and the two candidate protocols disagree: R12's COCO AP samples 101 interpolated recall points, R18's devkit inherits VOC's all-point interpolation) | The COCO 101-point grid (RECALL_POINTS = 101), sampled so that "has recall reached i/100" is decided by the values and not by rounding. dota_eval decides it in exact int64 (hits*100 >= i*positives); coco_eval supplies torchmetrics the correctly rounded float64 hundredths through its documented rec_thresholds constructor argument (WP-092), replacing the float32 torch.linspace default that overshoots k/100 at 36 of the 101 indices. Raw pycocotools/faster-coco-eval's own float64 numpy.linspace forfeits 10 (indices 35, 41, 47, 57, 69, 70, 82, 83, 94, 95); both lucid-yolo instruments now forfeit none and agree exactly at every boundary either can reach. A forfeited boundary cost 1/101 of that class's AP, always downward; exposure was ordinary rather than exotic, since a class with 5, 10, 20, 25, 50 or 100 ground truths lands on a grid point at every attainable recall, 20-36% of them forfeited. The two routes are exact for different reasons β int64 for any denominator, float64 because correctly rounded division puts equal rationals on the same double while distinct ones stay at least 1/(100Q) apart, exact to Q ~ 4.5e13 β so the agreement is a measured property, not a shared code path. Only the map family was affected; average recall never enters the grid. The oriented number remains non-comparable to published DOTA leaderboard figures, which use the devkit's VOC-style averaging; the tier report must state which protocol it quotes |
R12 (the 101-point grid); R18 devkit (the VOC-style alternative this departs from); the exact-sampling choice on both instruments: gap | test_dota_eval.py::TestRecallGridBoundary::test_boundary_recall_is_sampled_exactly β seven exact-boundary cases against hand-derived (k+1)/101, three at indices float32 gets wrong; ::test_agrees_with_the_axis_aligned_instrument_at_a_float32_boundary pins both instruments at 66/101 where they used to differ 66-versus-65. test_coco_eval.py::TestRecallGridBoundary::test_boundary_recall_is_sampled_exactly β eight cases, five at forfeited indices, confirmed lead-side to fail against the unwired code with deltas of exactly 1/101 while the three control boundaries float32 gets right pass throughout; ::test_the_torchmetrics_default_grid_overshoots_at_thirty_six_boundaries pins the dependency defect so its removal is noticed; ::test_guard_fires_when_the_metric_stops_honouring_rec_thresholds pins the fail-loud guard. Lead-side reproduction of the 36/10/0 forfeit counts and the exact ten-index float64 miss set, and of rec_thresholds being a documented parameter forwarded verbatim as float64 |
validated |
| A47 | Detections scored per image (R1 states no cap; R12's COCO protocol fixes maxDets at 100, which is below what this architecture emits) | MAX_DETECTIONS = 300, applied per image across all classes inside the accumulator. 300 is not borrowed β it is what the shipped oriented path produces (o2o_rotated_topk default k, the A45 tuple, A9's cap), so a 100 cap would discard two thirds of the model's output and report the remainder as its recall. Reported as mar_300 rather than mar_100, the cap differing so the name does too. The exposure this records: a rotated mAP quoted against COCO's 100-detection convention is not this number |
R12 (the 100-detection convention this departs from); A9 (the 300-row output); the value itself: gap, inherited from the architecture rather than from a protocol | test_dota_eval.py::TestProtocolConstants::test_detection_cap_is_300_not_cocos_100 asserts the constant equals the head's own output width, so the two cannot drift; TestDetectionCap::test_detections_beyond_the_cap_are_dropped and ::test_detections_within_the_cap_survive place the same correct detection at rank 350 and rank 299; lead-side reproduction of both |
open |
| A48 | Which ground truth a detection matches when a difficult instance also overlaps it (R18's devkit settles that a difficult match is discarded, but its VOC-derived code takes the argmax over all ground truths before inspecting the flag, so it never states what happens when a difficult and a matchable non-difficult instance both qualify) | A non-difficult match takes precedence: the detection claims the best still-unmatched non-difficult ground truth at or above the threshold; only failing that does an overlap with any difficult instance discard it; failing both it is a false positive. Difficult instances are never consumed, so several detections may be discarded against one β the devkit's behaviour. The divergence from literal argmax is narrow but real: under the devkit a difficult instance overlapping a detection more than a matchable non-difficult one shadows it and costs a true positive, and this ordering declines to. Load-bearing rather than decorative, because tile_windows flags every instance clipped below 70% visible as difficult (A39), so real DOTA val tiles carry them whether or not the label file did β which also makes keep_difficult=True a requirement of the evaluation loader, filtering at load turning every ignorable detection into a false positive |
R18 sec. 4 and its devkit (discard rule, 0.7 flag); the precedence ordering itself: gap | test_dota_eval.py::TestDifficultGroundTruths::test_detection_on_a_difficult_target_is_discarded contrasts the flag being present against the instance being deleted; ::test_difficult_targets_leave_the_recall_denominator; ::test_one_difficult_target_may_absorb_several_detections; ::test_absent_difficult_key_reads_as_no_instance_difficult; lead-side check that flagging scores 1.0 where deleting scores 0.5 on identical predictions |
open |
| A49 | Which of R17's two ProbIoU losses the oriented path uses, and at what gain (R17 proposes both the bounded Hellinger form and the unbounded Bhattacharyya form, and recommends starting on the latter and switching; R1 names neither, and settles no gain for an oriented box term) | The bounded Hellinger form L1 = H_D = 1 - ProbIoU, at A13's box_gain = 7.5 carried over unchanged. It occupies the slot the Complete-IoU loss vacated and shares its [0, 1] range, so the existing gain transfers without inventing a magnitude, which the unbounded form's distance-dependent scale would have required. R17's argument for B_D is its non-vanishing far field, and that far field is largely unreachable here: an anchor is a candidate only if its centre lies inside the rotated ground truth (A25), and a positive's weight is a normalized s**alpha * u**beta already near zero for a badly placed box. R17's start-on-L2-then-switch schedule would add both a switch epoch and a second gain, neither of which any source states. The form is a constructor argument rather than a hard-coded call, so the alternative is one config key away |
R17 (both forms and the switching schedule); R1 (silent on which, and on the gain); the selection and the gain transfer: gap | tests/ptl/test_obb_training.py::test_each_oriented_gain_enters_the_total_at_its_weight pins the gain's leverage on the total. Measured lead-side on the WP-088 overfit slice at seed 0, 100 epochs, recipe otherwise untouched: the registered Hellinger form scores 0.8959 rotated mAP50 against 0.3914 for bhattacharyya, a deficit of 0.5046. The alternative is not marginally worse but far worse, so this is a measured choice rather than an argued one |
validated |
| A50 | Whether the L1 box term survives beside a rotated IoU term, and against which target (R1 sec. 3.4.3 gives the oriented objective's terms but does not say whether the axis-aligned L1 term is kept, dropped, or retargeted) | Kept, with its target moved to the rotated box's own (cx, cy, w, h). Keeping it unchanged is not available: its original target is assign.target_boxes, the axis-aligned envelope of the ground truth, while A44's composition reads the decoded xyxy as the oriented box's own extents before rotating about its centre. For a 40x10 box at 45 degrees the envelope is roughly 35x35, so the two terms would pull the same w and h towards targets that disagree by the whole of the rotation; they coincide only at theta = 0. Dropping it is worse still: it would leave the extents supervised by ProbIoU alone, which floors a collapsed side at min_side with exactly zero gradient there (A41), and that is the state an untrained head starts in, since raw ltrb distances routinely decode to r < -l. The L1 term is what pulls a collapsed extent back out. theta is deliberately not an L1 column: it is not a stride-unit quantity, and R1 Eq. 15 already owns the angular residual |
R1 sec. 3.4.3 (the term list); A41 (the ProbIoU gradient floor this depends on); A44 (the decode frame that makes the original target wrong); the retargeting: gap | tests/ptl/test_obb_training.py::test_each_oriented_gain_enters_the_total_at_its_weight pins the term's leverage. Measured lead-side on the WP-088 overfit slice at seed 0, 100 epochs: retargeting scores 0.8959 rotated mAP50 against 0.1847 with the term dropped, a deficit of 0.7112 β the largest single effect measured on this path, and direct confirmation of the collapsed-extent mechanism argued above |
validated |
| A51 | Where R18's per-instance difficult flag lives between the loader and the metric (R18 defines the flag and its scoring rule, but no source states how it should be carried through a training pipeline that also serves non-oriented datasets) | A difficult channel on Targets, on the shared instance axis, defaulting to "no instance is difficult" so every non-oriented dataset means what it already meant. This is the channel A48's requirement needs rather than a second policy: A48 makes keep_difficult=True a requirement of the evaluation loader, and without somewhere to put the flag, keeping the instance and dropping it are identical downstream. The flag survives filter, concat, clone, the letterbox transform (the only geometric transform on the evaluation path) and the loader's packed transport. Train-time multi-image assemblies rebuild their instance axis and are documented at each site; the flag is not consumed in training at all, since A39 leaves that policy open |
R18 sec. 4 and its devkit (the flag and its scoring rule); A48 (the loader requirement this serves); the transport channel: gap | tests/ptl/test_obb_training.py::test_difficult_flags_reach_the_metric_through_the_loader drives the flag through the real loader into the metric; ::test_rotated_targets_survive_the_loader_transport_into_the_metric covers the geometry it rides with |
active |
| A52 | Whether an object-free crop is kept as a training image (R18 sec. 4 defines the crop geometry and the difficult rule for parts of objects, but neither it nor R1 says what becomes of a window that contains no annotated object at all β and at 1024 px over a DOTA image, many do) | Kept, and written to the layout like any other tile. The one-to-one branch is trained to fire once per object and not otherwise, so object-free crops are exactly the negative evidence for the second half of that; dropping them would train the suppression-free path only on scenes where something is present. The cost is disk, which is a build artifact's cheapest resource. build_dota_tiles.py --drop-empty-tiles selects the other reading without re-deriving anything, and the build's report prints the count either way so the choice is never silent |
R18 sec. 4 (the crop protocol that produces them); the retention policy: gap | tests/data/test_build_dota_tiles.py::test_every_window_of_every_source_image_is_written and ::test_dropping_empty_tiles_keeps_only_annotated_windows pin both readings against the window count |
active |
| A53 | How R18's per-instance difficult flag and a tile's window provenance survive a COCO container (COCO's schema has no field for either, and A51 fixes only where the flag lives after the loader has it) | Non-schema keys, written by the build and read back by name: difficult and visible_fraction per annotation, source_image and window per image. A standard COCO reader ignores all four and sees an ordinary detection set, which is the property that makes the layout usable by anything else; this project's reader forwards difficult onto the A51 channel and defaults it to False when absent, so an ordinary COCO file is unaffected. visible_fraction rides along because the fitted box's area is not the clipped area, so R18's U_i is otherwise unrecoverable and a threshold sweep would have to re-tile. The window keys exist because whole-image evaluation needs to know which source image a tile came from and where it sat, and recovering that by parsing file names would make the naming a load-bearing interface |
R18 sec. 4 (the flag and U_i); R12 (the COCO container being extended); the encoding: gap |
tests/data/test_coco.py::test_the_difficult_key_reaches_the_targets_channel on both readings and ::test_an_ordinary_coco_file_reads_as_nothing_difficult; tests/data/test_build_dota_tiles.py::test_the_difficult_flag_survives_the_round_trip and ::test_image_records_carry_their_window_provenance |
active |
| A54 | Whether a YOLO image with no label file has no objects or a broken export (the format carries no image manifest β no COCO images array β so an absent file and a half-finished download are indistinguishable on the format's own terms) |
A missing label file raises, naming the image and the path searched; an empty file reads as a background image, because writing zero bytes is a positive statement that the image holds none. allow_missing_labels=True opts into the permissive reading for a dataset that genuinely omits them |
R32 (every image in the read export has a label file; none is empty) | tests/data/test_yolo_parse.py β a missing file raises naming both, an empty one yields a zero-instance Targets |
active |
| A55 | The legal range of a YOLO label row's coordinate fields, and what to do outside it (no allowlisted source states the range or the failure mode) | Fields must lie in [0, 1], compared exactly with no epsilon; outside it the file was never normalized β typically written in pixels β and is rejected rather than clamped, since clamping turns such a file into a wall of degenerate edge boxes it would then train on. The pixel box derived from a valid row is still clamped to the image bounds, as the COCO reader clamps xywh |
R32 (fields lie in (0, 1]; the export writes clipped corners as exactly 0 and 1, so 1.0000001 is out of spec rather than rounding) |
tests/data/test_yolo_parse.py β an out-of-range field raises; a valid row's pixel box is canvas-clamped |
active |
| A56 | The oriented YOLO row's field count (R18's own label lines carry a trailing difficult flag; the normalized variant has no published spelling for one) |
Exactly nine whitespace-separated fields: class index, then four normalized (x, y) corners. A ten-field row is rejected naming file and line rather than having the extra column assumed away |
R18 (the quadrilateral convention and the flag it appends); no source reachable for the normalized ten-field form | tests/data/test_yolo_parse.py β a ten-field row raises |
active |
| A57 | The form of names in data.yaml (a list and a mapping are both plausible and the papers say nothing) |
A list in index order, or a mapping whose keys are exactly 0..K-1; any other mapping is rejected, because it leaves the class order unstated and a silently reordered class list is a wrong dataset that trains. nc, when present, must equal len(names) |
R32 (names written as an index-ordered list beside nc: 3) |
tests/data/test_yolo_parse.py β list and contiguous mapping accepted, sparse mapping and nc mismatch rejected |
active |
| A58 | How a data.yaml split entry resolves to a directory (the entry is a relative path with no stated base) |
Against the data.yaml's own directory: the literal reading first, then the same path with its leading .. components dropped. Neither candidate existing raises, listing both. A path: key is not honoured β it is not present in any export reachable from the allowlist, and inventing its semantics is the guess this package's clean-room clause exists to prevent |
R32 (writes train: ../train/images for a tree at <root>/train/images, and carries no path: key) |
tests/data/test_yolo_parse.py β both spellings resolve, an unresolvable entry raises listing every path tried |
active |
| A59 | How detections from overlapping tiles are merged onto one whole source image (R18 sec. 4 defines the crop protocol and says nothing about recombining detections, and R1's one-to-one branch has no suppression stage a seam duplicate could be removed by) | Core ownership. Each tile owns the source-coordinate rectangle whose axis boundaries sit at the midpoints of the overlap bands with its neighbours, outer bounds infinite; a detection survives iff its source-coordinate centre lies in its own tile's core, and whole-image ground truth is reconstructed from the tiles' own annotations by the identical filter. The cores partition the plane, so an object contributes at most one detection however many tiles saw it. This is ownership, not suppression: nothing is compared against another detection or ranked by confidence, and the assignment is fixed by the tiling geometry before the model runs β which is why it composes with a suppression-free path where a confidence-ranked IoU dedup would reintroduce NMS at the seam. With overlap v a core ends v/2 inside its own window, so every object of circumradius at most v/2 is wholly visible to the tile owning its centre, which is what R18's stride was chosen to give. Costs, all stated in the module: whole-image recall is the owner's recall and not the union, so the merge can only lower a per-tile number; objects wider than the overlap are unrecoverable under any tile-local rule; boundary jitter can double a detection. One residual flattering effect β a clipped difficult copy that survives ownership absorbs a detection the true annotation set would score as a false positive, measured at 0.165 of map_50 on the unit fixture and bounded to the same wider-than-overlap class |
R18 sec. 4 (the crop protocol and the 0.7 rule the reconstruction rests on); R1 sec. 3.2.1 (the suppression-free branch); the rule itself: gap | tests/eval/test_tile_merge.py::TestSeamDuplicate, ::TestSingleTileEquality, ::TestOwnershipCost, ::TestGroundTruthReconstruction |
active |
| A60 | How many detections are scored per evaluation unit when the unit is a merged whole image (A47 fixes 300 because that is what o2o_rotated_topk emits per forward pass; a merged source image is many forward passes) |
The whole-image path scores with the cap off. A47's 300 still applies where it was put β at emission, per tile β so nothing is uncapped that the head did not already cap, while re-capping the merged image at 300 would measure a truncation rather than a model, a dense DOTA image carrying far more than 300 instances. The per-tile path is unchanged and bit-identical at the default. Exposure: the metric key stays mar_300, A47's name, whatever cap produced it, so a report quoting a whole-image figure must name the cap it used β the report payload's whole_image.max_detections field does. First exercised at WP-111: the OBB-smoke checkpoint's whole-image mAR_300 came out 0.0118 above its per-tile figure on real DOTA-v1.0 val, consistent with the dense-image rationale above, though that run does not isolate the lifted cap from WP-107's core-ownership merge running alongside it |
A47 (the per-tile cap this departs from); R12 (COCO's maxDets); the lifting: gap |
tests/eval/test_tile_merge.py::TestDetectionCap; tests/eval/test_eval_obb.py β the report names both figures rather than quoting one; WP-111's real-data run, log-only, not test-pinned |
active |
| A61 | The IoU threshold of rotated non-maximum suppression (R1 states no suppression parameter at all β the one-to-one branch's freedom from NMS is its claim, not a setting; R18's devkit scores detections it is handed and settles nothing on the decode side; R13 fixes the angle convention and no suppression rule, so the oriented comparison column has a knob no allowlisted source turns) | ROTATED_NMS_IOU_THRESHOLD = 0.7, read by RotatedNMSDecoder from the one constant rather than restated at the constructor. The carry-over from the axis-aligned default (A62) is the argument: the oriented nms column exists to be compared against the suppression-free one, and turning the overlap measure and the threshold in the same step would leave a difference between the columns attributable to neither. It deliberately does not rest on the reading that looks obvious and is false β that the two measures are ordered. Rotated IoU is usually the smaller (mean 0.047 against 0.080 over 3000 random canonical pairs) but exceeds envelope IoU whenever both boxes lie along a shared diagonal, measured at 138 of those 3000 pairs by up to +0.146; so 0.7 cannot be defended as "suppresses no more than the axis-aligned path did", only as holding the knob still while the measure changes. Exposure: 0.7 under-suppresses elongated objects, which is DOTA's characteristic shape β two detections of one 10:1 object whose headings disagree by 5 degrees score 0.643 and both survive (10:1 at 2/5/10/15 degrees: 0.839/0.643/0.395/0.239; 5:1: 0.914/0.802/0.645/0.516; 2:1: 0.958/0.903/0.825/0.762). That direction is the one this project prefers, a duplicate being inspectable where an over-suppressed object is simply absent, and the value is a constructor argument |
R1 (states no suppression parameter); R18 devkit (evaluates, does not decode); R13 (angle convention only); the value itself: gap | tests/decode/test_rotated_nms.py::TestRegisteredThreshold pins the decoder's default to the constant so register and code cannot drift; ::TestRotatedSuppression::test_the_threshold_decides_a_partial_overlap places a pair at 0.667 between thresholds 0.7 and 0.5 and asserts each answer, so the parameter is consulted rather than effectively fixed at an extreme. Revised by the first OBB-tier run reporting both columns: the mAP delta between them at 0.7 is the measurement |
active |
| A62 | The IoU threshold of axis-aligned class-wise NMS (R1's dense branch is a training-time auxiliary and no allowlisted source states a decode-side suppression threshold for it; the value has shipped since WP-042 as NMSDecoder's default with no row of its own, which WP-091b found when A61 went looking for a lineage to inherit) |
0.7, unchanged β registered here as the convention it has always been rather than revised in place, because every axis-aligned "mAP (non-E2E)" number this project has published was measured at it and re-picking the value now would silently move a comparison column that already has committed figures beside it. It is the widely used detection-NMS convention and nothing more; no source on the allowlist is cited for it, and A61 inherits it as a convention rather than as a citation |
R1 (no decode-side suppression parameter); the value itself: gap | tests/decode/test_nms_path.py β the existing decoder suite exercises the default; a revision is a golden-moving change and would ship as the next 0.MINOR |
active |
| A63 | Which reader a dataset root calls for when its own directory names satisfy both conventions (no source states a precedence and none can β the two layouts come from different publishers, R12's COCO container and R32's YOLO export, with no shared precedence statement between them) | Refused, not resolved. Both candidate tables are evaluated in full and the verdict is a pair of booleans, so the answer cannot depend on which was consulted first; satisfying both raises naming both matches and the two spellings of the explicit layout override, and satisfying neither raises naming both conventions in words plus every path tried. Precedence is taken within a candidate table, where the loser is another spelling of one reader and at worst the wrong directory of the same dataset, and never between the tables, where the loser is a different label space, a different image set and a different parser β a silent pick there trains on annotations nobody named, and reports a plausible number while doing it. The explicit override exists because two states are otherwise unreachable: an ambiguous root on a read-only mount, and a YOLO root whose data.yaml points its splits outside the convention table, which from_root resolves and no probe can see |
gap; R12 and R32 are independent publications with no shared precedence statement | tests/ptl/test_datamodule.py::TestLayoutDispatch β a both-root raises naming both overrides, a neither-root raises naming both conventions and every path tried, an explicit layout settles either |
active |
| A64 | HorizontalFlip's left/right keypoint identity swap under a mirror (Phase 12's keypoint container is deliberately K-generic β no source states which index swaps with which, and none could: that pairing is anatomical, not geometric, and belongs to whichever dataset supplies K points, not to the mirror transform itself) |
The swap is a caller-supplied keypoint_flip_pairs constructor argument, never a constant inside HorizontalFlip; None mirrors x-coordinates only, which is correct for any K-point task with no left/right symmetry. For the COCO human-pose demo dataset (R12's 17-point person category, ordered nose, left/right eye, left/right ear, left/right shoulder, left/right elbow, left/right wrist, left/right hip, left/right knee, left/right ankle), the pairing is indices (1,2),(3,4),(5,6),(7,8),(9,10),(11,12),(13,14),(15,16), index 0 (nose) fixed β recorded here as the value to pass, not yet wired anywhere: WP-121 is the reader only (COCO keypoints field into the Targets channel, no datamodule change), and no roadmap row currently owns constructing HorizontalFlip(keypoint_flip_pairs=...) for a pose task. That wiring lands whenever a pose task first reaches DetectionDataModule -- WP-122 is the head/decode module only, not datamodule wiring either, so this still names no row -- not stated as settled here. Resolved by WP-132, and very nearly missed. The row was first drafted here as still-deferred, on the reading that WP-132's overfit gate ran with augmentation off -- which was wrong, and wrong in the direction that hides: DetectionDataModule.__getitem__ applies self._flip unconditionally at _FLIP_PROB = 0.5, so the gate mirrors half its samples. HorizontalFlip was being constructed with no keypoint_flip_pairs at all, which mirrors coordinates without swapping identities: every flank_left in a mirrored sample was supervised toward the physical point flank_right now occupies. That is not a crash, a shape error, or anything a gate reports -- it trains, and it trains a left/right-confused head. It surfaced only because the affine's own hard error stopped the run first and forced a second reading of the whole train path. The pairing is therefore now dataset-supplied and threaded through the datamodule rather than left as a value nobody passes: the reader carries its annotation schema's own permutation, R21's 7-point SYMBOL_KEYPOINT_FLIP_IDX = (0, 1, 2, 4, 3, 6, 5) for the symbol family (flank_left/flank_right and base_left/base_right swap; center, apex, tail lie on the mirror axis and map to themselves), R12's 17-point list above for COCO human pose. A None pairing still means "mirror coordinates only" and is still correct for a K-point task with no left/right symmetry; what is no longer possible is a symmetric schema silently receiving it |
R12 (COCO keypoint category and point order); R21 (the symbol schema's own flip permutation) | tests/data/test_photometric.py::TestHorizontalFlip::test_keypoints_mirror_without_identity_swap, ::test_keypoints_mirror_and_swap_supplied_pairs, ::test_out_of_range_keypoint_pair_raises pin the mechanism; tests/ptl/test_keypoint_training.py pins that the datamodule actually passes the schema's pairing rather than defaulting it away -- the assertion whose absence let this sit unnoticed |
active |
| A65 | What guarantees the RLE per-axis uncertainty (sigma) stays positive between the keypoint head's raw output and the flow's density (R14's RealNVP-plus-Laplace-base formulation needs a strictly positive scale; nothing in R1 addresses a keypoint task at all -- R14's own docs/PROVENANCE.md role is "future pose milestone only" -- so no allowlisted source hands this project a value or a formula to carry over the way A20's angle range did from R1 Eq. 13) |
Resolved by WP-123, from R14 directly rather than assumed. R14 sec. 3.3 states its own answer verbatim: "the deviation sigma_hat_i is predicted with a sigmoid function. Hence we have sigma_hat_i in (0, 1)." RLELoss.forward (losses/rle_loss.py) applies torch.sigmoid to the head's raw sigma output at the point that consumes it, matching A23's precedent of normalizing a raw head output where it is used rather than where it is emitted; DualDetectionHead's keypoint stem (WP-122) and decode_keypoints still emit and pass through the value completely raw, exactly as this row originally deferred |
R14 sec. 3.3 (states the sigmoid explicitly) | tests/losses/test_rle_loss.py::TestSigmaRange -- sigma stays in the sigmoid's open range at both saturation extremes; tests/models/test_keypoint_head.py -- the head's own raw output is still unactivated, confirming the split held |
active |
| A66 | Which COCO keypoint visibility values (v) contribute to the RLE loss (R12 defines the flag's meaning -- v=0 no annotation, v=1 labeled but occluded, v=2 labeled and visible -- but states no training policy for which values a loss should read; R14 is a generic pose-regression method with no COCO-specific visibility handling of its own) |
v == 0 is excluded unconditionally -- its coordinate is not ground truth, and training against it would optimize toward a meaningless target. v == 1 and v == 2 both contribute equally: occlusion is exactly the case a regression model must learn to infer from context, not a reason to withhold supervision, and COCO's own OKS evaluation protocol (R12) already scores against v >= 1 regardless of visibility -- training against the same set the metric scores against is the consistent reading, not a narrower one this project invented |
R12 (visibility semantics); the training-time policy itself: gap | tests/losses/test_rle_loss.py::TestVisibilityMasking -- v=0 is excluded (a corrupted target changes nothing), v=1 and v=2 are proven equivalent, and an all-unlabeled batch returns a finite zero rather than NaN |
active |
| A67 | Which per-point OKS sigmas score a synthetic symbol keypoint run (R12's 17-value table is the only sigma vector any allowlisted source states, and it is derived from measured annotator standard deviations on human anatomy -- a quantity that does not exist for R21's SymbolShape landmarks, which are placed analytically at three-quarters of the way from each outline's area centroid to a named vertex, with no annotator and therefore no variance to measure) |
A uniform sigma across all K points, at R12's own median value 0.072 (SYMBOL_KEYPOINT_OKS_SIGMA). Uniform is not a simplification but the only honest reading: a per-point table encodes which landmarks humans localize less reliably, and for analytically-placed points that structure is absent, so any non-uniform vector this project wrote would fabricate it. The value is close to arbitrary by construction -- a uniform sigma is a monotone rescaling of OKS, so it moves every score together and changes no ordering, meaning it sets only where an acceptance floor sits and never which run wins. R12's median is chosen over a round number so the resulting figure stays on a scale comparable to a real COCO pose run, keeping the WP-132 gate floor interpretable rather than free-floating. Exposure: an OKS quoted for a symbol run is not comparable to a published COCO pose OKS despite sharing a scale -- different sigma vector, different point count, synthetic data (A26) What the uniform value costs is now measured rather than assumed: with the prediction set to the ground truth the gate's scorer returns exactly 1.0, and displacing every point of every instance uniformly walks it down +1 px -> 0.934, +3 px -> 0.269, +8 px -> 0.000. At 0.072 on symbols a few dozen pixels across, OKS AP is a cliff, which is why the kp gate's frozen figure (0.3357) is an order below its sibling gates' while describing a loop that memorized the slice just as they did. The rescaling is monotone, so this fixes only where the floor sits -- but it fixes it steeply, and a future sigma change moves the frozen value a long way. |
R12 (the 17-value table and its annotator-variance derivation); the uniform choice and the value: gap | scripts/_tests/test_overfit_micro.py -- the kp gate's frozen train OKS; a per-point table is never constructed, so drift here is a code change rather than a silent retune |
active |
| A68 | The gain the RLE keypoint term enters the detection total at (R14 states the loss and its flow, never a multi-task weight -- it trains a pose-only network; R1 addresses no keypoint task at all, so neither source hands this project the balance the way R1 Eq. 15 handed A22 its 0.25 angle gain) |
keypoint_gain = 1.0, carried as a DetectionLitModule constructor argument rather than a module constant, matching how angle_gain (A22) is plumbed for the oriented task. Unity is chosen as the deliberately un-tuned starting point: the WP-132 gate it first ships under is an overfit-100 loop-composition check, where the question is whether the RLE term reaches the optimizer and drives the points at all, not whether it is balanced against the box and class terms -- a tuned value picked against a 100-image memorization run would not transfer to a real pose run anyway, and would look like evidence while being none. Exposure: unity is untested at tier scale; the first run entitled to move it is WP-125's pose-smoke, where a keypoint term that swamps or is swamped by the detection objective would show as a mechanism failure rather than a tuning one. WP-125 ran: unity trained a real pose head in both the RLE and Laplace-NLL arms, each beating its own control on OKS rather than being swamped by or swamping the detection objective -- the mechanism-failure exposure this row named did not happen. The value's own tuned adequacy, as distinct from that narrower bar, was never this row's question and is still unmeasured -- no sweep ran, and unity stays the un-tuned starting point it was chosen as |
R14 (the loss, no weight); R1 Eq. 15 / A22 (the precedent for how a task gain is plumbed, not for the value); the value itself: gap | tests/ptl/test_keypoint_training.py -- keypoint_gain=0 leaves the detection total bit-exact, proving the term is genuinely additive and the gain genuinely consulted; WP-125 (RESEARCH_LOG.md#wp-125) is the tier-scale mechanism-failure check, not a tuning sweep |
open |
| A69 | Whether the one-to-many and one-to-two branches share a single RLE normalizing flow or train one each (R14 fits one density to one regressor's residuals and has no dual-branch architecture to speak to; R1's dual assignment predates any keypoint task, so neither source has a view on how a learned residual density should be split across two branches that are deliberately trained to disagree early and converge late) | One shared flow (DetectionLitModule.rle_loss, a single RLELoss called once per branch and blended by WP-035's alpha, the same arrangement the mask and oriented terms use). Two arguments, neither decisive alone: it is the smaller claim -- one density over one model's residuals is what R14 actually describes, where two would assert that the branches' error distributions are different in kind rather than merely in schedule -- and it is the smaller parameter count, the flow being the only trainable-weight loss in the project. It is called twice rather than once over concatenated positives so each branch's term keeps its own alpha share; that much is forced by the blend, not chosen. Exposure: untested against the alternative. If the two branches' residual distributions genuinely differ early in training, one flow is fit to a mixture and may serve neither well -- a failure that would show as a keypoint term that falls slowly while the box terms converge normally, not as an error. No measurement here distinguishes the two, and the overfit gate cannot: a 100-image memorization run drives both branches to the same near-zero residual, which is exactly the regime where one flow and two are least distinguishable |
R14 (one density, one regressor, no dual branch); R1 (dual assignment, no keypoint task); the split itself: gap | tests/ptl/test_keypoint_training.py -- pins that one flow is constructed and that both branches reach it; the shared-vs-split question is a WP-125 measurement, since only a tier-scale run has branches whose residuals differ enough to tell |
open |
| A70 | What becomes of a keypoint that a geometric transform warps outside the canvas, on an instance the transform keeps (R12 annotates only what a human could see and so never faces the question -- a point outside the photograph was simply never labelled; R14 regresses points in a cropped person box and states no cropping policy of its own. The case is manufactured by this project's own augmentation pipeline: the training warp translates by up to 10% and scales, so a point on a kept instance can land off-canvas while its box survives the keep mask. Named for what they were when this row was written, both mechanisms have since moved without the case moving with them -- _keep_mask became upstream's instance_keep_mask at WP-155, and FusedAffineLetterbox became RandomAffine(letterbox=...) over one fuse segment at WP-156) |
Carried through unchanged -- true coordinate preserved, visibility untouched, no clamping and no demotion. The two alternatives were rejected on what they teach rather than on cost. Clamping to the boundary invents a target: it supervises the model toward a location the anatomy is demonstrably not at, which is worse than absent supervision because it is confident and wrong. Demoting to v=0 discards a coordinate that is genuinely known and overloads a flag A66 defines as no annotation exists with a second, incompatible meaning, existed and left the frame -- after which no consumer can tell the two apart, including the OKS scorer. Carrying through is also the reading A66's own argument already implies: it admits v=1 because "occlusion is exactly the case a regression model must learn to infer from context", and a point pushed past the edge by an affine is that same case with the occluder being the crop. Nothing in the head or the loss objects -- decode_keypoints adds stride-scaled offsets to an anchor centre and is unbounded by construction, so an off-canvas target is representable, not a special case. Exposure: a run whose augmentation pushes points off-canvas often is training partly on evidence the image does not contain, and this project has no measurement of where that stops helping; the overfit gate cannot supply one, since memorization succeeds regardless |
A66 (the visibility semantics this refuses to overload, and whose occlusion argument this extends); R12 and R14: gap on this case | tests/data/test_affine.py, tests/data/test_mosaic.py -- a point warped past the boundary keeps its coordinate and its visibility, and is neither clamped to the edge nor zeroed |
open |
| A71 | The coordinate frame the RLE residual is formed in (R14 states sigma_hat is "predicted with a sigmoid function. Hence we have sigma_hat_i in (0, 1)" and forms the residual as x_bar = (mu_g - mu_hat) / sigma_hat, but never names the frame mu lives in -- it regresses inside a top-down person crop resized to a fixed input, so the crop is the normalization and the paper has no occasion to state one. A dense one-stage detector has no crop: decode_keypoints returns absolute input pixels) |
The assigned ground-truth box's frame, per axis -- x by the box width, y by its height, via normalize_keypoints_to_box, applied to prediction and target alike immediately before RLELoss and nowhere else. The paper's own sigmoid bound forces this: a scale bounded above by 1 can only standardize a residual whose errors are O(1), so in pixels the smallest residual expressible for a 40 px error is 40, and the flow's Gaussian base is quadratic in it. Measured, not argued: un-normalized, the keypoint term entered at 1300 of a 1342 total loss and the run went non-finite on the second step; normalized, the median residual is 1.28. Per-axis rather than OKS's single sqrt(area) scalar because per-axis is what resizing a crop to a fixed input actually does -- the two agree on a square object and differ only in how an elongated one distributes tolerance. The box origin is subtracted as well as the extent divided, though the loss reads the two points only through their difference and the translation cancels: the values then mean what the name says. A degenerate box is floored at one pixel per axis, since a sub-pixel box carries no pose to normalize by and the unclamped division answers a finite annotation with an infinity. Exposure: this frame ties point supervision to box quality, so an instance whose box is badly wrong normalizes its points by the wrong scale -- invisible in an overfit run, where the boxes are memorized too |
R14 sec. 3.2 (the residual), sec. 3.3 (the sigmoid bound that forces a normalized frame); the frame itself: gap -- R14 does not state one; A67 (OKS's own object-scale normalization, the nearest stated precedent) | tests/ptl/test_keypoint_training.py -- corner-to-corner mapping, per-axis extents, the one-pixel floor, and that the residual the loss sees is O(1) on a real batch rather than O(10^3) |
active |
| A72 | Whether the RealNVP coupling layer's log-scale is emitted raw or bounded (R14 specifies the conditioner -- "Lfc=3 and Nn=64 by default... Each fully-connected layer is followed by a Leaky-RELU" -- and cites RealNVP for the coupling construction itself rather than restating it, so the parameterization of s is stated only in Dinh et al.) |
Bounded: a tanh times a per-layer learned scale, which is RealNVP's own parameterization and is given there as a stability measure -- "To compute the scaling functions s, we use a hyperbolic tangent function multiplied by a learned scale" (Dinh et al. sec. 4). WP-123 emitted the conditioner's raw linear output, which is not a simplification of that but a different layer, and the difference is invisible until the loss is composed into a training run: the stack multiplies exp(log_scale) six times, so an unbounded log-scale compounds. Measured: a residual of 27 reached the latent as 4e11 and the loss as 7e22, and the run went non-finite -- while the loss's own unit tests, which evaluate it at O(1) residuals, stayed green throughout. Only the log-scale is bounded; the shift is additive, compounds linearly, and is not what the paper stabilizes. Exposure: the learned scale is itself unbounded, so the runaway is damped rather than provably impossible -- what the tanh removes is the compounding feedback path, since a saturated tanh passes no gradient back to the conditioner. Residual spikes into the thousands still occur on this gate's data and are not understood to be harmless; they are the A70 off-canvas tail meeting a quadratic base density |
Dinh et al., Density estimation using Real NVP (arXiv:1605.08803) sec. 4, the source R14 Appendix A Eq. 12 defers to; R14: gap (does not restate the parameterization) | tests/losses/test_rle_loss.py -- the log-scale stays inside the learned bound, and a residual an order of magnitude outside the training range still yields a finite loss and finite gradients |
active |
| A73 | Whether det/seg's torchmetrics-backed eval and keypoints' hand-driven faster_coco_eval eval should be forced onto one code path (no paper states an evaluation architecture; torchmetrics 1.9's detection module carries zero keypoint/OKS support -- confirmed by grep, no "keypoint"/"kpt"/"oks" symbol anywhere) |
Keep keypoints hand-driven behind evaluate_keypoints; do not fork torchmetrics' CocoBackend/MeanAveragePrecision internals to add a keypoints iou_type (that fork is WP B, deferred, not attempted). WP-137: val/oks_mAP runs every validation epoch beside val/mAP, the same "beside, not instead of" relationship val/segm_mAP already has, closing the actual gap -- keypoints previously logged no per-epoch pose signal at all. Revised 2026-08-25 by WP-138: WP-137's own text here claimed a hand-driven hotcoco path "would cost back the memory-bounded streaming _StreamingScorer exists for" -- checked against torchmetrics' actual _get_safe_item_values source and found wrong: torchmetrics RLE-encodes every mask immediately inside update() and discards the dense tensor per batch, and nothing stops a hand-driven adapter doing the identical thing. WP-138 does exactly that: hotcoco is adopted as lucid-eval's default bbox/segm engine (DualPathEvaluator(backend=...), --eval_backend auto\|hotcoco\|faster_coco_eval), a hand-driven _HotcocoStreamingScorer RLE-encoding per batch same as torchmetrics does, verified bit-for-bit (abs=1e-6) against faster_coco_eval on bbox-only, bbox+segm and the empty-preds case. Five real hotcoco compatibility traps found and fixed, matching rf-detr PR 1402's own documented list: params/dataset are copy-on-read (get-mutate-reassign, never in-place); RLE counts comes back bytes, hotcoco's constructor silently reads it as an empty mask unless decoded to str first (verified directly: a 9-pixel mask round-tripped through this project's installed hotcoco as 0 pixels, no exception); mask.encode accepts uint8 only, not bool; iou_type is spelled snake_case; hotcoco writes stdout/stderr from Rust, which contextlib.redirect_stdout does not reach (verified directly) -- fd-level os.dup2 redirection required. Keypoints' OKS scoring and module.py's training-loop val/mAP/val/segm_mAP are untouched either way -- the torchmetrics-subclass fork this row still declines stays declined for both |
roboflow/rf-detr PR #1183 (keypoint_oks.py's MetricKeypointOKS, the identical hand-driven-behind-a-torchmetrics-shaped-interface choice for keypoints, citing upstream torchmetrics PR #3348 as the eventual real fix) and PR #1402 (_HotCocoBackend(CocoBackend), the working torchmetrics-subclass hotcoco integration this project's own WP B would follow if attempted -- 4.2-4.6x measured through the real compute() pathway, "not a training-throughput change... under 2% of an epoch" by their own stated verdict, corroborating this project's independent WP-137 benchmark); this project's own WP-137/WP-138 benchmarks (bbox 13x, segm ~127-180x in isolation at COCO-val scale -- ~1 minute saved over a multi-hour training run either way, the reason module.py's own per-epoch metrics stay on faster_coco_eval) |
tests/ptl/test_keypoint_training.py::TestKeypointValidation; tests/eval/test_coco_eval.py::TestHotcocoParity; revisit the torchmetrics-subclass fork (WP B) if module.py's own segm training-loop wall-clock is ever measured and found to matter, or when torchmetrics PR #3348 (native keypoint support) lands |
active |
| A74 | Whether R14 sec. 4's "Each fully-connected layer is followed by a Leaky-RELU" covers the conditioner's output projection as well as its hidden layers (the paper's figure and appendix draw no distinction, so the sentence admits both readings) | Read it literally: the output projection carries the trailing LeakyReLU, as it has since WP-125 | R14 sec. 4 | The pose smoke recipe trained twice at one seed, with and without the trailing activation, compared on WP-125's acceptance metric. Not run -- needs an accelerator | active |
π Deviation notes¶
- A26 is a deliberate deviation from blueprint section 7 (fixtures were specified as "16 permissively-licensed hand-annotated images"). Decision D12 (DECISIONS.md) records the rationale: synthetic scenes are fully redistributable, deterministic, and carry exact ground truth for boxes, polygons, and rotated boxes from one generator, removing annotation noise from unit gates. The dataset-contract rule "missing data blocks the WP, not worked around with synthetic substitutes" still applies to tier acceptance runs β A26 covers fixtures and offline development stand-ins only.