""" Contract tests for the live session. Everything runs against a fake engine, so these tests are fast and deterministic. They defend the behaviours a live shelf view depends on: labels that appear without blocking frames, labels that never silently change, restarted ids that are re-verified rather than inherited, and backpressure that always keeps the newest frame. """ from __future__ import annotations import os import asyncio import sys import threading import time import unittest current_dir = os.path.dirname(os.path.abspath(__file__)) if current_dir not in sys.path: sys.path.insert(0, current_dir) import cv2 # noqa: E402 import numpy as np # noqa: E402 from engine import RecognitionEngine # noqa: E402 from live import MAX_CROP_BYTES, LiveSession, LiveSettings # noqa: E402 class FakeDetector: weights_path = "fake.pt" def __init__(self): self.boxes = [{"id": 0, "box": [100, 100, 200, 260], "confidence": 0.9, "class_id": 0, "width": 100, "height": 160, "center": [150, 180]}] def detect(self, image, conf_threshold=0.25, iou_threshold=0.45): return {"status": "success", "boxes": [dict(b) for b in self.boxes]} def detect_empty_slots(self, image_shape, boxes, min_gap_ratio=0.75): return [] class FakeMatcher: def __init__(self, sku="sku_a", score=40.0, delay=0.0): self.sku = sku self.score = score self.delay = delay self.calls = 0 self.lock = threading.Lock() def score_crop(self, crop, method="hybrid"): with self.lock: self.calls += 1 if self.delay: time.sleep(self.delay) # The runner-up stays far below the winner so the decision is about the # configured score, not about ambiguity. return {self.sku: self.score, "other": 0.1}, {} def score_crop_fast(self, crop, shortlist=5): return self.score_crop(crop) class CosineEngine(RecognitionEngine): """A gallery-scale engine: score_crop only returns scores the gallery accepted.""" @property def identity_scale(self): return "cosine" def score_crop(self, crop, method=None): scores, _ = self.matcher.score_crop(crop, method or "hybrid") return scores class FakeInventory: """Stateful stand-in for InventoryManager, so stock assertions are real.""" def __init__(self): self.products = { "sku_a": {"sku_id": "sku_a", "product_name": "Product sku_a", "price": 150.0, "number_in": 10, "number_out": 1, "number_on": 9, "receipt_date": "", "expiration_date": "", "local_image_path": None}, "other": {"sku_id": "other", "product_name": "Product other", "price": 150.0, "number_in": 10, "number_out": 1, "number_on": 9, "receipt_date": "", "expiration_date": "", "local_image_path": None}, } def list_all_skus(self): return list(self.products) def get_product(self, sku_id): return self.products.get(sku_id) def update_stock(self, sku_id, delta): product = self.products.get(sku_id) if product is None: return False product["number_on"] = max(0, product["number_on"] + delta) if delta < 0: product["number_out"] += abs(delta) else: product["number_in"] += delta return True def make_session(matcher=None, settings=None, engine_cls=RecognitionEngine): detector = FakeDetector() matcher = matcher or FakeMatcher() engine = engine_cls(detector, matcher, FakeInventory(), settings=__import__("engine").EngineSettings(classify_workers=2)) session = LiveSession("test", engine, settings=settings or LiveSettings(method="hybrid")) session.refresh_catalog() return session, engine, detector, matcher def frame_bytes(width=640, height=480): ok, buffer = cv2.imencode(".jpg", np.zeros((height, width, 3), dtype="uint8")) return buffer.tobytes() def process_payload(session, frame_id, payload): session.submit_frame(frame_id, payload) pending = session.take_pending(timeout=1.0) assert pending is not None, "the session dropped a frame it should have kept" return session.process_frame(*pending) def process(session, frame_id=1): return process_payload(session, frame_id, frame_bytes()) def blurred_frame_bytes(width=640, height=480, value=120): """A flat grey capture: the whole frame is out of focus, not just a facing.""" ok, buffer = cv2.imencode(".jpg", np.full((height, width, 3), value, dtype="uint8")) return buffer.tobytes() def wait_for(predicate, timeout=2.0): deadline = time.time() + timeout while time.time() < deadline: if predicate(): return True time.sleep(0.01) return False class LabelLifecycleTests(unittest.TestCase): def test_first_frame_is_detected_then_labelled(self): session, engine, _, matcher = make_session() first = process(session, 1) self.assertEqual(first["tracks"][0]["identity"]["state"], "unconfirmed") self.assertIsNone(first["tracks"][0]["identity"]["sku_id"]) process(session, 2) # second frame: eligible for classification self.assertTrue(wait_for(lambda: matcher.calls > 0), "classification should be scheduled") self.assertTrue(wait_for(lambda: any( track["identity"]["state"] == "recognized" for track in process(session, 3)["tracks"] )), "the product should be labelled once classified") engine.shutdown() def test_label_is_never_shown_without_acceptance(self): session, engine, _, _ = make_session(FakeMatcher(score=0.4)) # below the accept threshold for frame_id in range(1, 8): result = process(session, frame_id) for track in result["tracks"]: self.assertIsNone(track["identity"]["sku_id"], "a weak match must never name a SKU") engine.shutdown() def test_weak_match_ends_as_needs_confirmation(self): session, engine, _, _ = make_session(FakeMatcher(score=0.4)) for frame_id in range(1, 8): process(session, frame_id) result = process(session, 8) self.assertTrue(wait_for(lambda: process(session, 9)["tracks"][0]["identity"]["state"] == "needs_confirmation", timeout=3.0)) engine.shutdown() def test_restarted_id_is_re_verified_not_inherited(self): session, engine, detector, matcher = make_session() for frame_id in range(1, 5): process(session, frame_id) self.assertTrue(wait_for(lambda: process(session, 5)["tracks"][0]["identity"]["state"] == "recognized")) first_relabels = process(session, 6)["tracks"][0]["relabels"] detector.boxes = [] # the product leaves the frame for frame_id in range(7, 14): process(session, frame_id) detector.boxes = [{"id": 0, "box": [100, 100, 200, 260], "confidence": 0.9, "class_id": 0, "width": 100, "height": 160, "center": [150, 180]}] result = process(session, 14) track = result["tracks"][0] self.assertGreaterEqual(track["relabels"], first_relabels + 1, "a restarted id must count as re-verified") self.assertGreaterEqual(track["restarts"], 1) engine.shutdown() def test_inherited_label_is_flagged(self): """A fresh track covering a labelled product inherits the label, and says so.""" session, engine, detector, matcher = make_session() for frame_id in range(1, 5): process(session, frame_id) self.assertTrue(wait_for(lambda: process(session, 5)["tracks"][0]["identity"]["state"] == "recognized")) # same place, new detection id => the tracker treats it as a new object detector.boxes = [{"id": 0, "box": [102, 100, 202, 260], "confidence": 0.9, "class_id": 0, "width": 100, "height": 160, "center": [152, 180]}] result = process(session, 6) self.assertTrue(result["tracks"][0]["inherited"] or result["tracks"][0]["identity"]["state"] == "recognized") engine.shutdown() class CosineScaleTests(unittest.TestCase): """A gallery-scale score is gated by the gallery, not re-gated by the live path.""" def test_a_head_scale_match_is_labelled(self): """0.45 clears the head's own 0.40 line but not COSINE_ACCEPT (0.60). score_crop only returns scores the gallery already accepted, so the live aggregate must not re-apply the cosine accept line: that refused every head match the gallery had accepted, which is the live regression. """ session, engine, _, _ = make_session(FakeMatcher(score=0.45), engine_cls=CosineEngine) for frame_id in range(1, 5): process(session, frame_id) self.assertTrue(wait_for(lambda: any( track["identity"]["state"] == "recognized" for track in process(session, 5)["tracks"] )), "a gallery-accepted head match must be labelled") engine.shutdown() def test_a_close_cosine_pair_stays_ambiguous(self): """The margin gate still applies: two near-equal scores must not name a SKU.""" session, engine, _, _ = make_session(FakeMatcher(score=0.45), engine_cls=CosineEngine) engine.matcher.score_crop = lambda crop, method=None: {"sku_a": 0.45, "other": 0.44} for frame_id in range(1, 8): process(session, frame_id) result = process(session, 9) for track in result["tracks"]: self.assertIsNone(track["identity"]["sku_id"], "a cosine pair inside the margin must not be named") engine.shutdown() class TagPassIntegrationTests(unittest.TestCase): """The tag pass must reach the live result without blocking frames.""" def make_tagged_session(self): from ocr import FakeOcr, OcrLine def reader(crop): height, width = crop.shape[:2] return [ OcrLine("コーヒー", 0.8, [int(width * 0.05), int(height * 0.05), int(width * 0.4), int(height * 0.3)]), OcrLine("150", 0.9, [int(width * 0.05), int(height * 0.4), int(width * 0.35), int(height * 0.8)]), # 150 x 1.08 = 162.00, so the tag's arithmetic corroborates the price. OcrLine("162.00", 0.6, [int(width * 0.55), int(height * 0.5), int(width * 0.8), int(height * 0.75)]), ] session, engine, detector, matcher = make_session() # Rail bands come from product rows, so the shelf needs a row of facings. detector.boxes = [ {"id": index, "box": [100 + index * 120, 100, 200 + index * 120, 260], "confidence": 0.9, "class_id": 0, "width": 100, "height": 160, "center": [150 + index * 120, 180]} for index in range(3) ] engine.set_ocr_engine(FakeOcr(reader=reader)) self.assertTrue(session.enable_tags()) return session, engine, detector def test_track_gains_a_price_tag_and_a_reconciliation(self): import time as _time session, engine, detector = self.make_tagged_session() result = None for frame_id in range(1, 12): result = process(session, frame_id) _time.sleep(0.05) # the tag pass runs on its own worker if any( (track.get("price_tag") or {}).get("state") == "readable" for track in result["tracks"] ): break track = result["tracks"][0] self.assertIn("price_tag", track, "the tag pass should attach to the facing above it") self.assertEqual(track["price_tag"]["price"], 150) self.assertIn("reconciliation", track) self.assertEqual(result["summary"]["price_tags"]["readable"], 1) engine.shutdown() def test_frames_are_not_blocked_by_ocr(self): """A slow OCR engine must not slow the frame loop: frames keep coming.""" import time as _time from ocr import FakeOcr, OcrLine def slow_reader(crop): _time.sleep(0.5) height, width = crop.shape[:2] return [OcrLine("150", 0.9, [10, 10, int(width * 0.3), int(height * 0.6)])] session, engine, detector, _ = make_session() detector.boxes = [ {"id": index, "box": [100 + index * 120, 100, 200 + index * 120, 260], "confidence": 0.9, "class_id": 0, "width": 100, "height": 160, "center": [150 + index * 120, 180]} for index in range(3) ] engine.set_ocr_engine(FakeOcr(reader=slow_reader)) self.assertTrue(session.enable_tags()) started = _time.perf_counter() for frame_id in range(1, 4): process(session, frame_id) elapsed = _time.perf_counter() - started self.assertLess(elapsed, 1.0, "three frames must not wait for a 0.5 s OCR pass") engine.shutdown() def test_config_can_turn_tags_off(self): session, engine, _ = self.make_tagged_session() config = session.configure(tags=False) self.assertFalse(config["tags"]) self.assertTrue(config["tags_available"]) result = process(session, 1) self.assertNotIn("price_tag", result["tracks"][0]) engine.shutdown() def test_a_panned_shelf_reusing_the_screen_row_is_planned(self): """The same screen row on a shelf that has moved is a new rail.""" session, engine, _ = self.make_tagged_session() session._tags.settings.pass_interval_frames = 0 session._tags.settings.settle_observations = 1 session._tags.settings.settle_passes = 1 class Panned: def __init__(self): self.calls = 0 def register(self, image): self.calls += 1 # Content moved up 400 px, so the frame origin moves down the shelf. return (0.0, -400.0) if self.calls == 2 else (0.0, 0.0) session._shift_map = Panned() process(session, 1) time.sleep(0.05) before = session._tags.stats()["passes_run"] self.assertGreaterEqual(before, 1) process(session, 2) time.sleep(0.1) self.assertGreaterEqual(session._frame_origin[1], 400.0) self.assertGreater( session._tags.stats()["passes_run"], before, "a shelf 400 px away reuses screen row 0 and must be read again", ) 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): def test_latest_frame_wins_and_drops_are_counted(self): session, engine, _, _ = make_session() session.submit_frame(1, frame_bytes()) session.submit_frame(2, frame_bytes()) session.submit_frame(3, frame_bytes()) pending = session.take_pending(timeout=1.0) self.assertEqual(pending[0], 3, "the newest frame must be the one processed") stats = session.snapshot()["stats"] self.assertEqual(stats["frames_received"], 3) self.assertEqual(stats["frames_dropped"], 2) engine.shutdown() def test_undecodable_frame_raises(self): session, engine, _, _ = make_session() with self.assertRaises(Exception): session.process_frame(1, b"not an image") engine.shutdown() def test_result_payload_shape(self): session, engine, _, _ = make_session() result = process(session, 1) for key in ("type", "frame_id", "seq", "width", "height", "tracks", "empty_slots", "quality", "summary", "timing_ms", "stats"): self.assertIn(key, result) self.assertEqual(result["type"], "result") self.assertIn("unconfirmed", result["summary"]) track = result["tracks"][0] for key in ("track_id", "box", "raw_box", "confidence", "first_seen_seq", "relabels", "restarts", "identity"): self.assertIn(key, track) engine.shutdown() def test_quality_hint_reports_glare(self): session, engine, _, _ = make_session() ok, buffer = cv2.imencode(".jpg", np.full((480, 640, 3), 255, dtype="uint8")) session.submit_frame(1, buffer.tobytes()) result = session.process_frame(*session.take_pending(timeout=1.0)) self.assertTrue(result["quality"]["glare"] > 0.5) self.assertEqual(result["quality"]["hint"], "glare") engine.shutdown() class ClassificationBudgetTests(unittest.TestCase): def test_per_session_inflight_is_capped(self): matcher = FakeMatcher(delay=0.3) session, engine, detector, _ = make_session( matcher, LiveSettings(method="hybrid", max_inflight_per_session=2) ) detector.boxes = [ {"id": index, "box": [10 + index * 120, 10, 110 + index * 120, 200], "confidence": 0.9, "class_id": 0, "width": 100, "height": 190, "center": [60 + index * 120, 105]} for index in range(6) ] process(session, 1) process(session, 2) self.assertLessEqual(matcher.calls, 2, "only the cap may be in flight at once") engine.shutdown() def test_method_change_clears_labels_and_ignores_late_results(self): session, engine, _, matcher = make_session(FakeMatcher(delay=0.25)) process(session, 1) process(session, 2) session.configure(method="orb") self.assertEqual(session.config()["method"], "orb") result = process(session, 3) for track in result["tracks"]: self.assertNotEqual(track["identity"]["state"], "recognized", "a late result from the old method must not label a track") engine.shutdown() def test_inspect_reports_track_not_in_view(self): session, engine, _, _ = make_session() process(session, 1) with self.assertRaises(KeyError): session.inspect(999) def test_close_stops_the_loop(self): session, engine, _, _ = make_session() session.close() self.assertTrue(session.closed) self.assertIsNone(session.take_pending(timeout=0.05)) def test_async_take_keeps_latest_and_wakes_on_close(self): """The server waiter must not miss a frame that beat registration, and close must wake it.""" session, engine, _, _ = make_session() async def scenario(): session.submit_frame(1, frame_bytes()) session.submit_frame(2, frame_bytes()) first = await asyncio.wait_for(session.take_pending_async(), timeout=1.0) self.assertEqual(first[0], 2) waiting = asyncio.create_task(session.take_pending_async()) await asyncio.sleep(0) self.assertFalse(waiting.done(), "an empty queue must not complete the waiter") session.reset() session.submit_frame(3, frame_bytes()) self.assertEqual((await asyncio.wait_for(waiting, timeout=1.0))[0], 3) cancelled = asyncio.create_task(session.take_pending_async()) await asyncio.sleep(0) cancelled.cancel() with self.assertRaises(asyncio.CancelledError): await cancelled session.submit_frame(4, frame_bytes()) self.assertEqual((await asyncio.wait_for(session.take_pending_async(), timeout=1.0))[0], 4) closing = asyncio.create_task(session.take_pending_async()) await asyncio.sleep(0) session.close() self.assertIsNone(await asyncio.wait_for(closing, timeout=1.0)) self.assertFalse(session.submit_frame(5, frame_bytes())) asyncio.run(scenario()) engine.shutdown() class SmallBoxDetector(FakeDetector): """A facing too small for the frame crop to decide, so only a crop can.""" def __init__(self, box=None): super().__init__() self.boxes = [{ "id": 0, "box": list(box or [100, 100, 130, 200]), "confidence": 0.9, "class_id": 0, "width": 30, "height": 100, "center": [115, 150], }] def make_crop_session(matcher=None, settings=None, detector=None): detector = detector or SmallBoxDetector() engine = RecognitionEngine( detector, matcher or FakeMatcher(), FakeInventory(), settings=__import__("engine").EngineSettings(classify_workers=2), ) session = LiveSession("crop", engine, settings=settings or LiveSettings(method="hybrid")) session.refresh_catalog() session.enable_crops() return session, engine, detector def requested_crop(result): for track in result["tracks"]: if "crop_request" in track: return track["crop_request"] return None def panned(detector, x): """Put the fake facing at x, the way a camera panning a shelf does. The facing stays too narrow for the frame path, so only the crop lane can decide it, and it stays under crop_below_px so a crop is asked about. """ detector.boxes[0].update({ "box": [x, 100, x + 36, 220], "width": 36, "height": 120, "center": [x + 18, 160], }) class GatedMatcher(FakeMatcher): """Holds every classification open until the test lets it finish.""" def __init__(self): super().__init__() self.gate = threading.Event() def score_crop(self, crop, method="hybrid"): self.gate.wait(2.0) return super().score_crop(crop, method) class SecondAnswerWaits(FakeMatcher): """Answers the first crop at once, holds the second one, and disagrees. Two facings that overlap this much are the only way a track can be handed a settled label while its own answer is still in the classifier, so the test answers the first crop, lets the track settle, and only then hands over a second crop whose answer names a different product. """ def __init__(self, sku="sku_a", other="other"): super().__init__(sku=sku) self.gate = threading.Event() self.first_answered = threading.Event() self.disagreeing = other self._seen = 0 def score_crop(self, crop, method="hybrid"): with self.lock: self._seen += 1 turn = self._seen self.calls += 1 if turn == 1: self.first_answered.set() return {self.sku: 40.0, "other": 0.1}, {} self.gate.wait(2.0) return {self.disagreeing: 40.0, self.sku: 0.1}, {} class CropLaneTests(unittest.TestCase): """A full-resolution crop is extra evidence for one track, nothing else.""" def test_a_protocol_v1_client_is_never_asked(self): session, engine, _ = make_crop_session() session.enable_crops # noqa: B018 - documents that crops start disabled session.settings.crops = False process(session, 1) result = process(session, 2) self.assertIsNone(requested_crop(result)) self.assertEqual(result["stats"]["crops"]["requested"], 0) engine.shutdown() def test_accepted_crop_labels_the_track_it_was_asked_about(self): session, engine, _ = make_crop_session() process(session, 1) request = requested_crop(process(session, 2)) self.assertIsNotNone(request, "a box the frame crop cannot use must be asked about") self.assertEqual(request["frame_id"], 2) reply = session.submit_crop(request["request_id"], request["track_id"], request["frame_id"], frame_bytes()) self.assertEqual(reply["type"], "crop_ack") self.assertTrue(wait_for(lambda: process(session, 3)["stats"]["crops"]["accepted"] == 1)) engine.shutdown() def test_the_label_says_it_was_measured_on_a_crop(self): session, engine, _ = make_crop_session() process(session, 1) request = requested_crop(process(session, 2)) session.submit_crop(request["request_id"], request["track_id"], request["frame_id"], frame_bytes()) self.assertTrue(wait_for(lambda: "crop_request" not in process(session, 3)["tracks"][0])) labelled = [t for t in process(session, 4)["tracks"] if t["identity"]["state"] == "recognized"] self.assertTrue(labelled, "the crop should settle the track the frame could not") self.assertEqual(labelled[0]["identity"]["sku_id"], "sku_a") self.assertEqual(labelled[0]["identity"]["source"], "crop") self.assertEqual(labelled[0]["identity"]["attempts"], 1) engine.shutdown() def test_a_crop_can_arrive_after_the_next_frame(self): session, engine, _ = make_crop_session() process(session, 1) request = requested_crop(process(session, 2)) process(session, 3) reply = session.submit_crop(request["request_id"], request["track_id"], request["frame_id"], frame_bytes()) self.assertEqual(reply["type"], "crop_ack") self.assertTrue(wait_for(lambda: session._crops_accepted == 1)) engine.shutdown() def test_a_delayed_crop_is_accepted_while_the_track_stays_on_its_product(self): """Movement alone is not staleness: the crop still shows the same product.""" session, engine, detector = make_crop_session() panned(detector, 100) process(session, 1) panned(detector, 106) request = requested_crop(process(session, 2)) self.assertIsNotNone(request, "a facing the frame cannot use is asked about") # The phone answers a frame late, on a track that has panned a little. panned(detector, 112) moved = process(session, 3) self.assertEqual(moved["tracks"][0]["track_id"], request["track_id"]) reply = session.submit_crop(request["request_id"], request["track_id"], request["frame_id"], frame_bytes()) self.assertEqual(reply["type"], "crop_ack") self.assertTrue(wait_for(lambda: process(session, 4)["stats"]["crops"]["accepted"] == 1)) labelled = [t for t in process(session, 5)["tracks"] if t["identity"]["state"] == "recognized"] self.assertEqual(labelled[0]["identity"]["sku_id"], "sku_a") engine.shutdown() def test_a_crop_for_a_track_that_slid_onto_its_neighbour_is_refused(self): """The crop photographs one product; a track that walked off it answers nothing.""" session, engine, detector = make_crop_session() panned(detector, 100) process(session, 1) panned(detector, 118) request = requested_crop(process(session, 2)) self.assertIsNotNone(request) # The pan carries the track onto the next product before the phone answers. panned(detector, 136) moved = process(session, 3) self.assertEqual(moved["tracks"][0]["track_id"], request["track_id"], "the id must survive the pan, or this refusal proves nothing") reply = session.submit_crop(request["request_id"], request["track_id"], request["frame_id"], frame_bytes()) self.assertEqual(reply["code"], "CROP_STALE") self.assertEqual(session._tracks[request["track_id"]].in_flight, 0, "a refused crop gives the track's busy slot back") result = process(session, 4) self.assertEqual(result["stats"]["crops"]["accepted"], 0) self.assertEqual(result["stats"]["crops"]["rejected"], 1) self.assertNotEqual(result["tracks"][0]["identity"]["state"], "recognized") engine.shutdown() def test_a_crop_answered_after_the_track_left_its_product_does_not_label_it(self): """A classification in flight cannot settle a track that has since moved on.""" matcher = GatedMatcher() session, engine, detector = make_crop_session(matcher=matcher) panned(detector, 100) process(session, 1) panned(detector, 118) request = requested_crop(process(session, 2)) self.assertIsNotNone(request) reply = session.submit_crop(request["request_id"], request["track_id"], request["frame_id"], frame_bytes()) self.assertEqual(reply["type"], "crop_ack", "the track is still on the product here") # The answer is still in the classifier when the pan takes the track off it. panned(detector, 136) process(session, 3) matcher.gate.set() track = session._tracks[request["track_id"]] self.assertTrue(wait_for(lambda: session._tracks[request["track_id"]].in_flight == 0)) self.assertEqual(track.attempts, 0, "a crop of a product left behind is not an attempt") self.assertIsNone(track.identity, "the neighbour must not wear the old product's label") engine.shutdown() def test_a_track_is_asked_once(self): session, engine, _ = make_crop_session() process(session, 1) process(session, 2) result = process(session, 3) self.assertIsNone(requested_crop(result), "one crop per track object") self.assertEqual(result["stats"]["crops"]["requested"], 1) engine.shutdown() def test_a_crop_for_another_frame_is_refused(self): session, engine, _ = make_crop_session() process(session, 1) request = requested_crop(process(session, 2)) reply = session.submit_crop(request["request_id"], request["track_id"], 999, frame_bytes()) self.assertEqual(reply["code"], "CROP_MISMATCH") result = process(session, 3) self.assertNotEqual(result["tracks"][0]["identity"]["state"], "recognized") self.assertEqual(result["stats"]["crops"]["rejected"], 1) self.assertEqual(result["stats"]["crops"]["accepted"], 0) engine.shutdown() def test_a_crop_for_another_track_is_refused(self): session, engine, _ = make_crop_session() process(session, 1) request = requested_crop(process(session, 2)) reply = session.submit_crop(request["request_id"], request["track_id"] + 41, request["frame_id"], frame_bytes()) self.assertEqual(reply["code"], "CROP_MISMATCH") self.assertEqual(process(session, 3)["stats"]["crops"]["accepted"], 0) engine.shutdown() def test_an_oversized_crop_is_refused(self): session, engine, _ = make_crop_session(settings=LiveSettings(max_crop_bytes=2048)) session.enable_crops(2048) process(session, 1) request = requested_crop(process(session, 2)) reply = session.submit_crop(request["request_id"], request["track_id"], request["frame_id"], b"\xff\xd8" + b"\x00" * 4096) self.assertEqual(reply["code"], "CROP_TOO_LARGE") self.assertEqual(process(session, 3)["stats"]["crops"]["accepted"], 0) engine.shutdown() def test_an_undecodable_crop_is_refused(self): session, engine, _ = make_crop_session() process(session, 1) request = requested_crop(process(session, 2)) reply = session.submit_crop(request["request_id"], request["track_id"], request["frame_id"], b"not an image") self.assertEqual(reply["code"], "CROP_UNDECODABLE") self.assertEqual(process(session, 3)["stats"]["crops"]["accepted"], 0) engine.shutdown() def test_a_crop_cannot_be_replayed(self): session, engine, _ = make_crop_session() process(session, 1) request = requested_crop(process(session, 2)) first = session.submit_crop(request["request_id"], request["track_id"], request["frame_id"], frame_bytes()) self.assertEqual(first["type"], "crop_ack") again = session.submit_crop(request["request_id"], request["track_id"], request["frame_id"], frame_bytes()) self.assertEqual(again["code"], "CROP_UNKNOWN_REQUEST") engine.shutdown() def test_an_unknown_request_is_refused(self): session, engine, _ = make_crop_session() process(session, 1) reply = session.submit_crop("c-nope", 1, 1, frame_bytes()) self.assertEqual(reply["code"], "CROP_UNKNOWN_REQUEST") engine.shutdown() def test_an_unanswered_request_expires_and_frees_the_track(self): session, engine, _ = make_crop_session(settings=LiveSettings(crop_ttl_frames=1)) process(session, 1) request = requested_crop(process(session, 2)) process(session, 3) # The request was made on frame 2 with a one-frame budget, so by frame 4 # it is out of date and the track it held is free again. result = process(session, 4) self.assertEqual(result["stats"]["crops"]["rejected"], 1, "an unanswered request is counted") late = session.submit_crop(request["request_id"], request["track_id"], request["frame_id"], frame_bytes()) self.assertEqual(late["code"], "CROP_UNKNOWN_REQUEST") engine.shutdown() def test_the_client_states_the_smaller_crop_limit(self): session, engine, _ = make_crop_session() self.assertEqual(session.enable_crops(4096)["max_crop_bytes"], 4096) self.assertEqual(session.capabilities()["crop"], True) engine.shutdown() def test_a_refused_limit_never_turns_the_lane_on(self): session, engine, _ = make_crop_session() session.settings.crops = False # a lane no client has advertised yet for bad in (True, 0, -1, 4096.0, "4096", MAX_CROP_BYTES + 1): with self.subTest(limit=bad): with self.assertRaises(ValueError): session.enable_crops(bad) self.assertFalse(session.capabilities()["crop"], "a refused limit must leave the lane off") self.assertEqual(session.enable_crops(4096)["max_crop_bytes"], 4096) engine.shutdown() def test_a_reset_while_the_crop_decodes_is_refused(self): """An ack promises this crop decided that track; a reset says it cannot.""" matcher = FakeMatcher() session, engine, _ = make_crop_session(matcher=matcher) process(session, 1) request = requested_crop(process(session, 2)) self.assertIsNotNone(request) decoding = threading.Event() resumed = threading.Event() decode = engine.decode_image def gated_decode(payload): decoding.set() resumed.wait(2.0) return decode(payload) engine.decode_image = gated_decode replies = {} def answer(): replies["reply"] = session.submit_crop( request["request_id"], request["track_id"], request["frame_id"], frame_bytes() ) worker = threading.Thread(target=answer) worker.start() self.assertTrue(decoding.wait(2.0), "the crop must be decoding before the reset") session.reset() resumed.set() worker.join(5.0) self.assertIn("reply", replies, "the answer never came back") self.assertEqual(replies["reply"]["code"], "CROP_STALE") self.assertEqual(session._crops_accepted, 0) self.assertEqual(matcher.calls, 0, "a refused crop never reaches the classifier") engine.shutdown() def test_a_blurred_frame_is_not_asked_about(self): """More pixels of an out-of-focus scene are not more evidence.""" session, engine, _ = make_crop_session() process(session, 1) result = process_payload(session, 2, blurred_frame_bytes()) self.assertEqual(result["quality"]["hint"], "blur", "the frame under test must be the blurry one") self.assertIsNone(requested_crop(result)) self.assertEqual(result["stats"]["crops"]["requested"], 0) engine.shutdown() def test_a_distant_shelf_is_still_asked_about(self): """Too far to read at the frame's own resolution is the case a crop exists for.""" session, engine, detector = make_crop_session( settings=LiveSettings(method="hybrid", min_crop_px=60) ) detector.boxes[0].update({ "box": [300, 380, 336, 430], "width": 36, "height": 50, "center": [318, 405], }) process(session, 1) result = process(session, 2) self.assertEqual(result["quality"]["hint"], "too_far", "the frame under test must be the distant one") self.assertIsNotNone(requested_crop(result)) engine.shutdown() def test_a_facing_the_frame_clips_is_not_asked_about(self): session, engine, detector = make_crop_session() clipped = {"box": [0, 100, 36, 220], "width": 36, "height": 120, "center": [18, 160]} detector.boxes[0].update(clipped) process(session, 1) result = process(session, 2) self.assertIsNone(requested_crop(result), "half a facing is not worth the phone's pixels") self.assertEqual(result["stats"]["crops"]["requested"], 0) # The same facing, walked clear of the edge, is asked about: only the # clip kept it out of the lane. asked = None for frame_id, x in enumerate([12, 24, 36], start=3): detector.boxes[0].update({"box": [x, 100, x + 36, 220], "center": [x + 18, 160]}) result = process(session, frame_id) asked = asked or requested_crop(result) if asked is not None: break self.assertIsNotNone(asked, "a whole facing is worth the phone's pixels") engine.shutdown() def test_a_box_this_frame_did_not_measure_is_not_asked_about(self): """The phone crops by the box it is sent, so a stale box crops the wrong thing.""" session, engine, detector = make_crop_session() process(session, 1) detector.boxes[0]["confidence"] = 0.15 # too low for the tracker to trust result = process(session, 2) self.assertIsNone(result["quality"]["hint"], "only the detection is at fault here") self.assertIsNone(requested_crop(result)) detector.boxes[0]["confidence"] = 0.9 self.assertIsNotNone(requested_crop(process(session, 3)), "the same facing is asked about once a frame measures it again") engine.shutdown() def test_a_method_change_lets_the_small_facing_ask_again(self): """A crop minted under the old method is not an attempt under the new one.""" session, engine, _ = make_crop_session() process(session, 1) first = requested_crop(process(session, 2)) self.assertIsNotNone(first) session.configure(method="orb") second = requested_crop(process(session, 3)) self.assertIsNotNone(second, "the new method gets a budget of its own") self.assertNotEqual(second["request_id"], first["request_id"]) self.assertEqual(process(session, 4)["stats"]["crops"]["requested"], 2) late = session.submit_crop(first["request_id"], first["track_id"], first["frame_id"], frame_bytes()) self.assertEqual(late["code"], "CROP_UNKNOWN_REQUEST", "the outstanding request was minted under settings the session left") engine.shutdown() def test_an_expired_request_leaves_exactly_one_more_ask(self): session, engine, _ = make_crop_session(settings=LiveSettings(crop_ttl_frames=1)) process(session, 1) first = requested_crop(process(session, 2)) self.assertIsNotNone(first) process(session, 3) # The one-frame budget ran out on frame 4, so the facing gets one more # ask rather than being left with a request nobody will ever answer. second = requested_crop(process(session, 4)) self.assertIsNotNone(second, "an expiry must not strand a facing only the crop can decide") self.assertNotEqual(second["request_id"], first["request_id"]) process(session, 5) process(session, 6) result = process(session, 7) self.assertIsNone(requested_crop(result), "the budget is bounded, not a loop") self.assertEqual(result["stats"]["crops"]["requested"], 2) engine.shutdown() def test_a_drifted_label_lets_the_next_product_be_asked_about(self): """The facing in the frame is a different product, so it gets its own ask.""" session, engine, detector = make_crop_session() panned(detector, 100) process(session, 1) request = requested_crop(process(session, 2)) session.submit_crop(request["request_id"], request["track_id"], request["frame_id"], frame_bytes()) self.assertTrue(wait_for(lambda: session._tracks[request["track_id"]].state == "recognized")) track_id = request["track_id"] asked_again = None for frame_id, x in enumerate([118, 136, 154, 172, 190, 208], start=3): panned(detector, x) result = process(session, frame_id) self.assertIn(track_id, session._tracks, "the id must survive the pan") again = requested_crop(result) if again is not None and again["request_id"] != request["request_id"]: asked_again = again break self.assertIsNotNone(asked_again, "a drifted label must hand the new product its own crop ask") self.assertGreater(asked_again["box"][0], request["box"][0], "the new ask must be about where the product is now") engine.shutdown() def test_a_late_crop_cannot_repaint_a_settled_label(self): """A track that took a neighbour's label keeps it; a later crop does not overrule it.""" matcher = SecondAnswerWaits() session, engine, detector = make_crop_session( matcher=matcher, settings=LiveSettings(method="hybrid", min_crop_px=60) ) # Two narrow facings that overlap by more than a label can be trusted # across: that is the only shape in which one inherits the other's. detector.boxes = [ {"id": 0, "box": [100, 100, 150, 340], "confidence": 0.9, "class_id": 0, "width": 50, "height": 240, "center": [125, 220]}, {"id": 1, "box": [110, 100, 160, 340], "confidence": 0.9, "class_id": 0, "width": 50, "height": 240, "center": [135, 220]}, ] process(session, 1) requests = {row["track_id"]: row["crop_request"] for row in process(session, 2)["tracks"] if "crop_request" in row} self.assertEqual(len(requests), 2, "both facings are too small for the frame crop") settled_id, waiting_id = list(requests) session.submit_crop(requests[settled_id]["request_id"], settled_id, requests[settled_id]["frame_id"], frame_bytes()) self.assertTrue(matcher.first_answered.wait(2.0), "the first crop must be decided") session.submit_crop(requests[waiting_id]["request_id"], waiting_id, requests[waiting_id]["frame_id"], frame_bytes()) inherited = process(session, 3) row = next(t for t in inherited["tracks"] if t["track_id"] == waiting_id) self.assertTrue(row["inherited"], "the facing takes the label its neighbour settled") self.assertEqual(row["identity"]["sku_id"], "sku_a") matcher.gate.set() self.assertTrue(wait_for(lambda: session._tracks[waiting_id].in_flight == 0)) settled = next(t for t in process(session, 4)["tracks"] if t["track_id"] == waiting_id) self.assertEqual(settled["identity"]["sku_id"], "sku_a", "a crop that arrives late must not overrule a settled label") self.assertTrue(settled["inherited"]) self.assertEqual(settled["identity"]["source"], "inherited") self.assertEqual(session._tracks[waiting_id].attempts, 0, "an answer that could not decide is not an attempt") engine.shutdown() if __name__ == "__main__": unittest.main()