Skip to content

Merge dev into stage - #5

Open
PSheon wants to merge 317 commits into
stagefrom
dev
Open

Merge dev into stage#5
PSheon wants to merge 317 commits into
stagefrom
dev

Conversation

@PSheon

@PSheon PSheon commented Aug 14, 2026

Copy link
Copy Markdown
Member

Summary

Related issue

Closes #

Changes

Checklist

  • Linked a related issue above, or explained why this PR doesn't need one
  • CI is green (lint / typecheck / tests as applicable)
  • Added or updated tests where it makes sense
  • Updated docs / config / env examples if behavior changed

Notes

PSheon and others added 29 commits August 17, 2026 13:42
`data.datasets[].classes` and `--detection-classes` both take a list of COCO names and do
opposite things. The config key narrows what the head *learns* and changes the output
space; the export flag narrows what the engine *emits* and leaves `num_classes` and the
trained weights alone.

The config key is the one someone reading a config finds first, and using it where the
export flag was meant renumbers every label under an existing checkpoint -- the run
completes, the loss falls, and each box comes back a confident wrong class. It also throws
away the COCO supervision the shared trunk gets for free, which is the trade RETAIL_SCOPE.md
§4 exists to argue against.

Raised by another session reading the change rather than found here, which is the useful
part: the feature is new and the footgun is old, so nobody working on the feature was
going to trip over it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`sam3_prelabel.py` names one session directory per input, after the input's
filename stem. `gs://studioa-recording` names a clip by its recording
timestamp, so two cameras that start recording in the same second produce the
same stem -- 4 of 96 clips in one real pull did. Both wrote into one directory,
the second replaced the first, and the run finished printing a plausible
per-class composition over whichever frames survived: 45 directories where 48
were asked for, 270 frames of 288, and a COCO file whose boxes referenced
frames from the wrong camera.

Nothing errored, which is the point. It was caught only because a class share
moved 33.33% -> 2.49% between a partial and a full pass, which is impossible
for an average over one more clip, and it came one step from putting a
contaminated camera into an approved site test split.

Widening the key would have made that collision go away without making the
name an identity, so two cameras could still collide under it and the failure
would stay silent. `session_names` asserts uniqueness instead and refuses the
run, naming the colliding input paths rather than the derived key -- the key is
what the script computed, the paths are what the operator has to go look at.
The remedy branches on the cause: distinct paths need names that differ, a
doubled glob needs de-duplicating, and advising symlinks for the second sends
someone in a circle.

`validate_inputs` covers the neighbouring defect found while smoke-testing the
first. A path that does not exist reached `probe()`, whose ffprobe call raises
uncaught, so one typo in a 48-clip batch produced a raw traceback, dropped
every later clip, and did so after the session directory was created and the
model load already paid for. The file looks like it handles this -- there is a
`no frames decoded, skipped` branch nine lines below -- but that branch is
unreachable for a missing file and reachable only for a directory input, so
the two input kinds degrade completely differently and reading top to bottom
suggests otherwise.

Both run over the whole input list before a single output handle opens, and
ahead of the model load: detecting either on the way past would still leave
the half-written dataset they exist to prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`validate_inputs` refuses a path that is not there, but existence cannot tell you a
real input will decode to zero frames. Both in-loop skips -- `--consensus needs 2+
frames` and `no frames decoded` -- ran after the two `mkdir` calls, so a skipped clip
left `images/<split>/<session>/` and `annotations/<split>/<session>/` behind, empty.
An empty session directory is indistinguishable from a camera that legitimately
yielded nothing, which is the ambiguity the refusal machinery exists to remove.

Moving the two calls below the skips fixes both paths at once. Verified against the
previous commit rather than asserted: run the all-skip case against
`ed0b65d:scripts/sam3_prelabel.py` and both directories are created; against this one,
neither is.

That exposed a second thing, found by running the all-skip case rather than by reading
it. `root` was being created as a side effect of the first session's `mkdir`, so a run
in which every clip skips now reaches `(root / "sam3_batch.json").write_text(...)` with
no `root`, and raises FileNotFoundError. `root` is created explicitly there instead,
and `out_json.parent` before the COCO write.

Creating `root` at the end rather than up front looks like it contradicts the change
and does not: the manifest is wanted output in that case, because it carries
`found_nothing`, which is exactly what an operator needs after a run that produced no
data. A dataset root holding a manifest that says so is an answer; an empty session
directory is an ambiguity. Different objects, and only one of them should be
conditional.

Also pins `hydranet_retail_objects_nc2.yaml` in KNOWN_UNSOURCED. It inherits
`hydranet_retail_objects.yaml` and inherits its empty `product` channel with it, so the
shipped-config test went red on it -- which is that test working, not failing. Listed
per config rather than pattern-matched on the parent, because a derived config is free
to add a dataset that fills the channel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DEPLOY_JETSON.md §4 has listed `num_convs: 2` as latency lever 4 since it was written,
with nothing measured behind it. Two things arrived today that make it worth settling.

A GB10 layer profile puts the detection head at half the graph -- 2.31 ms full against
1.16 ms terrain-only -- far above the 19.5% a profiler attributes to it by name, because
the GroupNorm and reshape fusions between the tower convolutions are filed under "other".
And the head's arithmetic at 512x640 over 6,820 positions is 5,026 MMAC, of which the two
4-conv towers are 2,263 each: 90% of the head. Halving them is worth ~45% of it, against
the 8.4% the export-time class narrowing takes off `cls_pred`.

The config inherits hydranet_retail_objects.yaml and changes one field. Verified by
diffing the two *resolved* configs rather than the two files: three differences, of which
two are the experiment name and the output directory.

What it can answer is what accuracy the saving costs, on COCO val2017, which is the
instrument that matches a detection-depth question. What it cannot answer is latency --
MMAC is not milliseconds -- and it cannot answer anything about a shop: its parent scored
terrain mIoU 0.7668 while predicting `column` 0.00% and `product` 0.00% on the footage it
serves. The file says all three out loud, because a run's own config is where someone
reading its numbers a month later will look first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on a GB10 at 512x640, single thread, real decode:

    infer     2.09 ms  31.2%
    d2h       0.37 ms   5.5%
    terrain   2.53 ms  37.8%   <- host argmax over 7x512x640
    detect    1.71 ms  25.5%
    total     6.70 ms  -> 149 fps

The host argmax was the largest single item in the frame, larger than the engine, and it
was on nobody's lever list. DEPLOY_JETSON.md §4 offers four ways to make the engine
smaller and the engine is 31% of the problem. Folding the argmax in measures 4.00 ms and
250 fps -- a 40% frame reduction from an export flag, with no retraining and not one
weight changed. Segmentation D2H falls from 9.18 MB of float logits to 0.33 MB of uint8
class ids, confirmed here at the retail_objects taxonomy.

It costs the logits, so anything wanting a confidence, a soft blend or a per-class
probability has to export without it. That is why it is a flag rather than the default.

The argmax is taken after the head's own bilinear upsample, not before. Taking it at P3
and resizing the class map with nearest is cheaper and is a different answer at every
boundary; the test asserts the folded result equals the host argmax exactly, not
approximately, because "a class map comes out" is not the property that matters.

The bindings are renamed `<head>_argmax`, for the same reason `image_rgb_255` and
`det_cls8` are named what they are. A host that keeps calling `.argmax(0)` on what it
believes are `[C, H, W]` logits will call it on an `[H, W]` uint8 map and get a
`[W]`-shaped array of nonsense -- sometimes a crash, sometimes a picture. A missing
binding is neither. `live_view_orin.py` reads whichever form the engine has.

`check_parity` now compares integer outputs by disagreeing-pixel share rather than
relative error. Relative error on label ids is meaningless -- ids 2 and 3 are not "1
apart" in any sense the float tolerance was set for -- and it would have passed a broken
argmax while failing a correct one on a map of small ids.

The GB10 numbers are another session's measurement, not this one's; there is no board
here. What is verified locally is the D2H figure, the exact-agreement property and the
composition with --detection-classes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RETAIL_OBJECTS_SPLIT.md opens with a run that scored terrain mIoU 0.7668 on ADE20K and
predicted `column` 0.00% and `product` 0.00% on the footage it is meant to serve. Both
numbers are true. Every labelled split this project has is the first kind, and one of the
second kind needs human-corrected masks (R3) that do not exist yet -- while every
architecture question queued behind it, `num_convs` included, wants to be judged on the
shop.

The camera does not move. So a pixel can be asked the same question N times and the
spread of the answers is an error signal that costs no labels. RETAIL_OBJECTS.md's audit
used it as the `agree` column and RETAIL_SCOPE.md §5 used it to count a false `caution` on
a structural column through 1,782 of 1,830 frames -- where four frames sampled by eye had
suggested the opposite conclusion. Both were one-off scripts; `count_false_caution.py`
hardcodes one ROI and three mp4 paths and reads rendered video rather than predictions.

**It is a necessary condition, not a quality metric.** A model that is confidently and
identically wrong on every frame scores 1.0. `test_a_model_that_is_always_wrong_scores_
perfectly` asserts exactly that, so the property is written down somewhere executable, and
`CAVEAT` is emitted into every report's JSON rather than left in a docstring -- a number
outlives the conversation that produced it, which is the same reason `metrics.jsonl`
carries per-class support.

Three parts of the design are another session's, who measured this shape over SAM 3's
masks first and whose version is about label stability where this one is about model
stability. Excluded classes are enforced rather than documented: excluding a class the
taxonomy does not have raises, because a silently-ignored exclusion leaves people in the
vote and turns the metric into a people-counter. The denominator travels in the JSON,
since eligible pixels are not all pixels. And `unstable_composition` -- where the
instability sits, per class -- is a first-class output beside the scalar, because their
run put 71.9% of it on `fixture` and a mean would have hidden that.

A camera with no eligible pixels left returns NaN rather than 0.0. That came from a test
fixture of mine that was wrong, not from foresight: 0.0 reads as "completely unstable",
the opposite end of the scale from "nothing to measure", and a fleet mean over six
cameras would take the zero and report a finding. NaN refuses to be averaged.

The CLI that drives this over a directory of clips is not here yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st camera

A camera with no eligible pixels reports NaN rather than 0.0, and the previous commit
argued that this "refuses to be averaged". That is true of `np.mean` and false of
`np.nanmean` -- and `np.nanmean` is this codebase's established idiom for exactly this
shape. `ConfusionMatrix.mean_iou` uses it and its docstring argues at length that
dropping the NaN entries is the only defensible choice there.

So the next person aggregating six cameras follows the local precedent, reaches for
`nanmean`, and reports a mean over five as a mean over six. The camera that drops out is
the one with a crowd across the frame: the busiest one, and the one most worth knowing
about. The trap is that `nanmean` looks *more* careful than `mean`, not less.

`fleet_summary` therefore ships here rather than being written by whoever needs it next,
and it carries `cameras` beside `contributing` and names what did not contribute -- the
same device as `terrain_mIoU` beside `terrain_mIoU_classes`, which exists so the two
cannot be compared without noticing the denominator moved. A mean of five labelled six is
not expressible through it.

`mean_stable_share` is None rather than NaN when nothing contributed, because NaN is not
valid JSON and a reader who sees null asks why.

Raised in review by another session, against a claim in the commit one before this. The
NaN was half a defence; the count is the other half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-seg

GB10, TRT 10.16, 512x640, single thread, real decode, median of three:

    build                    infer   d2h  terrain  detect  TOTAL     fps
    shipped                   2.09  0.38     2.53    1.70   6.69  144-150
    --argmax-seg              2.71  0.23     0       1.71   4.00  203-250
    + --detection-classes     2.71  0.17     0       0.64   3.29  284-304
    + CUDA graph              1.49  0.15     0       0.62   2.25  381-444

2.6x on the frame, nothing retrained, no weight changed. The class narrowing's predicted
2.5x on the sigmoid landed at 2.7x measured, detect 1.73 -> 0.64 ms.

**The `infer` column is why this is in the flag's own help text and not only here.**
Folding the argmax in makes the engine *slower*, 2.09 -> 2.71 ms: the work did not vanish,
it moved onto the GPU where it is cheap. Anyone benchmarking with trtexec alone sees a 25%
regression and reverts the flag -- correctly by their measurement and wrongly by two
milliseconds a frame. A caveat that lives only in a document is a caveat the person
running trtexec has not read, so it goes where they will be.

Also recorded: INT8 measured *slower* than FP16 on that board, 2.48 against 2.31 ms, while
this file recommends it as the step after FP16. Narrowing the neck to 64 was slower too,
and num_repeats 1 and regnet_x_400mf were both inside noise. One cause for all four --
`--useCudaGraph` alone takes 2.61 -> 1.95 ms, so the graph is launch-bound rather than
compute-bound, and shrinking arithmetic buys nothing against 416 kernel launches over
tensors as small as 4x5. Not re-measured on the Orin rig this file documents, and said so;
INT8 is the first thing anyone reaches for and the calibration set is not free to build.

All of the above is another session's measurement on hardware this one has no access to.
What is verified here is the ONNX-vs-PyTorch exactness of the fold and the D2H figure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last lever on the list that needs no export change, no retraining and no new weights.
This graph is launch-bound rather than compute-bound -- 416 kernel launches over tensors
as small as 4x5 -- and on a GB10 `--useCudaGraph` alone took trtexec from 2.61 to 1.95 ms.
The runtime pays the same per-frame launch cost and had no way to avoid it; capturing the
H2D -> enqueue -> D2H sequence once and replaying it is worth 0.6-1.0 ms a frame there.

**Opt-in, and it steps over its own failure.** Capture depends on the driver, the
TensorRT version and what else is touching the context, and this file runs on boards that
cannot be tested from here. A live viewer that refuses to start is a worse outcome than
one running at yesterday's speed, so a capture that does not take is printed and the eager
path runs instead. The eager path is unchanged and remains the default.

Two things the capture requires that are easy to get wrong and are handled here: TensorRT
needs one real execution before it can be captured, and the input buffer is `np.empty` at
that point with no frame yet arrived -- so it is zeroed first rather than warmed up on
whatever the allocator returned, which can be inf and would propagate through BatchNorm.

