Conversation
`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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Related issue
Closes #
Changes
Checklist
Notes