feat: a shelf map, so a facing outlives the track id that found it

The camera moves and the shelf does not, but every measurement so far treats a
frame as the world: a facing is "a track id", and that id dies the moment the camera
pans away and comes back. Label inheritance, drift checks and restart counters were
each correct fixes for that, and all of them are symptoms.

`shelf_map.py` keeps a per-session map of the shelf plane. Frames are registered by
their content shift, detections are placed in shelf coordinates, and a detection that
lands where a facing already is merges into it - so identity votes and tag readings
pool on the physical facing.

Measured, and reported honestly:

- Registration is exact on the synthetic walk: 0.01 px median error against the demo
  camera's own offsets, over 23 consecutive pairs, with both phase correlation and
  ORB + RANSAC. That validates the harness, not the method - the walk is a pure
  translation of one photograph. Real video needs step 1's footage.
- Pooling works: a facing seen 15 times across 2 track ids reports one identity at
  1.00 agreement.
- De-duplication is NOT yet better than the tracker: 179 facings against 156 track
  ids, where the shelf holds roughly 70. Both over-count, the map slightly more.

Two bugs were found by measuring, and both were silent: the content shift is the
negation of the camera's motion (getting it backwards turned ~60 facings into 857),
and matching purely by box overlap lets jitter split a facing (210 facings, 103 of
them seen once; matching by track continuity first brought it to 179 with 38).

175 tests pass.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kiyo9w 2026-09-25 01:54:38 +09:00
parent a718acdc15
commit b451e7096f
3 changed files with 550 additions and 0 deletions

View file

@ -0,0 +1,125 @@
"""
Tests for the shelf map: registration, merging, and evidence pooling.
The sign convention is tested explicitly, because getting it backwards is silent:
the map still produces boxes, they just land in the wrong place, and a shelf of
~60 facings becomes 857. That happened during development.
"""
from __future__ import annotations
import os
import sys
import unittest
import numpy as np
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
if os.path.exists(os.path.join(project_root, "stock")):
os.chdir(project_root)
if current_dir not in sys.path:
sys.path.insert(0, current_dir)
from shelf_map import Facing, ShelfMap, _iou # noqa: E402
def a_frame(shift_x=0, shift_y=0, size=(160, 240)):
"""A textured frame, optionally a shifted copy of another."""
height, width = size
rng = np.random.RandomState(7)
base = rng.randint(0, 255, (height * 2, width * 2, 3), dtype=np.uint8)
return base[height - shift_y:height * 2 - shift_y, width - shift_x:width * 2 - shift_x]
def a_track(track_id, box, sku=None, score=None, tag_sku=None):
track = {"track_id": track_id, "box": list(box)}
if sku:
track["identity"] = {"state": "recognized", "sku_id": sku, "score": score or 1.0}
else:
track["identity"] = {"state": "unconfirmed"}
if tag_sku:
track["tag_identity"] = {"sku_id": tag_sku, "score": 0.7}
return track
class IouTests(unittest.TestCase):
def test_overlap_maths(self):
self.assertAlmostEqual(_iou([0, 0, 10, 10], [0, 0, 10, 10]), 1.0)
self.assertEqual(_iou([0, 0, 10, 10], [20, 20, 30, 30]), 0.0)
class RegistrationTests(unittest.TestCase):
def test_the_frame_origin_moves_opposite_to_the_content(self):
"""The sign that matters: content moving left means the camera moved right.
Getting this backwards walks the map the wrong way. It is silent - boxes
still appear - and it turned a shelf of ~60 facings into 857.
"""
shelf = ShelfMap()
shelf.register(a_frame())
shift = shelf.register(a_frame(shift_x=10)) # content moved right 10
self.assertIsNotNone(shift)
self.assertAlmostEqual(shift[0], 10, delta=1.0)
# Content moving right means the camera moved LEFT, so the frame's origin
# sits 10 px further back along the shelf.
shelf.update(0, a_frame(), [a_track(1, [0, 0, 20, 20])])
offset_before = list(shelf._offset)
shelf.update(1, a_frame(shift_x=10), [a_track(2, [0, 0, 20, 20])])
self.assertLess(shelf._offset[0], offset_before[0] + 1,
"the offset must follow the camera, not the content")
def test_a_first_frame_has_no_shift(self):
shelf = ShelfMap()
self.assertEqual(shelf.register(a_frame()), (0.0, 0.0))
class MergingTests(unittest.TestCase):
def test_a_facing_seen_twice_is_one_facing(self):
shelf = ShelfMap()
shelf.update(0, a_frame(), [a_track(1, [10, 10, 110, 200])])
shelf.update(1, a_frame(), [a_track(2, [12, 11, 112, 201])])
self.assertEqual(len(shelf.facings()), 1)
self.assertEqual(shelf.stats()["merges"], 1)
def test_two_neighbouring_facings_stay_separate(self):
shelf = ShelfMap()
shelf.update(0, a_frame(), [a_track(1, [10, 10, 100, 200]), a_track(2, [110, 10, 200, 200])])
self.assertEqual(len(shelf.facings()), 2)
def test_identity_votes_pool_across_track_ids(self):
"""The point of the map: one facing, many transient track ids, one answer."""
shelf = ShelfMap()
for index, (track_id, sku) in enumerate([(1, "sku_a"), (2, "sku_a"), (3, "sku_a"), (4, "sku_b")]):
shelf.update(index, a_frame(), [a_track(track_id, [10, 10, 110, 200], sku=sku)])
facing = shelf.facings()[0]
self.assertEqual(facing.observations, 4)
self.assertEqual(len(facing.seen_track_ids), 4, "four tracks, one facing")
sku_id, agreement = facing.best_identity()
self.assertEqual(sku_id, "sku_a")
self.assertAlmostEqual(agreement, 0.75)
def test_tag_evidence_pools_separately_from_packaging(self):
shelf = ShelfMap()
shelf.update(0, a_frame(), [a_track(1, [10, 10, 110, 200], sku="sku_a", tag_sku="sku_a")])
shelf.update(1, a_frame(), [a_track(2, [10, 10, 110, 200], tag_sku="sku_a")])
facing = shelf.facings()[0]
self.assertEqual(facing.best_identity()[0], "sku_a")
self.assertEqual(facing.best_tag_identity()[0], "sku_a")
self.assertEqual(len(facing.tag_readings), 0, "no readable tag payload was attached")
class PayloadTests(unittest.TestCase):
def test_a_facing_reports_its_pooled_evidence(self):
facing = Facing(facing_id="face_1", shelf_box=[0, 0, 10, 10])
facing.vote_identity("sku_a", 2.0)
facing.vote_identity("sku_b", 1.0)
payload = facing.as_payload()
self.assertEqual(payload["sku_id"], "sku_a")
self.assertAlmostEqual(payload["identity_agreement"], 2 / 3, places=3)
self.assertIsNone(payload["tag_sku_id"])
if __name__ == "__main__":
unittest.main()