**Not verified on hardware.** There is no board on this machine; the ctypes bindings are
written from the CUDA 12 signatures and the fallback exists precisely because that is not
the same as having run them. Whoever has a board should confirm the "cuda graph: captured"
line appears and the frame rate moves before this is trusted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Cudart.__init__` loaded `libcudart.so.12` by name. A GB10 carries only `.so.13`, so the
constructor raised `OSError` at the top of both scripts' main loops and **neither
`bench_camera_orin.py` nor `live_view_orin.py` ran at all** on that board. The error named
a missing file, not a CUDA version, and there was no degraded mode to fall back to.

Worth being precise about the shape, because it is instructive. `b60f79e` added a
`try/except (RuntimeError, OSError, AttributeError)` around the CUDA graph capture, on the
argument that an untestable path should step over its own failure rather than take the
viewer down. That reasoning was right and the guard was in the wrong place: the far more
likely failure is binding the library at all, which happens before it. **A fallback around
the interesting code is not a fallback around the code that actually breaks.**

Sonames are now tried newest-first with the unversioned name last -- it usually exists
only where the CUDA development package is installed, which a deployment board need not
have -- and the bound name is printed, because a board that binds a different major than
TensorRT was built against fails later and less clearly. Failing all of them names every
attempt and says which constant to edit.

Found by another session running `b60f79e` on real hardware, which is also where the rest
of that commit was confirmed: capture succeeds, 0.87 ms/frame saved (30%), and four
distinct inputs give four distinct masks through the *replayed* graph at 99.81% agreement
with PyTorch -- the same figure as the eager path. That last check is better than the one
this session asked for. A capture that replays baked-in device memory would produce a
still image at an excellent frame rate, and neither "it did not crash" nor "it captured"
would catch it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every absolute in the frame table came from a shared GPU. The session that measured them
put the same baseline engine at 2.09 and 2.31 ms for `infer` on different runs and asked
that the variance be stated; that session has since ended, so recording it falls here.

The two decimal places are the measurement's format rather than its precision, and a table
that prints 6.69 and 3.29 without a variance invites someone to reproduce 6.71 and call it
a regression. The ratios held across three repeats and are what the table is for.

Also records what the CUDA graph replay was actually checked against, because it is the
part that would otherwise be lost with that session: four different real frames through
the replayed graph give four different masks, each matching its own PyTorch reference at
the same 99.81% the eager path gets. The failure that check exists for is a capture that
succeeds and replays baked-in device memory -- a still image at an excellent frame rate,
which neither "it started" nor "it captured" would catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on was wrong

RETAIL_OBJECTS.md listed columns and products as outstanding, at the exact path
the site pre-labels now occupy. Both landed on 2026-08-17, so the section that
proposed them records what they produced instead.

`product` is 19.28% of labelled pixels in train and 17.23% in test across 24
shop-floor cameras, with 10,524 merchandise instance boxes. That result also
retires a figure that would otherwise have talked someone out of the material
that works: the earlier `product box` measurement of 0 instances on accessory
walls was a property of 352x240 footage and one prompt out of eight, not of the
footage. At 1920x1080 the vocabulary segments each hanging packet.

`column` is the opposite result and the more useful one. It is sourced, it
scores val IoU 0.40-0.51, and it predicts 0.00% on every daytime site clip
measured -- a clad pillar centre-of-frame comes back as `wall`. The document
already contained the explanation, in a config comment written before the run,
with nothing executing it.

The correction is to the predicted failure mode of an unsourced class. This
file said it would report IoU 0.000 after sixty epochs with no error anywhere.
It reported nothing at all: no `IoU/terrain/05_product` key at any epoch and
`terrain_mIoU_classes` 5.0 on all 60 rows, because a class absent from the
ground truth never enters the confusion matrix. A 0.000 is visible and looks
wrong; an absent row looks like a taxonomy with five classes.

`--consensus 0.9` is dropped from the documented command, with the measurement
that decides it: only 31.1% of static pixels are stable at that threshold on
the six test cameras, and the discarded remainder is class boundaries -- 71.9%
of it `fixture` -- which is what an IoU compares.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ntrol

`utils/temporal.py` has never been committed. `git log --all` for it is empty
except a backup ref, it exists only as an untracked file plus a copy inside
stash@{0}, and it changes model output: `cli/scene.py --stabilise` runs every
prediction through it. Its failure mode is a *cleaner-looking* panel rather
than a crash, which is the failure this project treats as worst.

`tests/test_temporal.py` is 13 tests that pass and that no CI job has ever run,
because CI checks out the repository and the file was not in it. A test that
runs only when someone remembers to is not protecting anything, and this is the
module where that matters most: coverage was 17% before these were written.

Attribution, since a shared checkout makes this easy to get wrong and it has
been got wrong three times today. `utils/temporal.py` predates every session
that has been live today -- it is in stash@{0}, created 09:05:20 before the
morning's rebase -- and its author is not determinable from this tree.
`tests/test_temporal.py` is not in that stash and was written today by another
session, recorded in docs/journal/2026-08-17-session-board.md. Neither is mine.
I am putting them under version control, not adopting them.

Committed unchanged. The module has two known defects, both pinned rather than
fixed: the background plate is seeded from the first frame, so an occupant
present at construction becomes background and is voted over like floor; and
because the plate updates only where the scene is already static, a region it
is wrong about is permanently non-static and the plate never recovers, which
degrades the filter to a no-op over precisely the floor it was built to settle.
The tests name those limits and say a fix should turn the test around rather
than delete it. Fixing them inside a hygiene commit would bury a behaviour
change where nobody would review it.

`ruff check`, `ruff format --check` and `ty check` are clean on both, so the
type ratchet stays where it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ass lint

`scripts/pull_studioa.py` produced every clip in datasets/studioa_clips and
therefore every frame of the retail_objects pre-label batch, and it has never
been committed. It also carried two E501s, so the moment anyone did commit it
CI would have gone red on a file they had not written.

Two lines wrapped, nothing else touched. The UTC/local conversion, the
one-clip-per-camera-per-target sampling and the manifest format are unchanged.

Not mine; it predates my session and its author is not determinable from this
tree. Committed so it stops being an untracked dependency of a dataset that is
already being used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ne had to

The architecture did not change today -- backbone, neck and heads are byte-identical and
`nc2` is a control run, not a change. The *export contract* and the deployment path did,
and four places asserted the old one as fact:

  cli/export_onnx.py:7   "NMS and argmax stay in the host post-processing code"
  METHODOLOGY.md:77      post-processing owns argmax
  README.md              "configs/ holds three more"; coco_subsets.py as names + INDOOR_25
  RETAIL_SCOPE.md §7.4   "narrow the export... expect most of the 16.33 ms to go away"

The last one is the interesting one, because it was a prediction and two thirds of it were
wrong. Narrowing held: 2.7x measured on the detection decode against 2.5x predicted. But
**eight classes is the wrong list for retail analytics** -- it deletes `book`, the 1,683-
instance merchandise signal RETAIL_OBJECTS.md's audit is about -- and **the post-processing
was not mostly the sigmoid**. Split, the 43% bucket came apart into a detection decode and
a host argmax over the segmentation logits, and the argmax was larger, and larger than the
engine. That is recorded as a correction in place rather than a rewrite, because the
reusable part is that a 43% bucket labelled "post-processing" and never divided is not a
finding, it is a place to look.

ORIN_BRINGUP.md's 37.77 ms table is annotated, not restated. Different silicon and TRT
10.16 against that rig's 10.3, so the numbers do not transfer; what transfers is to split
`postprocess` before drawing a conclusion from its share again. Two boards deserve two
tables, and flattening them into one is exactly the mistake the annotation warns about.

README gains the two export flags with the caveat that matters at the point of use --
`--argmax-seg` makes the engine slower and is a whole-frame win invisible to trtexec -- and
`engine/consensus.py` in the layout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`hydranet_retail_objects.yaml` trains on ADE20K plus COCO. Neither contains a retail
`product` -- ADE20K has zero pixels of it -- so that run's `product` channel is trained on
nothing and reports IoU 0.000, which the config says out loud before it starts and which
RETAIL_OBJECTS_SPLIT.md opens with.

`datasets/retail_objects_batch01` answers that and **has been on disk, referenced by no
config**, while the case for annotating was restated. SAM 3 pre-labelled 36 training clips
and 12 held-out ones from the store cameras. Counted over the masks rather than assumed:

    class      train px       share   in masks      test px      share
    column     13,986,777      7.14%   115/216     4,102,425      8.03%
    fixture   119,911,138     61.18%   216/216    31,977,294     62.61%
    product    37,789,475     19.28%   216/216     8,800,158     17.23%
    person     24,323,474     12.41%   201/216     6,195,022     12.13%

`product` is in every mask. `column` -- the class the taxonomy exists to split out of
`wall` -- is in 115 of 216, against ADE20K's 22 of 285 at 0.66% of pixels, which is the
evidence base R7 was written about. Verified through the loader too: after transforms,
`product` arrives at 20.1% of labelled pixels in the training target.

The repository's own gate is the cheapest confirmation the run is different in kind:

    hydranet_retail_objects.yaml       unsourced: {'ade20k_retail_objects': ('product',)}
    hydranet_retail_objects_site.yaml  unsourced: none

ADE20K stays. batch01 has **zero** `floor` and `wall` pixels and 56% ignore -- SAM 3 was
asked for four hard classes and answered those -- so a run on it alone would train two
empty channels, the same failure moved to different classes. The two are complementary and
partial supervision is the mechanism already there for it.

Three limits, in the config rather than in a commit nobody re-reads. batch01's held-out
clips are wired in as **val, not test**: they are SAM 3's opinion, and R3 exists because a
test set drawn by the tool that drew the training set measures reproduction rather than
correctness -- so this run has no valid site test split and human-corrected masks remain
the missing piece. `primary_metric` is `IoU/terrain/05_product`, which is the right target
for this run and a narrow basis for a checkpoint, since it selects for agreement with SAM 3
on merchandise over 12 clips. And R1 is only partly checkable: 32 of 36 training clips and
10 of 12 val clips are named `archive_<timestamp>` with no camera, so their disjointness
cannot be read off the filenames -- the first thing to fix in the batch02 export, because a
split that cannot be checked is one someone will assume held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… hole

The deletion of assets/retail_cctv_clip3_{ab,column}.png has been staged in this
tree's index since before any of today's sessions started, owner unknown. Four
sessions committed around it with pathspec commits all day so as not to sweep it into
their own work. None of us asked what the two files were referenced by.

They were referenced by this document: an image embed at :258 and a close-up link at
:261, in the section whose argument is that the baseline paints `caution` on a fixed
structural column. A bare `git commit` by anyone would have published RETAIL_SCOPE.md
with a broken image. We were careful about the mechanism and never checked the
consequence, because unowned was quietly read as harmless.

Accepting the deletion rather than restoring the files, and rewriting the two
sentences that needed a figure to be visible. The count over all 1830 frames is left
exactly as it was, with a note that a figure stood above it until today and went with
the `cmp_clip3_*.mp4` renders it was cut from.

Removal improves this particular section, which is why accept rather than restore. The
figure was four frames chosen by eye, and the paragraph immediately below it exists to
say that four frames chosen by eye suggested the opposite of the full count. The table
is the record and always was.

The check none of us ran, which is one command and finds this class of defect across
the repository:

  git ls-files '*.md' '*.py' '*.yaml' \
    | xargs grep -ohE '[^ (`"]*assets/[A-Za-z0-9_.-]+\.(png|jpg|jpeg|gif|mp4|svg)' \
    | sed 's|.*assets/|assets/|' | sort -u \
    | while read -r a; do [ -e "$a" ] || echo "MISSING $a"; done

After this commit every image reference in every tracked file resolves. The remaining
misses are all .mp4 and all benign: assets/clip.mp4 and assets/clip_bev.mp4 are
placeholder paths in usage examples, and the rest are gitignored renders that were
never resolvable by anyone but the machine that made them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`bev_page.py` draws objects as wireframe cuboids and argues that a solid asset asserts a
shape nobody measured. That argument is about *shape*, and it is right. It is not an
argument for a crude mesh, which is how it was being read.

The thing actually uncertain in a retail scene is **position**. `analytics/dwell.py`
records the mechanism: a shopper behind a counter has their feet occluded, the box bottom
lands on the counter edge, and the projected position is metres out. Crudeness is a bad
way to express that -- it degrades the whole picture to hint at an error that lives in one
place, and nobody reads metres off a capsule any better than off a person.

So the figure is a person, and the uncertainty gets its own channel: `ground_disc` draws
the position's error radius on the floor, in the same units as everything else on it.
Honest and legible, instead of one bought with the other.

Generated rather than downloaded, which is what makes them usable at all here: a mesh file
is a binary blob with a licence to track and no way to ask it for a 1.62 m person. These
are functions of their dimensions, and dimensions are the output.

`extrude(footprint, height)` is the primitive the rest is built on, because it is the
shape the perception produces -- connected components over the `fixture` or `column` cells
of a BEV give a polygon per object. Footprint measured, height not, kept as separate
arguments so a renderer cannot confuse them. Ear clipping rather than a triangle fan: an
end-capped gondola run is L-shaped and a shelf bay is U-shaped, and a fan fills the notch
with a solid where the aisle is.

**A bug the tests found and the reason they are worth reading.** The 7.5-head canon puts
the crown at 1.013 of standing height, so `human(1.70)` returned a 1.722 m figure --
plausible, invisible, and wrong in the one quantity this module exists to be trusted on.
Normalising the built mesh fixes it and stays correct if a landmark is ever revised.

`scripts/mesh_preview.py` renders the asset rather than the asset being checked in alone;
an image in `assets/` with no generator is a thing this repository keeps finding and
cannot re-derive. The look is not the meshes -- it is smooth normals, three lights, depth
fade and a contact shadow, and the script says so and states its own limit: painter's
algorithm, no z-buffer, so anything that must be correct rather than illustrative goes to
a real renderer via `to_obj`.

Also guards `conv.bias` in `narrow_detection_head`, flagged by another session's ty run.
FCOSHead stores the focal-loss prior in that bias, so a biasless classifier is not a
configuration this project has -- copying weights and dropping the prior would shift every
score by ~4.6 logits and decode as almost no detections.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An 84-minute 60-epoch run measured on runs/hydranet_retail_objects spends 49
minutes after its metric peaked at epoch 25, and 17 minutes scoring 4,952 COCO
images for a detection mAP that selects nothing. Neither changes what the run
learns. Both change how long you wait to find out.

`early_stop_patience` stops after N validations with no new best
`primary_metric`. A smaller `epochs` would be the obvious alternative and it is
the wrong one: across 13 runs in runs/ the peak lands anywhere from epoch 6
(fixed_coco10) to epoch 53 of 60 (indoor_seed7), so a fixed cut either wastes
the first case or truncates the second. Patience only ever removes epochs that
improved nothing. The counter is checkpointed, because runs on this box get
preempted and a resumed run that restarts its patience either stops early or
never stops, announcing neither -- the same silent-wrong as resuming without
`best_metric`.

`detection_val_interval` scores the detection head every Nth validation.
Terrain validation is 285 images and stays every epoch since it is what selects
best.pt; detection is 4,952 and 20% of every epoch.

An interval, deliberately not removal. mAP is not dead weight when it selects
nothing: RETAIL_SCOPE.md's COCO-dilution sweep is readable only because
detection was measured alongside segmentation, and ARCHITECTURE_REVIEW.md's AMP
crash in the FCOS loss survived 166 tests and a full 60-epoch run because
nothing exercised that path. An unvalidated head is not a head with a stale
number, it is a head whose collapse is invisible. The last epoch always scores
detection whatever the interval says, so no run ends reporting an mAP from five
epochs earlier.

Both default to off; an existing config trains exactly as it did. Filtering
happens in the trainer rather than inside `evaluate`, which already accepts the
(val_sets, loaders) pair, so hydranet-eval is untouched and a dataset feeding
both heads is never dropped.

Measured, two three-epoch arms contended identically against another session's
run: 21 s saved per skipped epoch, 18%. Projected on the uncontended production
numbers, 85 min becomes 71 min from the interval and 42 min with patience 10 on
a run that peaks at epoch 25.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`sum(h.num_classes for h in model.seg_heads.values())` was a type diagnostic another
session attributed to this file before CI found it on someone else's branch, and it had two
things wrong that the checker saw and review had not.

`h` was the image height four lines above, reused as the loop variable. It worked -- a
generator expression has its own scope -- but by a language rule rather than by intent, and
a reader has to know that rule to believe the number printed next to it.

The diagnostic itself survived that rename, which is the more useful half: `ModuleDict`
values are typed `Module`, so `num_classes` is unreadable and `sum()` has no overload to
match. Narrowing with `isinstance(head, SemanticFPNHead)` states the thing that is actually
true -- only a segmentation head has a class count -- where an `int()` or a cast would have
silenced the question instead of answering it.

Ratchet 17 -> 16. The one left in this file is the `torch.onnx.export` signature and
predates all of today's work.

Also removes a paragraph this session duplicated when editing the ExportWrapper docstring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven test files reference cli/export_onnx: preprocessing, the wrapper, the
unsupervised-head guard, class narrowing, the folded argmax, weight selection, smoke.
Between them they called `main(` zero times, and `main` is 145 lines carrying every
refusal and every piece of flag wiring. test_export_guard.py has ten tests on
`unsupervised_heads` -- the predicate -- while the refusal that consumes it had never
been executed.

Same shape as the other defects found today: every component verified, the composition
not. The composition is what ships to the robot.

57% -> 90% on the file. Covered now: both --detection-classes refusals, the
unsupervised-head refusal, the embedded-vs-external preprocessing split and the dummy
range that goes with it, the export input_size override, --argmax-seg reaching the
wrapper, the ONNX metadata properties, and the sidecar's contents including that
`source_indices` really indexes back into the trained class list.

Two ordering properties are pinned rather than the absence of a file. A refusal must
happen before torch.onnx.export, because an .onnx on disk is indistinguishable from a
successful export and trtexec will build an engine from it. And every
--detection-classes refusal must precede `narrow_detection_head`, which mutates the
model in place -- a half-narrowed model in memory is invisible to a file check. That
second one is 26251130's, from reviewing the file; it also asserts the mutation does
happen on the accepted path, so it cannot pass because the patch never took effect.

torch.onnx.export and onnx are stubbed. The point is what main decides -- what it
refuses, in what order, what metadata it attaches -- not whether torch can serialise a
graph, which test_smoke.py::test_onnx_export already covers with a real export.

Nothing needed fixing in main(). One thing that looked like a bug is a real constraint:
at 64x80 the deepest FPN level is 1x1 and F.group_norm in the FCOS tower refuses a
one-element map. Eval mode propagates correctly through narrow_detection_head and
ExportWrapper; the fixture floor is 128x160 and says why.

Still uncovered and named here so 90% is not read as done: the --check-parity failure
branch, which is the last gate before an engine is built and whose failure path has
never run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… than unlearned

`hydranet_retail_objects_site.yaml` put SAM 3's store pre-labels in front of the model for
the first time and `product` still scored 0.000 for 22 consecutive epochs. The cause is
arithmetic:

    dataset      images   ratio   effective   share of segmentation steps
    ade20k         5998     1.0        5998        90.2%
    site_sam3       216     3.0         648         9.8%

`product` is 19.28% of the site masks' labelled pixels, so about 1.9% of every labelled
pixel the model sees.

**It is not dilution, it is sign.** In the other 90.2%, `product` is not absent -- every
pixel is a labelled *negative* for it, because ADE20K's homes and offices map entirely
onto other classes of this taxonomy. Nine batches in ten pushed that logit down; the rest
pushed it up on a fraction of the tenth. The channel was not learning slowly, it was being
trained to stay silent. The epoch-22 prediction histogram on site val says the same thing
from the other side: 48.61% of the frame comes back `wall` and 17.43% `floor`, against
0.00% of each in the ground truth -- both are 255 in SAM 3's masks, so those predictions
cost nothing and the model took the free answer.

The change is `ade20k` sample_ratio 1.0 -> 0.15, which puts the store at 41.9%. Thinning
the abundant data rather than raising the site ratio to ~20, which reaches the same balance
by showing 216 images twenty times an epoch and trades a suppressed channel for a memorised
one. Ratios below 1 truncate an epoch and reshuffle, so ADE20K becomes a different random
900 each epoch rather than a fixed subset.

**A diagnostic, not the fix.** If `product` leaves zero, sampling is the mechanism and the
fix is more store clips through SAM 3 -- batch01 is 36, and `sam3_prompts_objects.py`
reports `product box` at 116 instances and 0.604 mean score on Taichung-cam01, so that path
works and has simply not been run out. If it does not leave zero, the cause is the 68%
ignore or the taxonomy, which is worth knowing before more labelling is commissioned.

This is the same shape as RETAIL_SCOPE.md §5, where self-training taught the model that
fixture-shaped things in a shop are not `display_fixture`. Twice now a large, clean,
wrong-domain dataset has actively taught something false while the smaller correct one was
read as merely insufficient.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`unsourced_terrain_classes` asks whether *any* dataset can produce a class -- a yes/no
over the union. That is the right question only while the answer is no. Add one small
source and the union says yes, the gate goes quiet, and a harder failure starts.

A class absent from the dominant dataset is suppressed, not merely unlearned, and the
difference is sign rather than dilution. Measured on the retail-objects site run by
session 26251130: ADE20K was 90.2% of segmentation steps and contains zero `product`
pixels, so in nine batches out of ten every pixel was a negative for that channel.
`product` sat at IoU 0.000 for 22 epochs while the run looked entirely normal. The old
gate had said "ade20k_retail_objects can never produce product" all along and stopped
saying it the instant a 5% site dataset was added -- exactly when it began to matter.
There is a test asserting that silence, so the reason this function exists cannot be
mistaken for a docstring claim.

Run against the shipped configs it names `product` in both site configs, `rock` in the
off-road control, and -- unexpectedly -- `floor_metal`, `wet_slippery` and
`threshold_ramp` in hydranet_retail_cctv. Those three have been IoU 0.000 in this
project since the beginning and were assumed to be starved of data. They are not
unsourced there: `site_cctv_pseudo` draws them and ADE20K outvotes them. That is a
different problem with a different fix, and it is pinned rather than acted on, because
it is a training decision.

What this deliberately does not compute is the step share. `sample_ratio` is in the
config; the number of images behind it is not, so a percentage from ratios alone would
be an invented figure with a confident shape. It reports the partition and the declared
ratios and points at the measurement. The real share is known in `MultiTaskLoader`
after the datasets are built, and belongs there -- that also catches the case this
cannot, where the data changes under a byte-identical config.

The advice names a direction, and that is not decoration: prefer lowering the abundant
dataset's ratio, because raising the scarce one reaches the same balance by showing the
same few images many times an epoch and trades a suppressed channel for a memorised
one. A check that only ever said "raise the minority ratio" would push people toward
the worse of the two fixes. Tested.

Datasets with no `label_map` are excluded from the partition: a COCO detection set
contributes no segmentation target, so it neither supplies a terrain class nor supplies
a negative for one, and counting it would flag every class in every multi-task config.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… baseline down

CI has been red on `ty_ratchet.sh` since before this branch: 17 diagnostics
against a baseline of 15, the same count on origin/dev, so every PR was red
whatever it contained.

Ten of the seventeen were in two files, and only seven mentioned a torch type
at all -- so the script's own comment justifying the loose baseline, "almost
all torch and pycocotools stub gaps rather than anything this repo wrote", was
backwards about its own debt. That comment is corrected here rather than
deleted, because a comment excusing debt is itself a claim and nothing was
checking this one.

`AUGMENT_DEFAULTS` mixes a pair of floats with plain floats, so the dict merged
from `data.augment` typed every read as "float or a pair of floats" and five
uses were flagged. Reading each value through a small checked accessor removes
those five and buys something the casts never did: a config writing
`scale_range: 0.5` now fails saying which setting is wrong, instead of raising
inside `tuple()` naming neither the setting nor the file. An inverted range is
caught too.

That takes the count to 11, and BASELINE moves 15 -> 11 with it. A baseline
left above the measured number is a ceiling, not a ratchet -- it would let the
next four regressions through without a word.

Not fixed here: five diagnostics in `data/datasets.py`, where `LabelScheme`
carries `fmt` and `mapping` as independent fields when the mapping's key type
*is* the fmt. That is the ColorScheme/IdScheme split, it is the label-decode
path where this project's most expensive mistakes have been, and it wants its
own reviewed change rather than being smuggled into a green-CI commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bc43b57 reported `floor_metal`, `wet_slippery` and `threshold_ramp` as merely outvoted
in hydranet_retail_cctv, and pinned a comment saying a months-old finding -- that those
three need site annotation -- was backwards. It is not backwards. Counted over every
mask in datasets/retail_cctv_pseudo: 0 pixels in 0 of 408, for all three, and for
`floor_soft`, `stairs` and `glass` too.

The check reasons from `label_map`. `retail_native` is an identity map, so it expresses
all 13 classes by construction and claims every one of them. Expressible is not present.
`unsourced_terrain_classes` already documents that distinction for itself and
`minority_sourced_terrain_classes` inherited the limitation without inheriting the
caveat -- and an identity map is where it bites hardest, because it claims everything.
There is a second layer: retail_cctv_pseudo is pseudo-labels from a model scoring 0.000
on those three, so it cannot contain them. The labels are that model's opinion, and its
opinion is that they do not exist.

The two sides of the partition are not equally sound, which is why this is a fix rather
than a revert. "Cannot produce" is evidence: an explicit map that never emits an id
genuinely cannot. "Can produce" is a claim. So the warning now says CONFIRM WITH A PIXEL
COUNT FIRST when every claimant is an identity map, and stays unhedged when a claimant
is an explicit one -- an always-hedging warning tells a reader nothing about which case
they have, which is the same failure one level up. Both branches are tested.

`product` is unaffected and remains the confirmed instance: batch01 really does contain
19.28% product pixels, verified against the live config.

The three false positives are kept in KNOWN_MINORITY_SOURCED rather than filtered out,
with the count and the reason, because a reader who meets them needs to see why they are
there. Found by session 26251130, who counted the pixels instead of believing the map.

A pinned comment asserting that a correct months-old conclusion is backwards is the most
expensive kind of wrong thing to leave in a repository: it is exactly the artefact
someone finds later and trusts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RETAIL_OBJECTS_SPLIT.md opens by saying most of its rules "are only enforceable by someone
choosing to honour them later". R1, R2, R4, R6 and R7 do not have to be: they are all
decidable from `manifest_*.json` and the masks. R3 and R5 are judgements and are reported
as unchecked rather than guessed at -- R3 in particular, because nothing in a mask file
records who drew it, and if the test masks are SAM 3's opinion then every number above it
measures agreement with SAM 3 rather than correctness.

**batch01 passes, which was not obvious and could not previously be read.** Its clips are
named `archive_<start>_<end>` with no camera, so R1 -- the one rule whose violation
invalidates every number after it -- was unverifiable by inspection. Resolved through the
manifests: 18 training cameras, 6 test cameras, **no overlap**, all three stores on the
test side, and every present class on at least four test cameras. The split was done
correctly; only the record of it was missing.

**The recovery turned up something a glance would not: 8 of 184 clip stems are claimed by
more than one camera.** Two cameras in different stores can start a recording in the same
second, and then their files have the same name. One of those collisions is in batch01's
test split -- `archive_20260816-062742_20260816-063246`, claimed by both Kaohsiung-cam02
and Taichung-cam02 -- so that clip is genuinely unattributable and is reported as a
finding rather than assigned by a coin toss.

So flat `archive_*` naming is lossy, not merely inconvenient, and no care at labelling time
recovers it. A `Camera__stem` prefix cannot collide and beats the manifest wherever both
exist; batch02 should export that way and the tests pin the precedence.

`--write-cameras` records the resolved attribution beside the dataset rather than renaming
anything, since a rename invalidates every `meta.json` that already recorded a path.

The rare-class threshold is `evaluator.THIN_SUPPORT`'s 1%, imported by value with a test
pinning it, because two thresholds for one idea drift and this one was calibrated on
`column` at 0.66% -- the class this project already lost a run to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
274a4a08's account of why `product` was suppressed is sharper than "absent from the
dominant dataset": ADE20K_ID_TO_RETAIL_OBJECTS sends 15 source ids to `fixture` and 0 to
`product`, so a shelf of goods is one `fixture` region under ADE20K and a `fixture` +
`product` split under the site masks. The same pixels, two contradictory targets, and the
louder dataset wins. It also predicts something the "absent" reading does not -- that
`fixture` comes out inflated rather than merely unharmed, which is checkable after a run.

I put that into the warning and then took it out, and the reason is worth the comment
this commit adds. The only config-time proxy for "which class do those pixels get
instead" is the source-id count, and that proxy does not support the claim. Under
`ade20k_retail` the largest count is `obstacle_furniture` at 45, a catch-all, so the
warning announced that `floor_metal`'s pixels are labelled `obstacle_furniture` --
nonsense, since `floor_metal` is a floor surface. The product/fixture mechanism is true
because somebody looked at a shelf, not because 15 was the biggest number.

That is the same error 75cd43e fixed one commit ago: asserting a pixel-level fact from a
scheme-level signal. Writing it down rather than only reverting, because the attempt is
the useful part -- the next person to reach for the id count will reach for it for the
same good reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing model

`evaluate` accumulates one confusion matrix per head across every validation set. For a
class every set can contain, pooling is right. For a class only one of them can contain it
is not: the sets that cannot contain it still collect false positives, and those land in
the same denominator as the true positives from the set that can.

Measured on `runs/hydranet_retail_objects_site_balanced` at epoch 2, on 72 store frames
and 285 ADE20K frames:

    site val only     product IoU 0.5832    TP 333,877   FP  70,232   FN 168,364
    ade20k val only   product IoU 0.0000    TP       0   FP  62,235   FN       0

ADE20K's `product` ground truth is necessarily zero -- that is the whole reason batch01
exists -- so every pixel it predicts there is a false positive with nothing to offset it.
Pooled, and with ADE20K four times the frame count, the reported number oscillated between
0.05 and 0.35. **It read as a channel failing to learn. Recall on the store was 65%.** A
day was spent diagnosing a training failure that was a measurement failure, and a
`fixture`-to-ignore experiment was about to be run against it.

Detection already had this right. `_det_metrics` gives a second dataset its own suffixed
keys rather than letting it redefine the first one's number, and the comment there says
why. This is the same convention on the segmentation side: pooled keys are untouched, so
every existing `metrics.jsonl` and `train.primary_metric` still means what it did, and
`IoU/<head>/NN_<class>/<dataset>` appears alongside when more than one val set supervises
the head. A run whose reason for existing is one dataset can now select on it.

`ConfusionMatrix.predicted()` is the column sums against `support()`'s row sums, and the
two together are what makes the trap findable rather than merely fixed: a class with zero
support and non-zero prediction in one set is emitting pure false positives there, and the
evaluator now says so by name and by pixel count at the epoch it happens.

Also restores `@torch.no_grad()` to `_update_seg_heads`, which this change's own insertion
had moved onto the new function -- validation would have built an autograd graph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cinations

`primary_metric` was `IoU/terrain/05_product`, which pools every validation set into one
confusion matrix. ADE20K's `product` ground truth is necessarily zero -- that is the whole
reason batch01 exists -- so every product pixel predicted there is a false positive with
nothing to offset it, and at 285 ADE20K frames against 72 store frames those dominate.

At epoch 2 of the first attempt:

    site val only     product IoU 0.5832   TP 333,877  FP  70,232  FN 168,364
    ade20k val only   product IoU 0.0000   TP       0  FP  62,235  FN       0

So `best.pt` was being chosen partly for "hallucinates less product on ADE20K", which is
not what this run is for, and the reported figure oscillated 0.05-0.35 while recall on the
store was 65%.

`1f88a76` added the per-dataset keys; this points the run at one. The previous attempt's
output is kept as `runs/hydranet_retail_objects_site_balanced_pooledmetric` -- its weights
were never the problem and it is the control for the selection change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PSheon and others added 30 commits August 19, 2026 16:03
…ready drew

1,425 lines, 788 of them code -- the largest module in `src/` by both measures and
by a wide margin. It was not incoherent: its own section rules already cut it into
geometry helpers, the zone and line detectors, tier-1 behaviour and tier-2 pose.
Those rules are now the file boundaries. Nothing moved between sections, and no
function body changed.

    _types.py      the type list, TIERS, UNBUILT, Zone, CountingLine, SecurityEvent
    _geometry.py   point-in-polygon, cross product, runs of flags, segment crossing
    zones.py       intrusion, occupancy, line crossing, object left, stock removed
    behaviour.py   speed, tailgating, crowd, fall_candidate
    pose.py        the 17 keypoints, posture events, reach-to-shelf

Splitting made two couplings explicit that a single file had hidden. `Zone.contains`
is a polygon test, so `_types` depends on `_geometry` rather than the other way
round; and `tailgating_events` is a second *reading* of the crossings `line_events`
produces, which is the one edge from `behaviour` to `zones` and is one-way. Both are
now import lines with a comment, instead of two functions that happened to be in
scope. The graph is acyclic.

**The import surface is unchanged**, which is the point. Consumers use
`from syncai_hydranet.analytics import events as ev`; `scripts/mine_fall_candidates.py`
and `tests/test_dispositions.py` import names off the module directly. Every public
name is re-exported and both forms resolve as before -- verified by asserting all 26
of them, plus the two direct imports, still resolve. `ev._torso` is re-exported too,
under a comment: `scripts/pose_pilot.py` reads it to plot the torso-angle
distribution the fall detector would see, which is measuring the instrument rather
than calling it. It stays out of `__all__`.

`tests/test_stage_contract.py` changed with it. It reads the frame-key contract off
the source rather than restating it, and found its two functions by parsing
`ev.__file__` -- now an `__init__` with no function bodies in it. It parses the
package directory instead, and asserts a name is found exactly once, so a function
moving between modules cannot silently make the test read nothing.

Type ratchet unchanged: the package contributes zero diagnostics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scripts/propose_zones.py turns runs/onboard01 into per-camera candidate
zone polygons in metre space (runs/zones01): walkable-floor outline from
the terrain head's floor mask through pixel_to_ground, fixture footprints
from the fixture-floor contact edge extruded into non-floor space, and
entrance candidates from censored track birth/death hotspots on the two
cameras offline_tracks01 covers. Policy attributes ship as explicit nulls
-- physical zones are automatable, policy zones are proposed-never-decided.

Two footprint methods were measured and rejected on Taichung-cam01 before
the one that shipped: filling per-image-component traces claims the whole
aisle (the fixture class connects distinct furniture), and a global
extrusion closes into a ring whose outer contour is the floor. The final
partition assigns extruded cells to their nearest contact-edge run and
recursively bisects segments that stay concave or enclose the floor.

Cameras without a measured scale still get proposals, marked dav2_raw
loudly in the json, the render and every confidence note -- a number that
looks like a metre gets believed as one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ics/bytetrack`

`Kalman`, `Fragment` and `OfflineForward` lived in `scripts/offline_tracks.py`, and
`scripts/stable_infer.py` reached them with a `sys.path` insert.
`tests/test_scripts_are_not_libraries.py` names the cost and ratchets on it: shared
code under `scripts/` sits outside the wheel, outside the type ratchet and outside
the coverage floor, so the thing two callers depend on is the thing nothing checks.
The pair count falls 12 -> 11.

**The move surfaces a disagreement rather than settling one, and the docstring says
so at the top.** `analytics/tracker.py` refuses a Kalman filter and gives its reason:
no hand-labelled site boxes exist, so both covariances would be invented, and
"tuned-looking constants that were guessed are worse than an honest constant velocity
step, because they make the result look calibrated". This file runs one on ByteTrack's
published MOT17 weights. Borrowed-and-named is not the same as guessed, and the one
adaptation to this footage -- rescaling the velocity prior by frame rate, since MOT17
is 25-30 fps and these clips sample at 5 -- is stated where it happens. But it is
still not the measured noise model `tracker.py` is holding out for. Which tracker is
right is open in the honest direction: nobody has compared them on this footage, and
`reid_metrics.py` is where that would live. Until then the docstring says which to
pick for what.

Two things stayed behind on purpose:

* `stash_crops` cuts review thumbnails at `track_review.py`'s display geometry and
  crops at the encoder's training resolution. That is presentation, not tracking, so
  it is now a function over a `Fragment` in `offline_tracks.py`. `Fragment` keeps the
  two lists as storage the caller may fill and the tracker never reads.
* `_cwh`/`_xyxy` became `to_cwh`/`to_xyxy` and public. The stitch pass and the motion
  statistics both take a box centre off them, so the conversion was never private to
  the filter -- the underscore only ever meant "same file".

`tests/test_bytetrack.py` is the third part of the point, since a move with no tests
pays only two of the three costs the ratchet names. 21 tests, and the module lands at
100% statement and branch coverage; the suite total rises 86% -> 87%. They pin the
association *policy*, which is what a caller depends on and cannot check by eye: only
a high-score box may start a track, a low-score box may only continue one and is
gated more strictly, an unconfirmed track that dies is discarded rather than retired,
and a coasted frame is not recorded as an observation. The Kalman arithmetic is
ByteTrack's and is asserted only where this project's use of it differs.

Type ratchets unchanged: src/ at 12, scripts/ at 18 once the untracked
`campaign_site30k.py` another session is writing is excluded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
212 lines, the longest function in `src/` by 40. The phases were not hidden -- each
one had a blank line and a paragraph of comment over it -- so this cuts on those
lines and moves no statement between them: run directory, determinism, model, data,
schedule, run policy, run meta.

**What the split adds is the constraints between them, which a straight-line body
could not state.** In one block an ordering requirement reads as "the line above" and
there is nowhere to write it down; three of them were load-bearing and only one was
commented:

* `_seed_and_configure_backends` runs before `_build_model` because `seed_everything`
  seeds the RNG that initialises the weights. Seeding after would leave the initial
  weights unseeded while every log line still reported a seed.
* `_build_schedule` runs after `_build_model` because `_make_ema` deep-copies the
  model: converting to channels_last afterwards leaves the weights validation actually
  runs on in NCHW. This one was already commented, at the line rather than at the
  boundary.
* `_write_run_meta` is last because it snapshots the resolved cfg, the step counts and
  the dataset fingerprints, all of which the phases above produce.

Those are now docstrings on the methods they constrain. The risk they cover is a
reordering that still runs, still trains, and quietly changes what is in the
checkpoint.

Verified by comparing against `HEAD` rather than by reading: the constructor sets the
same 30 attributes on `self`, none added, none missing. 376 trainer, budget, resume,
checkpoint, seeding and EMA tests pass, the full suite is unchanged at 1,685, and the
type ratchet holds at 12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…irectory

`logging.getLogger(name)` is a process-wide singleton and `get_logger` guarded on
`if logger.handlers: return logger`, so the `log_file` argument was honoured **only
on the first call**. A second `Trainer` in the same process got the first run's file
handler: its own `output_dir` ended up with no `train.log`, and its lines were
appended to a finished run's log, under that run's name and interleaved with nothing
to say the run had changed.

Nothing in production hit it, because `hydranet-train` builds one `Trainer` and
exits. It is reachable from anything that trains twice in one process -- a sweep
driver, a notebook, and the test suite.

Found while verifying the `Trainer.__init__` split, from
`tests/test_cli_smoke.py::test_train_writes_everything_a_run_needs`: it failed after
`tests/test_trainer.py` and passed alone, and it fails identically at `HEAD` with
that split reverted, so it predates it. The order-dependence was the symptom and it
is the cheap thing to notice; the defect is a log written into another run's
directory, which nothing was looking for and which the log itself cannot show,
because the lines are all there and each one looks ordinary. `runs/` is the artefact
this project traces a checkpoint through -- `docs/METHODOLOGY.md` section 6 is about
keeping runs answerable -- and a log under the wrong run is that record lying.

A different file now replaces the handler rather than being ignored: a second run
means the first one is over, and keeping both would put run B's lines in run A's log,
which is the same failure written more slowly. The same file is a no-op, so the
repeated calls a resume path makes cannot stack handlers and double every line, and
`get_logger("eval")` with no file -- `cli/evaluate.py` -- does not detach one.

`tests/test_logger.py` pins all four cases plus the console handler, which must be
added once and survive a file move. The subset that reproduced the bug now passes in
any order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`CJK` spelled its character class with the characters themselves, so the scan that
walks every source file matched its own line and
`test_no_cjk_in_source[test_source_is_english.py]` failed on the guard rather than on
anything the guard is for.

The trap was already known one function further down: `test_no_file_re_exempts_itself`
builds its marker by concatenation and skips this file by path, under the note "Built
rather than written out: the literal would match this file's own source". That reads
as a fix, but it only covers that one pattern, and the exemption is the part worth
not repeating -- a guard that cannot be run against itself has a blind spot exactly
its own size.

So the ranges are escapes now and this file is English like the ones it polices. They
are the same ranges, proven rather than eyeballed: the two patterns were compared on
every code point in U+1000..U+10FFF and agree on all of them. U+3040-30FF kana,
U+3400-4DBF and U+4E00-9FFF ideographs, U+F900-FAFF compatibility ideographs.

Two failures remain on this file and neither is about it: both are
`scripts/onboard_camera.py`, which is written in Traditional Chinese and carries a
`# ruff: noqa: RUF001` self-exemption. That is another session's file and landed in
935ef42, after c1c904e set this rule and added this guard. Left for its author.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…norama

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fusion`

`scripts/val_sampling_error.py` reached it inside `scripts/site_confusion.py` through
a `sys.path` insert. The function's own docstring had already stated why it must not
be copied -- "the alternative was to copy this loop, whose upsample-before-argmax step
is easy to get quietly wrong" -- which is the argument for it not being in a script at
all. `tests/test_scripts_are_not_libraries.py` ratchets on that shape and cites what
it has already cost: four scripts kept their own copy of one loop, the copies
disagreed about lens correction, and that changed which observations the tracker
linked, under a mining run that then concluded "none of the 48 spans is a posture".

`run_config` came with it. Reading a finished run's config without today's validator
is not a property of one analysis script; it is how any consumer reads `runs/`.

**The signature changed.** It took an argparse `Namespace` and read five attributes
off it, which is a script's calling convention: a caller holding the five values and
no parser had to fabricate a namespace. They are keyword-only now, and one of the
tests is that a positional call raises -- `dataset` and `split` are both strings, and
swapping them positionally would produce a confident matrix over the wrong images.

`tests/test_confusion.py` is 13 tests at 100% statement and branch coverage. What they
pin is not that it runs but that the matrix means what a caller reads it as: the truth
axis is the first one (a transposed matrix is still square, still sums correctly, and
says the opposite thing about which class is eating which), IGNORE pixels are not
counted, an image with no annotation is skipped rather than counted as an all-zero
matrix -- which would otherwise dilute exactly the per-image spread
`val_sampling_error.py` exists to measure -- and `kept` stays index-aligned with the
stack, which is how that script resamples.

`tests/test_ignore_is_one_definition.py` follows the sentinel: the masking loop is in
`engine/confusion.py` now, so that is the file required to import `IGNORE`, and
`scripts/site_confusion.py` no longer mentions 255 at all.

The script-to-script pair count falls 11 -> 10, which is the baseline. It reads 11
again only because `scripts/propose_zones.py` landed in d94faf5 while this was being
written and imports `calibrate_from_plate`; that is the third pair on that one file
and is called out in the handover rather than chased here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…state

runs/bench_pro6000 named the frontier: the fp16 b16 engine computes 3,272 f/s
but synchronous fp32 H2D drags it to 1,553 against the 1,440 target (96x15).
This package is increment 1 of the serving pipeline, shaped around that
measurement, and the recovery is total -- measured in runs/serve_pilot01:
uint8 sync H2D alone lifts 1,516 -> 2,860 f/s, and pinned staging with
dual-stream double buffering reaches 3,206 f/s, 99.8% of the engine's own
compute rate. The copy has left the critical path.

- uint8_input: graph surgery, not re-export. A uint8 [B,H,W,3] binding with
  Cast+Transpose prepended to the exact ONNX the bench measured, so only the
  input path can have changed. Contract-by-name per the export convention:
  the input becomes image_rgb_u8_nhwc, and a runtime written for the fp32
  binding fails loudly instead of feeding bytes as floats.
- engine: TrtExecutor -- two buffer slots, separate copy/compute/D2H streams,
  events ordering them, the host throttled by the compute of two batches
  back. bench_sync restates bench_trt.bench's method (scripts are not
  libraries; the ratchet counts the imports).
- scheduler: fixed batch-16 ticks over N streams. A tick never blocks, a
  late stream is skipped, the freshest frame wins, fairness is
  longest-waiting-first -- six of 48 fleet cameras emit nothing, so one
  stalled RTSP session must never hold fifteen others hostage.
- camera: per-camera state -- label EMA, injected tracker, calib handle from
  runs/onboard01, per-class working thresholds. Thresholds are config, not a
  constant, because the b03_gdino retrain moved boxed_stock's calibration
  wholesale (5.5 -> 0.12 boxes/frame at a fixed 0.30, mAP unchanged). The
  EMA is the measured stabiliser (static flips 1.50% -> 0.67%) adapted to
  the argmax-only export, implemented incrementally in torch: 10.9 ms/frame
  naive numpy -> 0.9 ms, bit-identical output (the equivalence test runs 120
  frames across the scale renormalisation), and torch releases the GIL where
  numpy's fancy indexing serialised the whole post pool.
- decode: FCOSHead.decode mirrored over TRT output buffers, parity-tested
  against the model's own decode, plus per-class score thresholds and a
  pre-NMS topk (NMS over the ~6-7k candidates a 0.05 floor admits measured
  ~500 ms per 16-frame tick; topk 512 makes it 11 ms without changing the
  kept set, which is also tested).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the frontier

The two instruments for increment 1, results in runs/serve_pilot01:

bench-h2d proves the copy-path recovery on the benchmarked engine: fp32 sync
1,516 f/s -> uint8 sync 2,860 -> uint8 + pinned + dual-stream overlap 3,206,
which is 99.8% of the uint8 engine's 3,211 f/s compute ceiling; with the host
fill and full output D2H it holds 3,116 f/s, 2.16x the 96x15 target.

run is the pilot: 16 site clips decoded by CPU ffmpeg, each paced at the
target 15 fps (pipe backpressure throttles the decoder, so decode CPU is what
a real camera costs -- free-running instead saturates all 24 cores and
collapses the pipeline to 36 f/s, the NVDEC argument in one number), batch-16
ticks, the uint8 double-buffered engine, then per-camera EMA + tracks with
per-class thresholds. Achieved over 60 s: 234 frames/s = 16 x 14.6 fps, tick
budget 68 ms of which post uses 42 (decode 11, per-camera update 29) and the
GPU -- fully overlapped -- 5. Pacing the streams at 30 fps reaches 362 f/s,
so the post path caps ~23 ticks/s: Python post is now the frontier, exactly
where the morning's arithmetic put the risk after the copies.

Two costs the pilot surfaced and names: the stats-floor decode needed the
package's pre-NMS topk (500 -> 11 ms/tick), and bytetrack's deliberate
pure-Python Hungarian holds the GIL ~9.5 ms per crowded frame, so the pilot
subclasses the association onto scipy's C solver (post_update 111 -> 29 ms;
same matrix, same threshold, optimal either way) -- whether bytetrack grows
that fast path natively is an increment-2 decision, recorded in the report.

Also recorded there: the per-class score distributions that make boxed_stock's
working threshold derivable per checkpoint, and the increment-2 list (NVDEC,
post scaling toward 6 ticks per frame period, the static-composite skip,
retired-track eviction, engine provenance).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tleneck

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
scripts/onboard_camera.py carried the same header the two scripts of c1c904e did -- "this
file's prose, comments and output are Traditional Chinese", with `# ruff: noqa: RUF001,
RUF002, RUF003` -- which is exactly the marker test_no_file_re_exempts_itself fails on,
and the file itself failed test_no_cjk_in_source. Same treatment as c1c904e: checked
first that no Chinese string was a dict key or a JSON field (the .calib.json keys, flags
and source strings were already English, and REPORT.md is prose no code parses), then
translated the docstring, comments, help strings and report text, and removed the
exemption header.

Every measurement note survives in the English: the ±0.7° pitch-vs-anchor claim and its
vfov-pinning precondition, k1 = -0.225 as a fleet-hardware assumption tile-grid measured
only on Taichung-cam01, the >= 10-heights gate on the person-height scale, cam04's 8.6%
dirty plate over the 5% threshold, and the ±11% person-prior / ±5% vfov-unpinned
systematic terms. Executable lines are unchanged apart from the translated string
literals and one wrap for line length.

tests/test_source_is_english.py: 270 passed, 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sweep C settled `column`'s min_score at 0.5 and its totals survive in the prompt
table's comment, but its per-camera output was never written down. That made the
cameras its threshold discarded unnameable, and naming them was the only way left
to add a training camera for this class without egress -- so the measurement had
to be paid for twice. This is a script so there is no third time.

The answer is negative and that is the useful part. 17 cameras clear 0.50 and 24
clear 0.25; exactly two are both new and trainable, and both were opened at native
resolution and rejected -- Tao-Hsin-cam11's mask is street bollards seen through a
glass door, Taichung-cam09's is a narrow strip beside a display podium. The
trainable population for `column` stays at six.

Two things worth reusing came out of it. Four store-local tranches instead of
sweep C's two, and the count of tranches a camera clears 0.50 in separates a
column from a lighting artefact almost by itself: 13 of the shipped 14 fire in 3
or 4, every camera newly cleared fires in 1. And the rotation arm -- the census
found five sideways mounts, not one, so a column would have been a horizontal bar
on all five -- returns zero. Reasonable hypothesis, wrong, and now nobody needs to
retry it.

The first run of this script asked SAM 3 about a "daylight" arm that was
`datasets/studioa_clips/_survey`, whose plates all came from the mislabelled pull
and read 19:30 store-local. Frames are now selected by store-local hour from the
clips, and `TRANCHES` carries that mistake so the reader can see why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`retail_objects_columns_clean` is a `column` supplement with no val and no test,
so it had no assignment for this script to move and was invisible to it. It was
built on 2026-08-18 from the nine cameras then in neither val nor test; six hours
later a `--to val` run promoted Taichung-cam10, and the supplement went on
training on a val camera with nothing anywhere reporting it. Kaohsiung-cam02 went
the same way into batch03 test.

The three surfaces_columns seeds that had already finished were honest. Anything
trained after 19:47 was not, and no tool said so -- which is the part worth
fixing, because the config comment that predicted this failure predicted it
backwards. It warned that a batch selected on a property selects against its
split. This is the reverse: a static supplement invalidated by the split moving
underneath it.

An assignment cannot express that, so the guard is a digest. A dataset may carry
`clean_against` -- the batches it was built against and a hash of their assignment
at the time -- and every move here is checked against it beforehand and refused by
name, with the camera and the split it would land in. A supplement with no such
block is reported as unprotected rather than assumed safe: "nothing to check" and
"checked and fine" are the two states this failure confused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…found none

Two sections in RETAIL_DATA §4, both consequences of the resplit the section
above them describes.

The first is the leak: timestamps show columns_clean built at 14:51, the three
seeds finishing by 15:41, and split.json rewritten at 19:47 to promote
Taichung-cam10 to val. That withdraws the `column` 0.454 -> 0.505 those seeds
measured, on exactly the grounds the section already states for every other
checkpoint -- the val it was scored against no longer exists.

The second is the population, which is the answer to "more cameras, not more
frames": SAM 3 finds a column on 14 of 48 cameras at min_score 0.5, R4 and R7
spend eight of them on val and test, and the supplement already holds all six
that are left. Re-sweeping to find a seventh produced two candidates and both
were rejected on sight -- bollards through a glass door, and a strip beside a
podium. The path is exhausted inside the current pull, and the record now says so
rather than leaving the next person to re-derive it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Session record. The question that opened it had a wrong premise -- SAM 3 does
segment walls and columns and is the designated teacher for both; the claim it
had been confused with is that ADE20K's `column` does not transfer, which is
about the data source.

Kept here rather than only in RETAIL_DATA because three of the five findings are
things not to do again: the rotation arm returns zero across all five sideways
mounts, both new column candidates fail on sight, and
`datasets/studioa_clips/_survey` is 19:30 store-local footage from the
mislabelled pull that the first run of the sweep believed was daylight.

Also records what none of it settles: R3 is unsatisfied, so a `column` mask
stable across four tranches is stable, not right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A camera must clear min_score 0.50 in 3 of the 4 store-local tranches before it
supplies a `column` pixel. The rule counts tranches rather than raising the
threshold because a peak score is one frame's opinion under one light -- sweep C
had two frames per camera and could not have run the test at all.

The evidence is the separation itself: 13 of the 14 cameras banked at 0.50 clear
it in 3 or 4 tranches, while every camera the re-sweep newly clears fires in
exactly 1, and both trainable candidates among those were rejected on sight.

Applied, and it cost a sitting member. Taichung-cam04 was in the shipped 14 and
clears 0.50 at midnight only -- 0.727 against 0.408 / 0.000 / 0.332 for the other
three -- and at native resolution its midnight mask is a fragment behind the back
counter that never reaches the floor: `column` drifting onto a wall strip, which
is this class's oldest failure. Its column pixels are IGNORE now; its fixture,
product and person pixels stay, so the camera keeps supplying what it is good
for. Not labelling beats labelling wrong, and a supplement is the last place to
defend a class's pixel count.

So the honest count is 5 selling-floor cameras, not 6 -- searching harder for
more made the number smaller. The pixel supply behind them is also far more
uneven than a camera count suggests, down to Kaohsiung-cam04 at 0.27%, which
passes the rule legitimately and simply has a small column. Recorded because a
camera count is not a pixel count and this class has been misread that way
before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e pointed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… ratchet back to 18

_prelabel loads sam3_prelabel.py by path; spec_from_file_location and spec.loader
are both Optional, and the three resulting diagnostics shipped with 2388b88 left
the scripts/ type ratchet red at 21 against its 18 baseline for every commit since.
The guard is unreachable for an existing .py file -- it exists so the Optional
returns are checked rather than silently narrowed. Behaviour unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…olving two script pairs

onboard_camera.py importing calibrate_from_plate.py made 11 script-to-script pairs
against test_scripts_are_not_libraries.py's baseline of 10, and the rule's intent is
the fix, not the baseline: the shared pipeline now lives in
src/syncai_hydranet/geometry/plate_calibration.py -- undistort_image (the division
model's closed-form inverse warp), run_depth (DA-V2 once), pick_daytime_slot,
floor_candidates/choose_floor (RANSAC "lowest plausible horizontal surface"),
column_health, floor_scale, load_person_boxes and person_checks -- where the wheel,
the type ratchet and the coverage floor reach it.

fit_camera_from_people.fit moved too, as fit_pose_from_people: person_checks runs it
as the depth-free sanity bound and a package module cannot import a script; the
1.70 m ADULT_M prior moved with it, so it exists exactly once.

calibrate_from_plate.py stays the same CLI over package imports (flags, JSON and
prints unchanged; undistort_image/run_depth remain re-exported for propose_zones.py
and the in-progress campaign script). onboard_camera.py imports the package directly.
BASELINE_PAIRS lowered 10 -> 9, since the move also dissolved
calibrate_from_plate -> fit_camera_from_people.

Regression: the single-camera path re-run on Taichung-cam01 reproduces
runs/onboard01/Taichung-cam01.calib.json exactly -- pitch 49.53 / roll +0.84 /
h_raw 3.842, every by_vfov row byte-identical -- the only diff being the provenance
string, which now names the package module instead of the script.

Follow-up checked, not taken here: propose_zones.py duplicates no ground-plane
arithmetic (it imports undistort_image through the CLI's re-export and could import
the package directly next time it is touched); serving/camera.py only consumes the
calib JSON and holds no geometry of its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The `person` teacher swap to Grounding DINO at 0.35 named its own gap: the
night half rested on one empty-store clip on one camera, and nine of 48 cameras
had returned SAM 3 night hallucinations. This is the instrument that closes it.

It joins GDINO at floor 0.10, SAM 3 at its shipped 0.50, and static share against
the ~00:00 plates, over the same 12 midnight frames from each of the 42 live
cameras. The frames really are the same: the two box scripts share a sampling
rule to the line, so running SAM 3 with its daylight gate disabled puts both
teachers on identical pixels without a second sampler to disagree.

The verdict is deliberately three-way rather than pass/fail. A cleaner at
midnight is a real person and finding them is the threshold working, so a camera
over threshold whose box *moves* is reported as `person present`; only a box that
is over threshold and static is a counter-example. The static share is what
separates them, and the midnight plates already exist for all 42 cameras, cut
from these very clips, so the reference illumination matches the frame.

It also writes down which cameras SAM 3 fires on at night. That count was
recorded and the names were not -- the same way sweep C's per-camera output was
not -- and recovering it a second time is the cost of that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-measured across all 42 live cameras, 12 midnight frames each, as the swap
entry's own caveat asked for. 28 cameras hold, 3 carry a real person, and 11 are
counter-examples: a box over 0.35 on an empty shuttered store, static against the
~00:00 plate, and confirmed not-a-person at native resolution. The worst is
0.594, 1.7x the threshold it is supposed to sit below.

The pipeline reproduces the original measurement on the camera it was made on
before anything else is read off it -- Taichung-cam09 returns GDINO max 0.323
against the recorded 0.326 and SAM 3 229 against 229 -- so the gap was real and
it was one camera's.

The swap did not fix the hanging-packet failure, it moved it: GDINO reads pegboard
stock as people where SAM 3 returns nothing, and the reverse. Box-level agreement
at night is 24%. Requiring both teachers clears 9 of the 11 and costs real people,
75% on Kaohsiung-cam02 and 100% on Tao-Hsin-cam09 -- the same trade the static
gate was switched off for.

So night `person` is a per-camera decision rather than a fleet threshold, and
night tranches cannot be labelled unreviewed. Day is unaffected. The three
documents are corrected together because leaving any one of them saying 0.35
works at night is worse than the original error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Grounding DINO at 0.35 does not hold at night -- 13 of 42 cameras put a box over
it on an empty shuttered store, the worst at 0.594. This is what makes the other
28 usable and bounds the 13: drop a box whose static share against that camera's
own midnight plate is at or above 0.50. The plates already exist for all 42
cameras and are cut from these very clips, so the reference illumination matches
the frame rather than approximating it.

The threshold is the centre of a plateau, not a point in a gap. Swept against
eye-verified ground truth, every value from 0.30 to 0.75 removes the same 62 of
72 false boxes and loses none of the 12 real ones.

It is a veto and the docstring says so at length, because the first pass of this
work used the measurement in both directions and was wrong in one. A high share
means the box never moved, which is sound. A low share does not mean a person:
where the plate itself is noisy everything reads as moving, and that filed two
cameras of parked scooters and hanging stock as people. Both are now excluded by
name in UNPROTECTED rather than run through a gate that cannot see them.

Three behaviours make it a veto rather than a classifier, and the tests hold each:
no plate is a refusal and not a pass, a box smaller than the plate's resolution is
kept rather than dropped, and an excluded camera refuses with the reason attached.

`static_person_filter.py` now imports `static_share` from here instead of keeping
its own copy. The report that chose the threshold and the gate that applies it
must not be able to disagree about a box; re-run after the change its 8,616 boxes
are bit-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`night_person_filter.py` reads a COCO from `gdino_person_boxes.py` and writes the
boxes a night tranche may use unreviewed, with a per-camera accounting of
everything that did not make it and why. It is the entry point a batch pipeline
calls; the rule and its argument live in the package.

The tests are the acceptance criteria rather than a coverage exercise. Measured on
the 42-camera fleet: 12 of 12 eye-verified person boxes survive, 0 boxes are
dropped across the 28 clean cameras, and 72 of 72 false boxes are removed -- 62 by
the veto, 10 by camera exclusion. Killing a real person is a rejected recipe, not
a tuning result, which is why that one is stated first.

The CLI deliberately does not gate on daylight. It reports the chroma it saw so a
day frame that wandered in is visible rather than silently vetoed against a
midnight plate: a filter that quietly corrects its caller's mistake is how the
mislabelled pull went unnoticed for a day.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…erson

Two cameras were filed as `person present` on a low static share and hold parked
scooters and a hanging accessory. Opening them was required by the veto's
acceptance set and is what caught it.

The mechanism is worth more than the count. The static share measures pixels
changing, and it was used in two directions when it supports one: a high share
means the box never moved, which is sound, but a low share does not mean a person
-- where the plate is noisy everything reads as moving. An outdoor street bay and
a dark near-nadir rack both do. A three-way verdict computed from one measurement
was reported as if it were two independent ones.

It does not weaken the entry's conclusion, it strengthens it: the counter-example
count went up, so 0.35 holds on fewer cameras than first reported, not more. The
stale counts in sections 2 through 6 are corrected in place rather than left to
contradict the correction.

Also records what was built from it -- the veto, its acceptance results, and the
residual risk it cannot close: a person who holds perfectly still for a whole clip
is furniture to a measurement of whether pixels moved, and no threshold can tell
them apart. That belongs to the alerting layer, not the labelling one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two measurements on Kaohsiung-cam04 forced it. The camera sits 2.17 m up at 52.33 deg
of pitch with a 70.4 deg vertical field, so the top edge of the frame is still 17.1 deg
below horizontal: every ray points down, the image reaches 1.09 m at 3.5 m, and the
whole height map tops out at 1.75 m. The v2 fixture split's `shelf >= 1.40 m` band is
therefore unreachable on this camera, and a per-pixel rule with no reachable band paints
parts of objects -- which is also how a table's side panel became `shelf` in the v1
pilot, and how the speckle and stair-stepped edges the user rejected were produced.

Second: undistort_image keeps the canvas size, so with k1 = -0.225 the undistorted
sample of 21.6% of the frame falls outside the plate and is border-clamped. Height and
verticality there are the border's, not the pixel's -- the constant-height blobs at the
frame corners are that artifact, and 22% of the reviewed v4.2 floor sits in the ring
(three quarters of it carried by the b03 floor channel, which needs no geometry).

The entry records the resulting recipe, every gate with the spread it was read from, and
that the reviewed v4.2 floor recipe still exists only in a session scratchpad.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant