fix: anchor OCR passes and tags to shelf coordinates

This commit is contained in:
kiyo9w 2026-09-28 13:41:12 +09:00
parent 06f9fb4819
commit f0e2baaf3d
2 changed files with 191 additions and 42 deletions

View file

@ -84,11 +84,19 @@ _TAX_RE = re.compile(r"税(込|抜|不|別)")
@dataclass @dataclass
class RailBand: class RailBand:
"""One horizontal strip of shelf rail, derived from a product row.""" """One horizontal strip of shelf rail, derived from a product row.
`box` is this frame's pixels (the crop `run_pass` reads). `origin` is the
shelf position of the frame origin at the moment `plan` chose the band, and
`key` is the rail that origin names. Both travel with the band: a later
frame's origin must not relabel a pass that is still queued.
"""
box: List[int] box: List[int]
row_index: int row_index: int
product_ids: List[int] = field(default_factory=list) product_ids: List[int] = field(default_factory=list)
origin: Optional[Tuple[float, float]] = None
key: Optional[Tuple[str, int]] = None
# The field model lives with the card reader; the pipeline consumes it unchanged. # The field model lives with the card reader; the pipeline consumes it unchanged.
@ -439,6 +447,9 @@ class TagService:
self.passes_failed = 0 self.passes_failed = 0
self.last_pass_error: Optional[str] = None self.last_pass_error: Optional[str] = None
self.passes_skipped = 0 self.passes_skipped = 0
# Latest frame only. A queued pass must not read these; its origin is on the band.
self._view_origin: Optional[Tuple[float, float]] = None
self._view_shape: Optional[Tuple[int, int]] = None
# ------------------------------------------------------------------ frame loop # ------------------------------------------------------------------ frame loop
def plan( def plan(
@ -462,7 +473,8 @@ class TagService:
width = float(image_shape[1]) width = float(image_shape[1])
bands = [band for band in bands if (band.box[2] - band.box[0]) >= self.settings.min_band_width_ratio * width] bands = [band for band in bands if (band.box[2] - band.box[0]) >= self.settings.min_band_width_ratio * width]
with self._lock: with self._lock:
self._shelf_offset = shelf_offset self._view_origin = _origin_of(shelf_offset)
self._view_shape = (int(image_shape[0]), int(image_shape[1]))
self._frame_seq += 1 self._frame_seq += 1
seq = self._frame_seq seq = self._frame_seq
chosen: List[RailBand] = [] chosen: List[RailBand] = []
@ -481,6 +493,8 @@ class TagService:
break break
for band in chosen: for band in chosen:
key = band_key(band, shelf_offset) key = band_key(band, shelf_offset)
band.origin = _origin_of(shelf_offset)
band.key = key
self._last_pass_seq[key] = seq self._last_pass_seq[key] = seq
self._pending_bands.add(key) self._pending_bands.add(key)
return chosen return chosen
@ -492,7 +506,7 @@ class TagService:
independent reads of the same tag are what makes the vote meaningful independent reads of the same tag are what makes the vote meaningful
(identical pixels would produce identical output). (identical pixels would produce identical output).
""" """
key = band_key(band, getattr(self, "_shelf_offset", None)) key = _planned_key(band)
try: try:
x1, y1, x2, y2 = band.box x1, y1, x2, y2 = band.box
crop = image[y1:y2, x1:x2] crop = image[y1:y2, x1:x2]
@ -632,13 +646,19 @@ class TagService:
return all(tag.settled for tag in row_tags) return all(tag.settled for tag in row_tags)
def _absorb(self, candidate: Dict[str, Any], band: RailBand, pass_id: int) -> TagObservation: def _absorb(self, candidate: Dict[str, Any], band: RailBand, pass_id: int) -> TagObservation:
"""Fold one clustered tag into the cache; must be called with the lock held.""" """Fold one clustered tag into the cache; must be called with the lock held.
The candidate box is this frame's pixels. The record keeps shelf pixels,
using the origin `plan` stamped on the band, so a later pan cannot make
this read land on a different rail.
"""
box = candidate["box"] box = candidate["box"]
lines = candidate["lines"] lines = candidate["lines"]
fields = candidate["fields"] fields = candidate["fields"]
shelf_key = band_key(band, getattr(self, "_shelf_offset", None)) shelf_key = _planned_key(band)
shelf_box = _to_shelf(box, band.origin)
observation = TagObservation( observation = TagObservation(
box=list(box), box=list(shelf_box),
lines=list(lines), lines=list(lines),
fields=fields, fields=fields,
band_index=band.row_index, band_index=band.row_index,
@ -646,11 +666,11 @@ class TagService:
observed_at=time.time(), observed_at=time.time(),
) )
record = self._match_record(box, shelf_key) record = self._match_record(shelf_box, shelf_key)
if record is None: if record is None:
record = TagRecord( record = TagRecord(
tag_id=f"tag_{uuid.uuid4().hex[:8]}", tag_id=f"tag_{uuid.uuid4().hex[:8]}",
box=list(box), box=list(shelf_box),
band_index=band.row_index, band_index=band.row_index,
shelf_key=shelf_key, shelf_key=shelf_key,
first_seen=observation.observed_at, first_seen=observation.observed_at,
@ -658,7 +678,7 @@ class TagService:
if len(self._tags) >= self.settings.max_tags: if len(self._tags) >= self.settings.max_tags:
return observation return observation
self._tags[record.tag_id] = record self._tags[record.tag_id] = record
record.box = list(box) record.box = list(shelf_box)
record.last_seen = observation.observed_at record.last_seen = observation.observed_at
record.observations.append(observation) record.observations.append(observation)
record.passes += 1 record.passes += 1
@ -758,52 +778,47 @@ class TagService:
# ------------------------------------------------------------------ attach # ------------------------------------------------------------------ attach
def attach( def attach(
self, tracks: Sequence[Dict[str, Any]] self,
tracks: Sequence[Dict[str, Any]],
shelf_offset: Optional[Sequence[float]] = None,
image_shape: Optional[Sequence[int]] = None,
) -> Tuple[Dict[int, Dict[str, Any]], List[Dict[str, Any]], Dict[str, int]]: ) -> Tuple[Dict[int, Dict[str, Any]], List[Dict[str, Any]], Dict[str, int]]:
"""Give each track its tag for this frame. """Give each track its tag for this frame.
Records live in shelf coordinates. They are projected into this frame
before association, and a tag that does not overlap the frame is left
out, so an old tag cannot bind a new facing that reused its screen box.
Returned boxes stay in screen pixels. `shelf_offset` defaults to the
origin of the latest `plan` (a still, or a caller that has not panned).
Returns (tag_by_track_id, all_tags, state_counts). A track whose tag was Returns (tag_by_track_id, all_tags, state_counts). A track whose tag was
never read gets state 'unreadable' when a tag was found near it, and never read gets state 'unreadable' when a tag was found near it, and
'absent' when nothing tag-shaped was seen there. 'absent' when nothing tag-shaped was seen there.
""" """
with self._lock: with self._lock:
tags = [record.as_dict() for record in self._tags.values()] origin = _origin_of(shelf_offset if shelf_offset is not None else self._view_origin)
records = list(self._tags.values()) shape = image_shape if image_shape is not None else self._view_shape
visible = _visible_records(list(self._tags.values()), origin, shape)
by_track: Dict[int, Dict[str, Any]] = {} by_track: Dict[int, Dict[str, Any]] = {}
used: set = set() used: set = set()
records = [record for record, _screen in visible]
screen_of = {record.tag_id: screen for record, screen in visible}
tags = [_screen_dict(record, screen_of[record.tag_id]) for record in records]
for track in tracks: for track in tracks:
box = track.get("box") or track.get("raw_box") box = track.get("box") or track.get("raw_box")
if not box: if not box:
continue continue
record = _tag_below(box, records, used) record = _tag_below(box, records, used, screen_of)
if record is not None: if record is not None:
used.add(record.tag_id) used.add(record.tag_id)
payload = record.as_dict() payload = _screen_dict(record, screen_of[record.tag_id])
payload["associated"] = True payload["associated"] = True
by_track[int(track["track_id"])] = payload by_track[int(track["track_id"])] = payload
elif self._band_had_readable(record=None, box=box, records=records): elif self._band_had_readable(record=None, box=box, records=records, screen_of=screen_of):
by_track[int(track["track_id"])] = { by_track[int(track["track_id"])] = _empty_tag(TAG_STATE_UNREADABLE)
"id": None,
"box": None,
"state": TAG_STATE_UNREADABLE,
"price": None,
"name": None,
"confidence": 0.0,
"observations": 0,
"settled": False,
}
else: else:
by_track[int(track["track_id"])] = { by_track[int(track["track_id"])] = _empty_tag(TAG_STATE_ABSENT)
"id": None,
"box": None,
"state": TAG_STATE_ABSENT,
"price": None,
"name": None,
"confidence": 0.0,
"observations": 0,
"settled": False,
}
counts = { counts = {
TAG_STATE_READABLE: 0, TAG_STATE_READABLE: 0,
@ -815,13 +830,20 @@ class TagService:
counts[payload["state"]] = counts.get(payload["state"], 0) + 1 counts[payload["state"]] = counts.get(payload["state"], 0) + 1
return by_track, tags, counts return by_track, tags, counts
def _band_had_readable(self, record, box: Sequence[int], records: Sequence[TagRecord]) -> bool: def _band_had_readable(
self,
record,
box: Sequence[int],
records: Sequence[TagRecord],
screen_of: Optional[Dict[str, List[int]]] = None,
) -> bool:
"""Was there any tag-shaped evidence near this facing (readable or not)?""" """Was there any tag-shaped evidence near this facing (readable or not)?"""
x1, y1, x2, y2 = box x1, y1, x2, y2 = box
width = max(1, x2 - x1) width = max(1, x2 - x1)
for candidate in records: for candidate in records:
cx = (candidate.box[0] + candidate.box[2]) / 2.0 shown = (screen_of or {}).get(candidate.tag_id, candidate.box)
cy = (candidate.box[1] + candidate.box[3]) / 2.0 cx = (shown[0] + shown[2]) / 2.0
cy = (shown[1] + shown[3]) / 2.0
if x1 - width * 0.3 <= cx <= x2 + width * 0.3 and y1 <= cy <= y2 + (y2 - y1) * 0.5: if x1 - width * 0.3 <= cx <= x2 + width * 0.3 and y1 <= cy <= y2 + (y2 - y1) * 0.5:
return True return True
return False return False
@ -858,7 +880,8 @@ class TagService:
self._pending_bands.clear() self._pending_bands.clear()
self._pass_id = 0 self._pass_id = 0
self._frame_seq = 0 self._frame_seq = 0
self._shelf_offset = None self._view_origin = None
self._view_shape = None
def _iou(a: Sequence[int], b: Sequence[int]) -> float: def _iou(a: Sequence[int], b: Sequence[int]) -> float:
@ -876,8 +899,17 @@ def _iou(a: Sequence[int], b: Sequence[int]) -> float:
return inter / union if union > 0 else 0.0 return inter / union if union > 0 else 0.0
def _tag_below(box: Sequence[int], records: Sequence[TagRecord], used: set) -> Optional[TagRecord]: def _tag_below(
"""The tag that belongs to this facing: below it, horizontally overlapping.""" box: Sequence[int],
records: Sequence[TagRecord],
used: set,
screen_of: Optional[Dict[str, List[int]]] = None,
) -> Optional[TagRecord]:
"""The tag that belongs to this facing: below it, horizontally overlapping.
`screen_of` is the record projected into the current frame. Without it the
stored shelf box is used, which is the screen box when no origin was known.
"""
x1, y1, x2, y2 = box x1, y1, x2, y2 = box
width = max(1, x2 - x1) width = max(1, x2 - x1)
height = max(1, y2 - y1) height = max(1, y2 - y1)
@ -886,7 +918,7 @@ def _tag_below(box: Sequence[int], records: Sequence[TagRecord], used: set) -> O
for record in records: for record in records:
if record.tag_id in used: if record.tag_id in used:
continue continue
rx1, ry1, rx2, ry2 = record.box rx1, ry1, rx2, ry2 = (screen_of or {}).get(record.tag_id, record.box)
center_x = (rx1 + rx2) / 2.0 center_x = (rx1 + rx2) / 2.0
overlap = max(0, min(x2, rx2) - max(x1, rx1)) overlap = max(0, min(x2, rx2) - max(x1, rx1))
overlap_ratio = overlap / max(1, rx2 - rx1) overlap_ratio = overlap / max(1, rx2 - rx1)
@ -907,6 +939,73 @@ def _tag_below(box: Sequence[int], records: Sequence[TagRecord], used: set) -> O
return best return best
def _origin_of(shelf_offset: Optional[Sequence[float]]) -> Optional[Tuple[float, float]]:
if not shelf_offset or len(shelf_offset) < 2:
return None
return (float(shelf_offset[0]), float(shelf_offset[1]))
def _planned_key(band: RailBand) -> Tuple[str, int]:
"""The rail `plan` chose. A band built without `plan` falls back to its screen row."""
if band.key is not None:
return band.key
return band_key(band, band.origin)
def _shift_box(box: Sequence[int], dx: float, dy: float) -> List[int]:
return [int(round(box[0] + dx)), int(round(box[1] + dy)), int(round(box[2] + dx)), int(round(box[3] + dy))]
def _to_shelf(box: Sequence[int], origin: Optional[Tuple[float, float]]) -> List[int]:
if origin is None:
return [int(v) for v in box]
return _shift_box(box, origin[0], origin[1])
def _to_screen(box: Sequence[int], origin: Optional[Tuple[float, float]]) -> List[int]:
if origin is None:
return [int(v) for v in box]
return _shift_box(box, -origin[0], -origin[1])
def _overlaps_frame(box: Sequence[int], image_shape: Sequence[int]) -> bool:
height, width = int(image_shape[0]), int(image_shape[1])
return box[2] > 0 and box[0] < width and box[3] > 0 and box[1] < height
def _visible_records(
records: Sequence[TagRecord],
origin: Optional[Tuple[float, float]],
image_shape: Optional[Sequence[int]],
) -> List[Tuple[TagRecord, List[int]]]:
shown: List[Tuple[TagRecord, List[int]]] = []
for record in records:
screen = _to_screen(record.box, origin)
if image_shape is not None and not _overlaps_frame(screen, image_shape):
continue
shown.append((record, screen))
return shown
def _screen_dict(record: TagRecord, screen_box: Sequence[int]) -> Dict[str, Any]:
payload = record.as_dict()
payload["box"] = list(screen_box)
return payload
def _empty_tag(state: str) -> Dict[str, Any]:
return {
"id": None,
"box": None,
"state": state,
"price": None,
"name": None,
"confidence": 0.0,
"observations": 0,
"settled": False,
}
if __name__ == "__main__": # smoke: python code/tags.py <image> if __name__ == "__main__": # smoke: python code/tags.py <image>
import argparse import argparse

View file

@ -335,6 +335,56 @@ class TagServiceTests(unittest.TestCase):
self.assertGreaterEqual(service.stats()["passes_run"], 1) self.assertGreaterEqual(service.stats()["passes_run"], 1)
self.assertIsNone(service.stats()["last_pass_error"]) self.assertIsNone(service.stats()["last_pass_error"])
def test_a_queued_pass_keeps_the_shelf_it_was_planned_on(self):
"""OCR that finishes after a pan must clear its own rail, not the new one."""
service, _ = self.make(pass_interval_frames=0, settle_observations=1, settle_passes=1)
boxes = [box(100, 100, 200, 300, 0), box(220, 100, 320, 300, 1), box(340, 100, 440, 300, 2)]
array = image()
planned = service.plan(boxes, array.shape, shelf_offset=(0.0, 0.0))
self.assertEqual(service.stats()["pending_bands"], 1)
moved = service.plan(boxes, array.shape, shelf_offset=(0.0, 400.0))
self.assertTrue(moved, "the new shelf is a different rail and must be queued too")
self.assertEqual(service.stats()["pending_bands"], 2)
service.run_pass(array, planned[0])
self.assertEqual(service.stats()["pending_bands"], 1, "the finished pass clears only its own key")
self.assertEqual(service._tags[next(iter(service._tags))].shelf_key[0], "shelf")
self.assertNotEqual(
service._tags[next(iter(service._tags))].shelf_key,
moved[0].key,
"the late read must not be filed under the shelf now in frame",
)
service.run_pass(array, moved[0])
self.assertEqual(service.stats()["pending_bands"], 0)
self.assertEqual(len({tag.shelf_key for tag in service._tags.values()}), 2)
def test_an_offscreen_tag_does_not_bind_the_new_facing(self):
"""A pan that reuses screen coordinates must not hand the old tag to the new facing."""
service, _ = self.make(pass_interval_frames=0, settle_observations=1, settle_passes=1)
boxes = [box(100, 100, 200, 300, 0), box(220, 100, 320, 300, 1), box(340, 100, 440, 300, 2)]
array = image()
first = service.plan(boxes, array.shape, shelf_offset=(0.0, 0.0))
service.run_pass(array, first[0])
by_track, tags, _ = service.attach(
[{"track_id": 0, "box": [100, 100, 200, 300]}],
shelf_offset=(0.0, 0.0),
image_shape=array.shape,
)
self.assertEqual(by_track[0]["state"], TAG_STATE_READABLE)
self.assertEqual(by_track[0]["price"], 269)
self.assertGreaterEqual(tags[0]["box"][1], 0)
self.assertLess(tags[0]["box"][1], array.shape[0])
again = service.plan(boxes, array.shape, shelf_offset=(0.0, 400.0))
self.assertTrue(again)
by_track, tags, _ = service.attach(
[{"track_id": 9, "box": [100, 100, 200, 300]}],
shelf_offset=(0.0, 400.0),
image_shape=array.shape,
)
self.assertEqual(tags, [], "the old tag is above this frame")
self.assertEqual(by_track[9]["state"], TAG_STATE_ABSENT)
self.assertIsNone(by_track[9]["id"])
class StorePolicyTests(unittest.TestCase): class StorePolicyTests(unittest.TestCase):
def test_a_missing_file_uses_the_defaults(self): def test_a_missing_file_uses_the_defaults(self):