fix: bind tag projection to each frame origin
This commit is contained in:
parent
05cf32e1d9
commit
d5a7a56dff
4 changed files with 146 additions and 35 deletions
|
|
@ -189,8 +189,9 @@ class LiveSession:
|
||||||
self._catalog_by_sku: Dict[str, Dict[str, Any]] = {}
|
self._catalog_by_sku: Dict[str, Dict[str, Any]] = {}
|
||||||
self._catalog_version = ""
|
self._catalog_version = ""
|
||||||
self._tags = None
|
self._tags = None
|
||||||
# Shelf position of this frame's origin. Updated from a content shift
|
# Shelf position of this frame's origin. Register and origin update
|
||||||
# before the session lock; tag cadence keys rows by it. Not a shelf map.
|
# share one gate so overlapping workers cannot reorder either.
|
||||||
|
self._shift_gate = threading.Lock()
|
||||||
self._frame_origin = [0.0, 0.0]
|
self._frame_origin = [0.0, 0.0]
|
||||||
self._shift_map = None
|
self._shift_map = None
|
||||||
self._compliance_tracker = None
|
self._compliance_tracker = None
|
||||||
|
|
@ -337,6 +338,7 @@ class LiveSession:
|
||||||
}
|
}
|
||||||
|
|
||||||
def reset(self) -> None:
|
def reset(self) -> None:
|
||||||
|
with self._shift_gate:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._generation += 1
|
self._generation += 1
|
||||||
self.tracker.reset()
|
self.tracker.reset()
|
||||||
|
|
@ -367,7 +369,6 @@ class LiveSession:
|
||||||
empty_enabled = self.settings.empty_slots
|
empty_enabled = self.settings.empty_slots
|
||||||
generation = self._generation
|
generation = self._generation
|
||||||
|
|
||||||
|
|
||||||
shelf_offset = self._note_frame_shift(image)
|
shelf_offset = self._note_frame_shift(image)
|
||||||
|
|
||||||
boxes = self.engine.detect(image, conf=conf)
|
boxes = self.engine.detect(image, conf=conf)
|
||||||
|
|
@ -495,7 +496,7 @@ class LiveSession:
|
||||||
summary = self._summary_locked()
|
summary = self._summary_locked()
|
||||||
stats = self._stats_locked()
|
stats = self._stats_locked()
|
||||||
track_payload = [self._track_payload(state) for state in self._tracks.values()]
|
track_payload = [self._track_payload(state) for state in self._tracks.values()]
|
||||||
tag_summary = self._apply_tags(seq, track_payload, tag_bands)
|
tag_summary = self._apply_tags(seq, track_payload, tag_bands, shelf_offset, image.shape)
|
||||||
if tag_summary is not None:
|
if tag_summary is not None:
|
||||||
summary["price_tags"] = tag_summary["tags"]
|
summary["price_tags"] = tag_summary["tags"]
|
||||||
summary["compliance"] = tag_summary["compliance"]
|
summary["compliance"] = tag_summary["compliance"]
|
||||||
|
|
@ -996,16 +997,19 @@ class LiveSession:
|
||||||
def _note_frame_shift(self, image: Any) -> Tuple[float, float]:
|
def _note_frame_shift(self, image: Any) -> Tuple[float, float]:
|
||||||
"""Shelf position of this frame's origin. Phase correlation only.
|
"""Shelf position of this frame's origin. Phase correlation only.
|
||||||
|
|
||||||
Runs before the session lock. A failed registration keeps the last origin
|
Register and origin update share one per-session gate, so overlapping
|
||||||
rather than inventing a jump. The content shift moves the origin the
|
workers cannot reorder the previous-frame baseline or publish a later
|
||||||
other way, same sign as ShelfMap.
|
origin first. Correlation stays outside the session lock and outside
|
||||||
|
OCR. A failed registration keeps the last origin rather than inventing
|
||||||
|
a jump. The content shift moves the origin the other way, same sign as
|
||||||
|
ShelfMap.
|
||||||
"""
|
"""
|
||||||
|
with self._shift_gate:
|
||||||
if self._shift_map is None:
|
if self._shift_map is None:
|
||||||
from shelf_map import ShelfMap
|
from shelf_map import ShelfMap
|
||||||
|
|
||||||
self._shift_map = ShelfMap()
|
self._shift_map = ShelfMap()
|
||||||
shift = self._shift_map.register(image)
|
shift = self._shift_map.register(image)
|
||||||
with self._lock:
|
|
||||||
if shift is not None:
|
if shift is not None:
|
||||||
self._frame_origin[0] -= float(shift[0])
|
self._frame_origin[0] -= float(shift[0])
|
||||||
self._frame_origin[1] -= float(shift[1])
|
self._frame_origin[1] -= float(shift[1])
|
||||||
|
|
@ -1034,19 +1038,27 @@ class LiveSession:
|
||||||
LOGGER.warning("tag pass failed: %s", error)
|
LOGGER.warning("tag pass failed: %s", error)
|
||||||
|
|
||||||
def _apply_tags(
|
def _apply_tags(
|
||||||
self, seq: int, track_payloads: List[Dict[str, Any]], bands: Sequence[Any]
|
self,
|
||||||
|
seq: int,
|
||||||
|
track_payloads: List[Dict[str, Any]],
|
||||||
|
bands: Sequence[Any],
|
||||||
|
shelf_offset: Sequence[float],
|
||||||
|
image_shape: Sequence[int],
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
"""Attach tag + reconciliation to each track and compute compliance.
|
"""Attach tag + reconciliation to each track and compute compliance.
|
||||||
|
|
||||||
Must be called with the session lock held (it reads the tag cache under its
|
Must be called with the session lock held (it reads the tag cache under its
|
||||||
own lock, which is never taken the other way round).
|
own lock, which is never taken the other way round). The origin and shape
|
||||||
|
are this frame's, not whatever `plan` last stored.
|
||||||
"""
|
"""
|
||||||
service = self._tags
|
service = self._tags
|
||||||
if service is None or not self.settings.tags:
|
if service is None or not self.settings.tags:
|
||||||
return None
|
return None
|
||||||
from reconcile import ComplianceTracker, evaluate_compliance, reconcile, summarize_findings
|
from reconcile import ComplianceTracker, evaluate_compliance, reconcile, summarize_findings
|
||||||
|
|
||||||
tag_by_track, all_tags, counts = service.attach(track_payloads)
|
tag_by_track, all_tags, counts = service.attach(
|
||||||
|
track_payloads, shelf_offset=shelf_offset, image_shape=image_shape
|
||||||
|
)
|
||||||
reconciliations: Dict[int, Dict[str, Any]] = {}
|
reconciliations: Dict[int, Dict[str, Any]] = {}
|
||||||
for payload in track_payloads:
|
for payload in track_payloads:
|
||||||
track_id = int(payload["track_id"])
|
track_id = int(payload["track_id"])
|
||||||
|
|
|
||||||
|
|
@ -788,16 +788,20 @@ class TagService:
|
||||||
Records live in shelf coordinates. They are projected into 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
|
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.
|
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
|
Returned boxes stay in screen pixels. A caller that omits `shelf_offset`
|
||||||
origin of the latest `plan` (a still, or a caller that has not panned).
|
is a still: screen coordinates, never another frame's stored origin.
|
||||||
|
|
||||||
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:
|
||||||
origin = _origin_of(shelf_offset if shelf_offset is not None else self._view_origin)
|
origin = _origin_of(shelf_offset)
|
||||||
shape = image_shape if image_shape is not None else self._view_shape
|
shape = (
|
||||||
|
(int(image_shape[0]), int(image_shape[1]))
|
||||||
|
if image_shape is not None
|
||||||
|
else self._view_shape
|
||||||
|
)
|
||||||
visible = _visible_records(list(self._tags.values()), origin, shape)
|
visible = _visible_records(list(self._tags.values()), origin, shape)
|
||||||
|
|
||||||
by_track: Dict[int, Dict[str, Any]] = {}
|
by_track: Dict[int, Dict[str, Any]] = {}
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,80 @@ class TagPassIntegrationTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
engine.shutdown()
|
engine.shutdown()
|
||||||
|
|
||||||
|
def test_overlapping_frames_keep_their_own_origin(self):
|
||||||
|
"""A later worker must not become the previous-frame baseline or steal origin."""
|
||||||
|
session, engine, _ = self.make_tagged_session()
|
||||||
|
|
||||||
|
class Ordered:
|
||||||
|
def __init__(self):
|
||||||
|
self.inside = 0
|
||||||
|
self.max_inside = 0
|
||||||
|
self.calls = 0
|
||||||
|
self._inner = threading.Lock()
|
||||||
|
|
||||||
|
def register(self, image):
|
||||||
|
with self._inner:
|
||||||
|
self.inside += 1
|
||||||
|
self.max_inside = max(self.max_inside, self.inside)
|
||||||
|
self.calls += 1
|
||||||
|
call = self.calls
|
||||||
|
time.sleep(0.03)
|
||||||
|
with self._inner:
|
||||||
|
self.inside -= 1
|
||||||
|
return (0.0, -400.0)
|
||||||
|
|
||||||
|
session._shift_map = Ordered()
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def run(frame_id):
|
||||||
|
try:
|
||||||
|
session.process_frame(frame_id, frame_bytes())
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(exc)
|
||||||
|
|
||||||
|
early = threading.Thread(target=run, args=(1,))
|
||||||
|
late = threading.Thread(target=run, args=(2,))
|
||||||
|
early.start()
|
||||||
|
late.start()
|
||||||
|
early.join(2.0)
|
||||||
|
late.join(2.0)
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
self.assertFalse(early.is_alive() or late.is_alive())
|
||||||
|
self.assertEqual(session._shift_map.calls, 2)
|
||||||
|
self.assertEqual(session._shift_map.max_inside, 1)
|
||||||
|
self.assertEqual(session._frame_origin[1], 800.0)
|
||||||
|
engine.shutdown()
|
||||||
|
|
||||||
|
def test_cached_tag_projects_at_this_frame_origin(self):
|
||||||
|
"""A panned frame must not attach a tag that only overlaps the previous origin."""
|
||||||
|
from tags import TagRecord
|
||||||
|
|
||||||
|
session, engine, _ = self.make_tagged_session()
|
||||||
|
record = TagRecord(
|
||||||
|
tag_id="cached", box=[150, 700, 190, 760], band_index=0, name="cached", price=150,
|
||||||
|
flags=["tax_consistent"],
|
||||||
|
)
|
||||||
|
record.settled = True
|
||||||
|
session._tags._tags[record.tag_id] = record
|
||||||
|
|
||||||
|
class Panned:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def register(self, image):
|
||||||
|
self.calls += 1
|
||||||
|
return (0.0, -400.0) if self.calls == 2 else (0.0, 0.0)
|
||||||
|
|
||||||
|
session._shift_map = Panned()
|
||||||
|
first = session.process_frame(1, frame_bytes())
|
||||||
|
self.assertFalse(any(track.get("price_tag", {}).get("id") for track in first["tracks"]))
|
||||||
|
second = session.process_frame(2, frame_bytes())
|
||||||
|
bound = [track["price_tag"] for track in second["tracks"] if track.get("price_tag", {}).get("id")]
|
||||||
|
self.assertTrue(bound)
|
||||||
|
self.assertGreaterEqual(bound[0]["box"][1], 200)
|
||||||
|
self.assertLess(bound[0]["box"][1], 480)
|
||||||
|
engine.shutdown()
|
||||||
|
|
||||||
|
|
||||||
class FramePipelineTests(unittest.TestCase):
|
class FramePipelineTests(unittest.TestCase):
|
||||||
def test_latest_frame_wins_and_drops_are_counted(self):
|
def test_latest_frame_wins_and_drops_are_counted(self):
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,27 @@ class TagServiceTests(unittest.TestCase):
|
||||||
self.assertEqual(by_track[9]["state"], TAG_STATE_ABSENT)
|
self.assertEqual(by_track[9]["state"], TAG_STATE_ABSENT)
|
||||||
self.assertIsNone(by_track[9]["id"])
|
self.assertIsNone(by_track[9]["id"])
|
||||||
|
|
||||||
|
def test_attach_does_not_use_the_latest_plan_origin(self):
|
||||||
|
"""A later plan must not project this attach through its stored origin."""
|
||||||
|
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])
|
||||||
|
service.plan(boxes, array.shape, shelf_offset=(0.0, 400.0))
|
||||||
|
by_track, tags, _ = service.attach(
|
||||||
|
[{"track_id": 0, "box": [100, 100, 200, 300]}],
|
||||||
|
image_shape=array.shape,
|
||||||
|
)
|
||||||
|
self.assertTrue(tags, "omitting shelf_offset is screen coordinates, not the panned plan origin")
|
||||||
|
self.assertEqual(by_track[0]["state"], TAG_STATE_READABLE)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue