Some checks failed
tests / model-free (pull_request) Has been cancelled
The previous commit stopped the live path re-gating gallery scores at COSINE_ACCEPT, but it passed zero accept lines. `aggregate_votes` divides by the whole observation window, so one rejected crop beside one accepted 0.45 averages to 0.225 - and a zero gate labelled that, at a score the gallery itself calls unknown. The aggregate now clears the gallery's own line: `head_threshold` (0.40) when a fine-tuned head is loaded, `threshold` (0.55) otherwise. The margin gate is still the only extra gate, so a head match between 0.40 and 0.60 is labelled. Regression test: a rejected crop followed by an accepted 0.45 must not label the track (fails on the zero-gate version). Verified: `python -m unittest discover -s code -p 'test_*.py'` - 258 tests, OK, 2 skipped.
1097 lines
48 KiB
Python
1097 lines
48 KiB
Python
"""
|
|
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 FakeGallery:
|
|
"""The gallery's own accept line, which the live aggregate must clear."""
|
|
|
|
head_threshold = 0.40
|
|
threshold = 0.55
|
|
|
|
def __init__(self, head=True):
|
|
self._head = head
|
|
|
|
def head_loaded(self):
|
|
return self._head
|
|
|
|
|
|
class CosineEngine(RecognitionEngine):
|
|
"""A gallery-scale engine: score_crop only returns scores the gallery accepted."""
|
|
|
|
def __init__(self, *args, head=True, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self._fake_gallery = FakeGallery(head=head)
|
|
|
|
@property
|
|
def identity_scale(self):
|
|
return "cosine"
|
|
|
|
def identity_gallery(self):
|
|
return self._fake_gallery
|
|
|
|
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()
|
|
|
|
def test_a_rejected_crop_cannot_dilute_an_accepted_one(self):
|
|
"""aggregate_votes divides by the whole window, so a miss must not lower the line.
|
|
|
|
A rejected crop first, then an accepted 0.45: the mean is 0.225. Gating the
|
|
aggregate at zero would label that, at a score the gallery calls unknown.
|
|
"""
|
|
session, engine, _, _ = make_session(FakeMatcher(score=0.45), engine_cls=CosineEngine)
|
|
answers = iter([{}, {"sku_a": 0.45}])
|
|
engine.matcher.score_crop = lambda crop, method=None: (next(answers, {}), {})
|
|
# The retry needs retry_gap frames before the second attempt is eligible.
|
|
for frame_id in range(1, 12):
|
|
process(session, frame_id)
|
|
result = process(session, 13)
|
|
for track in result["tracks"]:
|
|
self.assertIsNone(track["identity"]["sku_id"],
|
|
"a diluted mean must not be labelled below the gallery's line")
|
|
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()
|