fix: bind tag projection to each frame origin

This commit is contained in:
kiyo9w 2026-09-28 14:41:49 +09:00
parent 05cf32e1d9
commit d5a7a56dff
4 changed files with 146 additions and 35 deletions

View file

@ -189,8 +189,9 @@ class LiveSession:
self._catalog_by_sku: Dict[str, Dict[str, Any]] = {}
self._catalog_version = ""
self._tags = None
# Shelf position of this frame's origin. Updated from a content shift
# before the session lock; tag cadence keys rows by it. Not a shelf map.
# Shelf position of this frame's origin. Register and origin update
# share one gate so overlapping workers cannot reorder either.
self._shift_gate = threading.Lock()
self._frame_origin = [0.0, 0.0]
self._shift_map = None
self._compliance_tracker = None
@ -337,22 +338,23 @@ class LiveSession:
}
def reset(self) -> None:
with self._lock:
self._generation += 1
self.tracker.reset()
self._tracks.clear()
self._last_seen_seq.clear()
self._relabel_count.clear()
self._restart_count.clear()
self._slot_history.clear()
self._label_memory.clear()
self._slots = []
self._latest_image = None
self._latest_frame_id = None
self._frame_origin = [0.0, 0.0]
self._shift_map = None
# ponytail: do not touch _waiter here. asyncio.Event is loop-affine;
# take_pending_async clears a stale wake on its own loop.
with self._shift_gate:
with self._lock:
self._generation += 1
self.tracker.reset()
self._tracks.clear()
self._last_seen_seq.clear()
self._relabel_count.clear()
self._restart_count.clear()
self._slot_history.clear()
self._label_memory.clear()
self._slots = []
self._latest_image = None
self._latest_frame_id = None
self._frame_origin = [0.0, 0.0]
self._shift_map = None
# ponytail: do not touch _waiter here. asyncio.Event is loop-affine;
# take_pending_async clears a stale wake on its own loop.
# ------------------------------------------------------------------ frame path
def process_frame(self, frame_id: int, payload: bytes) -> Dict[str, Any]:
@ -367,7 +369,6 @@ class LiveSession:
empty_enabled = self.settings.empty_slots
generation = self._generation
shelf_offset = self._note_frame_shift(image)
boxes = self.engine.detect(image, conf=conf)
@ -495,7 +496,7 @@ class LiveSession:
summary = self._summary_locked()
stats = self._stats_locked()
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:
summary["price_tags"] = tag_summary["tags"]
summary["compliance"] = tag_summary["compliance"]
@ -996,16 +997,19 @@ class LiveSession:
def _note_frame_shift(self, image: Any) -> Tuple[float, float]:
"""Shelf position of this frame's origin. Phase correlation only.
Runs before the session lock. 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.
Register and origin update share one per-session gate, so overlapping
workers cannot reorder the previous-frame baseline or publish a later
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.
"""
if self._shift_map is None:
from shelf_map import ShelfMap
with self._shift_gate:
if self._shift_map is None:
from shelf_map import ShelfMap
self._shift_map = ShelfMap()
shift = self._shift_map.register(image)
with self._lock:
self._shift_map = ShelfMap()
shift = self._shift_map.register(image)
if shift is not None:
self._frame_origin[0] -= float(shift[0])
self._frame_origin[1] -= float(shift[1])
@ -1034,19 +1038,27 @@ class LiveSession:
LOGGER.warning("tag pass failed: %s", error)
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]]:
"""Attach tag + reconciliation to each track and compute compliance.
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
if service is None or not self.settings.tags:
return None
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]] = {}
for payload in track_payloads:
track_id = int(payload["track_id"])