fix: bound and gate the crop evidence lane
The crop lane asked the phone for a full-resolution crop of a facing the frame could not decide. Four defects made that unsafe or useless: * A capability was half-applied. `crop: true` without a usable `max_crop_bytes` enabled the lane on the client's say-so, leaving the two sides believing different limits. A capability is now believed whole or refused with BAD_MESSAGE, and the limit must be an integer in 1..1048576 (bool is an int in Python and is not a limit). * A small JPEG could claim enormous dimensions. The header is now read without decoding and anything over 4 megapixels is refused before the decode allocates it. * A request was single-use, so an expired one stranded a facing only the crop lane could decide. The lane now spends the same max_attempts budget the frame path spends, refilled on method change and on settled-label drift, and a phone that never answers still cannot loop. * A candidate could be asked about from a box the frame path would refuse: clipped by the frame edge, carried over from an earlier frame, or measured at a score the tracker does not trust. Both lanes now share one edge gate, and only a track whose box this frame actually measured is asked about. Drift and method change also cleared `identity` but left `confirmed = True`, which permanently blocked both the crop lane and label inheritance for that track; both now clear it, without which the budget reset was dead code. Blur and glare are refused as whole-capture faults, while `too_far` is exempt on purpose: a distant shelf is the one frame a native-resolution crop exists to decide.
This commit is contained in:
parent
02e69acac7
commit
aa25f99699
5 changed files with 504 additions and 46 deletions
|
|
@ -25,7 +25,7 @@ import cv2 # noqa: E402
|
|||
import numpy as np # noqa: E402
|
||||
|
||||
from engine import RecognitionEngine # noqa: E402
|
||||
from live import LiveSession, LiveSettings # noqa: E402
|
||||
from live import MAX_CROP_BYTES, LiveSession, LiveSettings # noqa: E402
|
||||
|
||||
|
||||
class FakeDetector:
|
||||
|
|
@ -109,14 +109,23 @@ def frame_bytes(width=640, height=480):
|
|||
return buffer.tobytes()
|
||||
|
||||
|
||||
def process(session, frame_id=1):
|
||||
payload = frame_bytes()
|
||||
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:
|
||||
|
|
@ -556,6 +565,34 @@ class GatedMatcher(FakeMatcher):
|
|||
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."""
|
||||
|
||||
|
|
@ -764,6 +801,216 @@ class CropLaneTests(unittest.TestCase):
|
|||
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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue