feat: add bounded crop evidence and keyed gallery cache
This commit is contained in:
parent
69de9ea418
commit
0d0e9f82b9
8 changed files with 1065 additions and 41 deletions
|
|
@ -58,6 +58,14 @@ STATS_WINDOW = 30
|
|||
# still overlaps the labelled product; a neighbour-slide crosses it.
|
||||
DRIFT_IOU = 0.5
|
||||
STATE_UNCONFIRMED = "unconfirmed"
|
||||
# Which evidence produced the newest observation on a track. A label is only ever
|
||||
# shown for an accepted match, so this says how that match was measured.
|
||||
SOURCE_FRAME = "frame"
|
||||
SOURCE_CROP = "crop"
|
||||
SOURCE_INHERITED = "inherited"
|
||||
# A crop the client returns is base64 inside a text control message, so it is
|
||||
# bounded on its own rather than riding the binary frame path.
|
||||
MAX_CROP_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -79,6 +87,18 @@ class LiveSettings:
|
|||
glare_fraction: float = 0.02
|
||||
glare_pixel: int = 245
|
||||
too_far_height_ratio: float = 0.08
|
||||
# Full-resolution crop lane. Off until a client advertises it, so a
|
||||
# protocol-v1 client is never asked for a crop and its traffic is unchanged.
|
||||
crops: bool = False
|
||||
max_crop_bytes: int = 128 * 1024
|
||||
# A crop answered this many processed frames late describes a product that
|
||||
# may have moved on, so it is dropped instead of relabelling the track.
|
||||
crop_ttl_frames: int = 6
|
||||
# Requests outstanding at once. Bounds what one phone can owe us.
|
||||
max_crop_inflight: int = 4
|
||||
# Ask for a native-resolution crop while the box is still this small. At the
|
||||
# camera's own resolution it may still be large enough to classify.
|
||||
crop_below_px: int = 64
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -115,6 +135,11 @@ class TrackState:
|
|||
# or pruned then re-created). Restarting resets the label, so it is always a
|
||||
# re-verification rather than an unexplained label change.
|
||||
restarts: int = 0
|
||||
# A crop is asked for at most once per object. A recycled id gets a fresh
|
||||
# TrackState, so the new object may be asked again.
|
||||
crop_requested: bool = False
|
||||
# Which evidence produced the newest observation: frame or crop.
|
||||
last_source: str = SOURCE_FRAME
|
||||
|
||||
def area(self) -> int:
|
||||
if not self.raw_box:
|
||||
|
|
@ -137,6 +162,19 @@ def _iou(a: Sequence[int], b: Sequence[int]) -> float:
|
|||
return inter / union if union > 0 else 0.0
|
||||
|
||||
|
||||
class _CropRejected(Exception):
|
||||
"""A returned crop that may not be used, carrying the reply to send back."""
|
||||
|
||||
def __init__(self, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.reply = _crop_error(code, message)
|
||||
|
||||
|
||||
def _crop_error(code: str, message: str) -> Dict[str, Any]:
|
||||
"""The one shape a refused crop answers with, recoverable like any other."""
|
||||
return {"type": "crop_error", "code": code, "message": message}
|
||||
|
||||
|
||||
class LiveSession:
|
||||
"""Stateful live session for one streaming client."""
|
||||
|
||||
|
|
@ -195,6 +233,14 @@ class LiveSession:
|
|||
self._frame_origin = [0.0, 0.0]
|
||||
self._shift_map = None
|
||||
self._compliance_tracker = None
|
||||
# Full-resolution crop lane. A request pins the frame and the track
|
||||
# object it was minted for, so a late or borrowed crop cannot relabel a
|
||||
# product the client never photographed.
|
||||
self._crop_requests: Dict[str, Dict[str, Any]] = {}
|
||||
self._crop_counter = 0
|
||||
self._crops_requested = 0
|
||||
self._crops_accepted = 0
|
||||
self._crops_rejected = 0
|
||||
self._last_compliance: Dict[str, Any] = {"violations": 0, "by_rule": {}, "rows": []}
|
||||
self._label_memory: "collections.deque[Dict[str, Any]]" = collections.deque(
|
||||
maxlen=LABEL_MEMORY_MAX
|
||||
|
|
@ -325,6 +371,10 @@ class LiveSession:
|
|||
track.in_flight = 0
|
||||
track.last_attempt_seq = 0
|
||||
track.last_attempt_area = 0
|
||||
# Outstanding crops were minted against the old method and the
|
||||
# old generation; answering one now would be evidence measured
|
||||
# under settings the session no longer holds.
|
||||
self._crop_requests.clear()
|
||||
return self.config()
|
||||
|
||||
def config(self) -> Dict[str, Any]:
|
||||
|
|
@ -348,6 +398,7 @@ class LiveSession:
|
|||
self._restart_count.clear()
|
||||
self._slot_history.clear()
|
||||
self._label_memory.clear()
|
||||
self._crop_requests.clear()
|
||||
self._slots = []
|
||||
self._latest_image = None
|
||||
self._latest_frame_id = None
|
||||
|
|
@ -492,10 +543,16 @@ class LiveSession:
|
|||
# releasing it. add_done_callback can run the callback inline when the
|
||||
# future already finished, and the callback takes this same lock.
|
||||
submissions = self._plan_classification(image, seq)
|
||||
crop_requests = self._plan_crop_requests(seq, int(frame_id))
|
||||
|
||||
summary = self._summary_locked()
|
||||
stats = self._stats_locked()
|
||||
track_payload = [self._track_payload(state) for state in self._tracks.values()]
|
||||
if crop_requests:
|
||||
for row in track_payload:
|
||||
request = crop_requests.get(row["track_id"])
|
||||
if request is not None:
|
||||
row["crop_request"] = request
|
||||
tag_summary = self._apply_tags(seq, track_payload, tag_bands, shelf_offset, image.shape)
|
||||
if tag_summary is not None:
|
||||
summary["price_tags"] = tag_summary["tags"]
|
||||
|
|
@ -552,6 +609,7 @@ class LiveSession:
|
|||
track.state = STATE_RECOGNIZED
|
||||
track.confirmed = True
|
||||
track.label_box = list(track.box)
|
||||
track.last_source = SOURCE_INHERITED
|
||||
track.identity = {
|
||||
"sku_id": entry["sku_id"],
|
||||
"confidence": entry["confidence"],
|
||||
|
|
@ -690,6 +748,208 @@ class LiveSession:
|
|||
)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ crop lane
|
||||
def capabilities(self) -> Dict[str, Any]:
|
||||
"""What this client is allowed to ask the phone for.
|
||||
|
||||
A client that never sends `capabilities` keeps the protocol-v1 defaults:
|
||||
no crop lane, so no result ever carries a crop request.
|
||||
"""
|
||||
return {
|
||||
"crop": bool(self.settings.crops),
|
||||
"max_crop_bytes": int(self.settings.max_crop_bytes),
|
||||
}
|
||||
|
||||
def enable_crops(self, max_crop_bytes: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""Turn the lane on for a client that advertised it. The client states the
|
||||
largest crop it will produce; the server keeps the lower of the two."""
|
||||
with self._lock:
|
||||
self.settings.crops = True
|
||||
if max_crop_bytes is not None:
|
||||
try:
|
||||
stated = int(max_crop_bytes)
|
||||
except (TypeError, ValueError):
|
||||
stated = 0
|
||||
self.settings.max_crop_bytes = max(1024, min(stated, MAX_CROP_BYTES))
|
||||
return self.capabilities()
|
||||
|
||||
def _plan_crop_requests(self, seq: int, frame_id: int) -> Dict[int, Dict[str, Any]]:
|
||||
"""Ask the phone for a fuller crop of tracks the frame crop could not use.
|
||||
|
||||
The box is in the pixel space of `frame_id`, the frame the client just
|
||||
sent, so the phone can answer from the buffer behind that exact frame
|
||||
rather than from whatever is on screen now. One request per track object,
|
||||
at most `max_crop_inflight` outstanding, and only for a client that
|
||||
advertised the lane.
|
||||
"""
|
||||
self._expire_crop_requests(seq)
|
||||
if not self.settings.crops:
|
||||
return {}
|
||||
room = self.settings.max_crop_inflight - len(self._crop_requests)
|
||||
if room <= 0:
|
||||
return {}
|
||||
planned: Dict[int, Dict[str, Any]] = {}
|
||||
for track in self._tracks.values():
|
||||
if len(planned) >= room:
|
||||
break
|
||||
if track.crop_requested or track.in_flight > 0:
|
||||
continue
|
||||
if track.state != STATE_DETECTED or track.confirmed:
|
||||
continue
|
||||
if track.hits < self.settings.min_hits or len(track.raw_box) != 4:
|
||||
continue
|
||||
x1, y1, x2, y2 = track.raw_box
|
||||
# The frame crop is too small to say anything about this product. At
|
||||
# the camera's own resolution it may still be worth a decision, and
|
||||
# that is the only thing a crop can add here.
|
||||
if min(x2 - x1, y2 - y1) >= self.settings.crop_below_px:
|
||||
continue
|
||||
self._crop_counter += 1
|
||||
request_id = "c%d" % self._crop_counter
|
||||
self._crop_requests[request_id] = {
|
||||
"request_id": request_id,
|
||||
"track_id": track.track_id,
|
||||
"frame_id": int(frame_id),
|
||||
"object_generation": track.object_generation,
|
||||
"generation": self._generation,
|
||||
"seq": seq,
|
||||
"method": self.settings.method,
|
||||
}
|
||||
track.crop_requested = True
|
||||
# Hold the track while the phone answers, so the frame path does not
|
||||
# spend a second attempt on the same box at the same time.
|
||||
track.in_flight += 1
|
||||
self._crops_requested += 1
|
||||
planned[track.track_id] = {
|
||||
"request_id": request_id,
|
||||
"track_id": track.track_id,
|
||||
"frame_id": int(frame_id),
|
||||
"box": list(track.raw_box),
|
||||
}
|
||||
return planned
|
||||
|
||||
def _expire_crop_requests(self, seq: int) -> None:
|
||||
"""Drop requests the phone did not answer in time.
|
||||
|
||||
A request holds the track busy while it is out, so an unanswered one has
|
||||
to give that slot back or the track could never be classified at all.
|
||||
"""
|
||||
stale = [
|
||||
request_id for request_id, record in self._crop_requests.items()
|
||||
if seq - record["seq"] > self.settings.crop_ttl_frames
|
||||
]
|
||||
for request_id in stale:
|
||||
record = self._crop_requests.pop(request_id)
|
||||
self._crops_rejected += 1
|
||||
self._release_crop_slot(record)
|
||||
|
||||
def _release_crop_slot(self, record: Dict[str, Any]) -> None:
|
||||
"""Give back the busy slot a crop request took. Lock must be held."""
|
||||
track = self._tracks.get(record["track_id"])
|
||||
if track is not None and track.object_generation == record["object_generation"]:
|
||||
track.in_flight = max(0, track.in_flight - 1)
|
||||
|
||||
def submit_crop(
|
||||
self,
|
||||
request_id: Any,
|
||||
track_id: Any,
|
||||
frame_id: Any,
|
||||
payload: Optional[bytes],
|
||||
) -> Dict[str, Any]:
|
||||
"""Score a crop the client returned for a track we asked about.
|
||||
|
||||
The crop is evidence about one track object, in one frame, decided under
|
||||
one configuration. Anything that breaks that binding is refused before a
|
||||
single pixel reaches the classifier, and the frame crop stays the only
|
||||
evidence for the track.
|
||||
"""
|
||||
try:
|
||||
record, box, payload = self._prepare_crop(request_id, track_id, frame_id, payload)
|
||||
except _CropRejected as refused:
|
||||
with self._lock:
|
||||
self._crops_rejected += 1
|
||||
return refused.reply
|
||||
try:
|
||||
crop = self.engine.decode_image(payload)
|
||||
except Exception:
|
||||
# The busy slot is the request's to give back, whoever failed on it.
|
||||
with self._lock:
|
||||
self._release_crop_slot(record)
|
||||
self._crops_rejected += 1
|
||||
return _crop_error("CROP_UNDECODABLE", "crop is not a readable image")
|
||||
submitted_at = time.perf_counter()
|
||||
track_id_out = record["track_id"]
|
||||
generation = record["generation"]
|
||||
method = record["method"]
|
||||
object_generation = record["object_generation"]
|
||||
try:
|
||||
future = self._executor.submit(self.engine.score_crop, crop, method)
|
||||
except RuntimeError: # executor already shut down
|
||||
with self._lock:
|
||||
self._release_crop_slot(record)
|
||||
self._crops_rejected += 1
|
||||
return _crop_error("CROP_UNAVAILABLE", "classifier is shutting down")
|
||||
with self._lock:
|
||||
self._crops_accepted += 1
|
||||
future.add_done_callback(
|
||||
lambda fut, tid=track_id_out, gen=generation, m=method, started=submitted_at,
|
||||
obj=object_generation, box=box: self._on_classified(
|
||||
fut, tid, gen, m, started, obj, box, SOURCE_CROP
|
||||
)
|
||||
)
|
||||
return {
|
||||
"type": "crop_ack",
|
||||
"request_id": record["request_id"],
|
||||
"track_id": track_id_out,
|
||||
"frame_id": record["frame_id"],
|
||||
}
|
||||
|
||||
def _prepare_crop(
|
||||
self, request_id: Any, track_id: Any, frame_id: Any, payload: Optional[bytes]
|
||||
) -> Tuple[Dict[str, Any], List[int], bytes]:
|
||||
"""Check a returned crop against the request it claims to answer.
|
||||
|
||||
Raises [_CropRejected] with the reply to send. The request is consumed
|
||||
either way: it is single use, so a rejected crop cannot be retried
|
||||
against a track that has since moved.
|
||||
"""
|
||||
with self._lock:
|
||||
record = self._crop_requests.pop(str(request_id), None) if request_id else None
|
||||
if record is None:
|
||||
raise _CropRejected("CROP_UNKNOWN_REQUEST", "no crop is waiting for that request id")
|
||||
try:
|
||||
answered_track = int(track_id)
|
||||
answered_frame = int(frame_id)
|
||||
except (TypeError, ValueError):
|
||||
self._release_crop_slot(record)
|
||||
raise _CropRejected("CROP_BAD_MESSAGE", "track_id and frame_id are required")
|
||||
if answered_track != record["track_id"] or answered_frame != record["frame_id"]:
|
||||
# A crop of some other track, or of another frame, says nothing
|
||||
# about this one however well it matches.
|
||||
self._release_crop_slot(record)
|
||||
raise _CropRejected("CROP_MISMATCH", "crop does not match the request it answers")
|
||||
if self._seq - record["seq"] > self.settings.crop_ttl_frames:
|
||||
self._release_crop_slot(record)
|
||||
raise _CropRejected("CROP_STALE", "crop answered too late to describe the track")
|
||||
if record["generation"] != self._generation:
|
||||
self._release_crop_slot(record)
|
||||
raise _CropRejected("CROP_STALE", "the session changed after the request")
|
||||
track = self._tracks.get(record["track_id"])
|
||||
if track is None or track.object_generation != record["object_generation"]:
|
||||
self._release_crop_slot(record)
|
||||
raise _CropRejected("CROP_STALE", "the track id was recycled after the request")
|
||||
# The sender keeps streaming while the requested crop is encoded.
|
||||
# A newer frame is normal; object generation and the bounded frame
|
||||
# TTL, not exact latest-sequence equality, guard against reuse.
|
||||
if track.last_seq < record["seq"]:
|
||||
self._release_crop_slot(record)
|
||||
raise _CropRejected("CROP_STALE", "the track is no longer in the requested frame")
|
||||
limit = self.settings.max_crop_bytes
|
||||
if not payload or len(payload) > limit:
|
||||
self._release_crop_slot(record)
|
||||
raise _CropRejected("CROP_TOO_LARGE", "crop is empty or over %d bytes" % limit)
|
||||
return record, list(track.box), payload
|
||||
|
||||
def _on_classified(
|
||||
self,
|
||||
future: Future,
|
||||
|
|
@ -699,6 +959,7 @@ class LiveSession:
|
|||
submitted_at: float,
|
||||
object_generation: int,
|
||||
submitted_box: List[int],
|
||||
source: str = SOURCE_FRAME,
|
||||
) -> None:
|
||||
try:
|
||||
scores = future.result()
|
||||
|
|
@ -715,6 +976,9 @@ class LiveSession:
|
|||
return
|
||||
track.in_flight = max(0, track.in_flight - 1)
|
||||
track.attempts += 1
|
||||
# A crop is an attempt against the same budget as a frame crop, so
|
||||
# the lane cannot be used to re-roll the dice on one product.
|
||||
track.last_source = source
|
||||
observation = {
|
||||
str(sku_id): float(score) for sku_id, score in (scores or {}).items()
|
||||
}
|
||||
|
|
@ -788,6 +1052,10 @@ class LiveSession:
|
|||
"candidates": list(track.candidates),
|
||||
"attempts": track.attempts,
|
||||
"method": self.settings.method,
|
||||
# Which evidence produced the newest observation. A client that only
|
||||
# reads this can tell a label measured on the frame it sent from one
|
||||
# measured on a crop it sent back.
|
||||
"source": track.last_source,
|
||||
}
|
||||
product = None
|
||||
if track.state == STATE_RECOGNIZED and track.identity:
|
||||
|
|
@ -865,6 +1133,11 @@ class LiveSession:
|
|||
if self._classify_times:
|
||||
classify_p50 = round(statistics.median(self._classify_times) * 1000.0, 2)
|
||||
return {
|
||||
"crops": {
|
||||
"requested": self._crops_requested,
|
||||
"accepted": self._crops_accepted,
|
||||
"rejected": self._crops_rejected,
|
||||
},
|
||||
"frames_received": self._frames_received,
|
||||
"frames_processed": self._frames_processed,
|
||||
"frames_dropped": self._frames_dropped,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue