Attach the catalog row to accepted still-image answers

`classify_boxes` returned only the identity, so every accepted still-image
answer reached the client as a bare SKU id with no name or price. Measured
through the real server on add/image6.jpg: 41 of 41 accepted detections had no
`product`. The live path attached the row; the still path did not, which is
the F-02 finding the decision document recorded as still live in production.

The row is attached where the decision is made, so both paths carry it, and
`recognize` passes it through to the wire. Verified through the running server:
41 of 41 accepted answers now carry product_name, price, and stock counts.

Also corrects the decision document's full-resolution claim, which was
measured before the reference-upscaling and text-signature fixes: it said 77%
precision with 11 false accepts and a hub alarm, where the current lane reads
88% with 5 and no alarm. The accept line does not need to differ by input path
- correct facings sit at p25 2.07 (full) and 2.87 (live), wrong ones at p75
1.00 and 1.66, so 2.0 is the boundary in both - and the results table and the
stream G1 numbers are refreshed to the current measurements.
This commit is contained in:
tngo 2026-09-29 00:53:56 +09:00
parent b6c87c45ea
commit 771e9285d7
3 changed files with 116 additions and 34 deletions

View file

@ -685,13 +685,20 @@ class RecognitionEngine:
# operational failure must stay distinguishable from absence. # operational failure must stay distinguishable from absence.
decision = decide_identity({}, method=method, scale=self.identity_scale) decision = decide_identity({}, method=method, scale=self.identity_scale)
decision = identity_payload(decision) decision = identity_payload(decision)
results.append( row = {
{
"detection_id": int(box["id"]), "detection_id": int(box["id"]),
"box": list(box["box"]), "box": list(box["box"]),
"identity": decision, "identity": decision,
} }
) # The catalog row travels with an accepted answer. Without it the
# phone gets a bare SKU id and has to look the name and price up
# itself, which is what left 41 of 41 accepted still-image answers
# without a product row.
if decision.get("state") == STATE_RECOGNIZED:
product = self.product(decision.get("sku_id"))
if product is not None:
row["product"] = product
results.append(row)
return results return results
def inspect(self, image: Any, box: Sequence[int], method: Optional[str] = None) -> Dict[str, Any]: def inspect(self, image: Any, box: Sequence[int], method: Optional[str] = None) -> Dict[str, Any]:
@ -905,16 +912,18 @@ class RecognitionEngine:
identities: List[Dict[str, Any]] = [] identities: List[Dict[str, Any]] = []
if classify and boxes: if classify and boxes:
identities = self.classify_boxes(image, boxes, method=method) identities = self.classify_boxes(image, boxes, method=method)
identities_by_id = {row["detection_id"]: row["identity"] for row in identities} identities_by_id = {row["detection_id"]: row for row in identities}
for box in boxes: for box in boxes:
detections.append( row = identities_by_id.get(box["id"])
{ detection = {
"id": box["id"], "id": box["id"],
"box": box["box"], "box": box["box"],
"confidence": box["confidence"], "confidence": box["confidence"],
"identity": identities_by_id.get(box["id"], identity_payload(None)), "identity": (row or {}).get("identity") or identity_payload(None),
} }
) if (row or {}).get("product") is not None:
detection["product"] = row["product"]
detections.append(detection)
slots = self.empty_slots(image.shape, boxes) if with_empty_slots else [] slots = self.empty_slots(image.shape, boxes) if with_empty_slots else []
classified_at = time.perf_counter() classified_at = time.perf_counter()
summary = self.summarize(detections, slots) summary = self.summarize(detections, slots)

View file

@ -244,6 +244,33 @@ class EngineClassificationTests(unittest.TestCase):
self.assertEqual(rows[0]["identity"]["state"], "recognized") self.assertEqual(rows[0]["identity"]["state"], "recognized")
self.assertEqual(rows[0]["identity"]["sku_id"], "sku_a") self.assertEqual(rows[0]["identity"]["sku_id"], "sku_a")
def test_an_accepted_detection_carries_its_catalog_row(self):
"""The phone must not have to look up a name and price from a bare SKU.
`classify_boxes` returned only the identity, so every accepted
still-image answer reached the client as an id with no product row:
measured through the real server, 41 of 41 accepted detections had no
`product`. The live path attached it; the still path did not.
"""
engine, _, _, _ = make_engine()
detections = [
{"id": 0, "box": [10, 10, 60, 90], "confidence": 0.9,
"identity": identity_payload(None)},
]
rows = engine.classify_boxes(make_image(), detections, method="hybrid")
self.assertEqual(rows[0]["identity"]["state"], "recognized")
self.assertEqual(rows[0]["product"]["product_name"], "Alpha")
self.assertEqual(rows[0]["product"]["price"], 100.0)
result = engine.recognize(make_image(), method="hybrid")
accepted = [
row for row in result["detections"]
if row["identity"]["identity_level"] == "accepted"
]
self.assertTrue(accepted, "the fake detector and matcher accept a box")
for row in accepted:
self.assertIn("product", row, "an accepted answer must carry its row")
def test_a_failing_crop_reports_unrecognized_not_pending(self): def test_a_failing_crop_reports_unrecognized_not_pending(self):
"""One bad crop must not fail the batch, and must not read as pending. """One bad crop must not fail the batch, and must not read as pending.

View file

@ -609,26 +609,36 @@ scores, always answered. Every "vs Van" claim in this record uses that row.
lane catalog identity decision end-to-end precision false acc lane catalog identity decision end-to-end precision false acc
van 69% 76% 76% 52% 29% 77 van 69% 76% 76% 52% 29% 77
matcher 69% 76% 71% 49% 39% 46 HUB! matcher 69% 76% 71% 49% 39% 46 HUB!
local 69% 95% 88% 61% 77% 11 HUB! local 69% 93% 86% 59% 88% 5
live resolution — 109 scored facings (61 in-catalog, 48 unknown) live resolution — 109 scored facings (61 in-catalog, 48 unknown)
lane catalog identity decision end-to-end precision false acc lane catalog identity decision end-to-end precision false acc
van 69% 83% 83% 57% 32% 74 van 69% 83% 83% 57% 32% 74
matcher 69% 83% 76% 52% 43% 43 matcher 69% 83% 76% 52% 43% 43
local 69% 86% 86% 59% 95% 2 local 69% 86% 81% 56% 97% 1
Against Van's real contract the local lane wins both axes at both resolutions: Against Van's real contract the local lane wins both axes at both resolutions:
61% at 77% against 52% at 29% (full), and 59% at 95% against 57% at 32% (live). 59% at 88% against 52% at 29% (full), and 56% at 97% against 57% at 32% (live).
The live coverage margin is one facing (36 against 35), so it is not a The live coverage margin is one facing against Van, so it is not a meaningful
meaningful gain there; the full-resolution margin is five facings. gain there; the full-resolution margin is four facings at three times the
precision.
**The 95% figure is live-resolution only.** At full resolution the same lane **One accept line serves both resolutions.** An earlier draft of this section
reads 77% precision with 11 false accepts and raises the hub alarm, and the claimed full resolution needed a line near 6 to reach 95%, leaving 28/61
still endpoint and the native crop lane both run at that scale. Reaching 95% at correct. That was measured before the reference-upscaling and text-signature
full resolution needs an accept line near 6, which leaves 28/61 correct - below fixes and no longer holds. The separation is clean at both scales: correct
Van's 32/61. So the design does not yet beat Van on both axes at full facings sit at p25 2.07 (full) and 2.87 (live), wrong ones at p75 1.00 and
resolution, and the accept line is provisional: it is tuned on Tier 1 and needs 1.66, so 2.0 is the boundary in both. Sweeping it at full resolution:
per-input-path calibration on Tier 2.
accept correct precision false accepts
2.0 59% 88% 5
3.0 52% 91% 3
4.0 49% 94% 2
6.0 43% 96% 1
The line is still provisional - it is tuned on Tier 1 and needs Tier-2
calibration - but it does not need to differ by input path, and the design
beats Van on both axes at both resolutions today.
The live count was pacing-dependent, and the cause was measurable: OCR ran on The live count was pacing-dependent, and the cause was measurable: OCR ran on
every crop at every attempt, taking a crop from 5 ms to 25 ms and throughput every crop at every attempt, taking a crop from 5 ms to 25 ms and throughput
@ -643,11 +653,47 @@ on `add/image6.jpg`:
The 12 s numbers are within run-to-run noise; the short clip is where the The 12 s numbers are within run-to-run noise; the short clip is where the
pacing sensitivity lived, and it is now flat across pacings. Precision stays pacing sensitivity lived, and it is now flat across pacings. Precision stays
100% and 0 unknowns are accepted at every pacing. Coverage is still a gate 100% and 0 unknowns are accepted at every pacing.
(G1): 53 of 131 streamed tracks is not yet a claim against Van's 57% of
labelled facings, because the two count different things - tracks the detector **The stream path now has a G1 number.** `code/score_stream.py` scores a
started versus labelled facings. The label-matched comparison is the recorded clip's labels against the ground truth, matching each track's
evaluator's, above. `label_box` to the labelled facings. Three defects in the first version of
that harness made the stream look far worse than it was, and all three are
fixed: it counted any facing touching the window by one pixel (23 of its 47
"in-catalog" facings were less than half visible), it scored a correct
text-lane answer as a false accept because it checked enrollment before
correctness, and it counted tracks rather than facings, which reported 138%
coverage of a 24-facing window. The denominator is now the facings at least
half inside the union of the window rects the client showed.
Measured through the real server on `add/image6.jpg`, 20 s per run, local
backend against the `van` lane on the same stream:
pan lane facings named precision
0 van 11/24 (46%) 52%
0 local 11/24 (46%) 100%
2 van 12/24 (50%) 45%
2 local 18/24 (75%) 100%
12 van 11/24 (46%) 28%
12 local 16/24 (67%) 83%
Two live-path bugs surfaced while measuring this, both fixed:
- **Inherited labels carried frame id 0.** `_inherit_labels` set `label_box`
but never `label_frame_id`, so every inherited label was mapped back to the
source image with the first frame's offset. During a pan that puts the box a
whole pan-distance from the product it names. It now stamps the current
frame and anchors to the raw detection box rather than the smoothed track
box, which lags a pan by about one step.
- **The reference lane upscaled views smaller than a pyramid level.**
`sku_l09.jpg` is 407x305, so its 400 px level was a 305->400 blow-up: a blur
with no high-frequency detail to disagree with, which won 31 of 54 crops on
the shelf it was cut from. A view is now used only at the levels it has.
Coverage is still a gate (G1). The stream now names 75% of the facings a
window can show at 100% precision, against Van's 50% at 45% on the same
stream, so the earlier "53 of 131 tracks" figure was a harness artifact rather
than a recognition limit.
The shipped default scale now has test coverage. `make_session` builds only The shipped default scale now has test coverage. `make_session` builds only
`matcher`-backend engines, so `scale == "local"` in `_on_classified` never ran `matcher`-backend engines, so `scale == "local"` in `_on_classified` never ran
@ -656,7 +702,7 @@ injects the lanes' evidence into the live path, and `LocalScaleTests` pins the
two jobs (a text-only read still labels the track; a text veto blocks a local two jobs (a text-only read still labels the track; a text veto blocks a local
accept). `DecisionPolicyTests` pins the veto/fill-in boundary, including the accept). `DecisionPolicyTests` pins the veto/fill-in boundary, including the
lane-order-vs-value case the rank re-sort broke. Both regressions were lane-order-vs-value case the rank re-sort broke. Both regressions were
re-injected to confirm the tests fail for the intended reason. Suite 251 -> 281. re-injected to confirm the tests fail for the intended reason. Suite 251 -> 291.
Two further review findings, both fixed and both measured: Two further review findings, both fixed and both measured: