diff --git a/recognization/code/engine.py b/recognization/code/engine.py index 342a98c..959c7b8 100644 --- a/recognization/code/engine.py +++ b/recognization/code/engine.py @@ -523,7 +523,7 @@ class RecognitionEngine: return DecisionPolicy().decide(lane.rank(crop), text_rank, text_read=read) def score_crop_lanes( - self, crop: Any, method: Optional[str] = None + self, crop: Any, method: Optional[str] = None, skip_ocr: bool = False ) -> Tuple[Dict[str, float], List[Tuple[str, float]], str]: """Both lanes' evidence for one crop: local scores, text ranking, raw read. @@ -538,6 +538,8 @@ class RecognitionEngine: `decide_crop`, which fuses a single crop. """ scores = self.score_crop(crop, method) + if skip_ocr: + return scores, [], "" text_lane = self.identity_text_lane() if text_lane is None: return scores, [], "" diff --git a/recognization/code/live.py b/recognization/code/live.py index 44f7d92..31bf465 100644 --- a/recognization/code/live.py +++ b/recognization/code/live.py @@ -147,6 +147,13 @@ class TrackState: crop_attempts: int = 0 # Which evidence produced the newest observation: frame or crop. last_source: str = SOURCE_FRAME + # The newest text reading for this object, cached so OCR runs once per + # track rather than once per attempt. Vision takes a crop from 5 ms to + # 25 ms, which is what made streamed coverage pacing-dependent: at 8 + # workers throughput fell from 789 to 60 crops/s. Both fields are cleared + # with the rest of the evidence when the object generation changes. + text_rank: List[Tuple[str, float]] = field(default_factory=list) + text_read: str = "" def area(self) -> int: if not self.raw_box: @@ -421,6 +428,8 @@ class LiveSession: track.candidates = [] track.score_sums = {} track.observations = [] + track.text_rank = [] + track.text_read = "" track.attempts = 0 track.in_flight = 0 track.last_attempt_seq = 0 @@ -741,6 +750,10 @@ class LiveSession: track.candidates = [] track.score_sums = {} track.observations = [] + # The reading described the product the box has left, so it goes with + # the label rather than naming whatever moved into the box. + track.text_rank = [] + track.text_read = "" track.attempts = 0 track.in_flight = 0 track.last_attempt_seq = 0 @@ -813,8 +826,15 @@ class LiveSession: # drift check later compares against. submitted_box = list(track.box) track_id = track.track_id + # OCR once per object: a track that already has a reading re-runs + # only the local lane, which is the difference between 5 ms and + # 25 ms per crop. A native crop is preferred for the one reading + # because it is the sharpest view the phone will send. + skip_ocr = bool(track.text_read) try: - future = self._executor.submit(self.engine.score_crop_lanes, crop, method) + future = self._executor.submit( + self.engine.score_crop_lanes, crop, method, skip_ocr + ) except RuntimeError: # executor already shut down with self._lock: track.in_flight = max(0, track.in_flight - 1) @@ -988,8 +1008,14 @@ class LiveSession: self._release_crop_slot(record) self._crops_rejected += 1 return _crop_error("CROP_STALE", stale) + # A native crop is the sharpest view the phone sends, so it is the + # one worth reading - but only once per object, like the frame path. + track = self._tracks.get(track_id_out) + skip_ocr = bool(track is not None and track.text_read) try: - future = self._executor.submit(self.engine.score_crop_lanes, crop, method) + future = self._executor.submit( + self.engine.score_crop_lanes, crop, method, skip_ocr + ) except RuntimeError: # executor already shut down self._release_crop_slot(record) self._crops_rejected += 1 @@ -1134,6 +1160,15 @@ class LiveSession: track.score_n += 1 self._classify_times.append(finished_at - submitted_at) + # The reading is cached on the track so later attempts skip OCR. + # An empty read is not cached: a crop that was too small or too + # blurry to read says nothing about the next one. + if text_read: + track.text_rank = list(text_rank or []) + track.text_read = text_read + elif track.text_read: + text_rank, text_read = track.text_rank, track.text_read + if not any(track.observations) and not text_rank: if track.attempts >= self.settings.max_attempts: track.state = STATE_UNRECOGNIZED diff --git a/recognization/code/test_live.py b/recognization/code/test_live.py index 33fd027..6c6ef92 100644 --- a/recognization/code/test_live.py +++ b/recognization/code/test_live.py @@ -107,15 +107,20 @@ class LocalScaleEngine(RecognitionEngine): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.lane_answers = [] + self.ocr_calls = 0 @property def identity_scale(self): return "local" - def score_crop_lanes(self, crop, method=None): - if self.lane_answers: - return self.lane_answers.pop(0) - return {}, [], "" + def score_crop_lanes(self, crop, method=None, skip_ocr=False): + answer = self.lane_answers.pop(0) if self.lane_answers else ({}, [], "") + if skip_ocr: + # The real engine re-runs only the local lane and returns no text; + # the live path reuses the reading it cached on the track. + return answer[0], [], "" + self.ocr_calls += 1 + return answer class FakeInventory: @@ -381,6 +386,23 @@ class LocalScaleTests(unittest.TestCase): ) engine.shutdown() + def test_ocr_runs_once_per_track(self): + """Vision on every attempt is what made coverage pacing-dependent. + + A crop costs 5 ms without OCR and 25 ms with it, so re-reading the same + package on every attempt cut throughput from 789 to 60 crops/s at 8 + workers. The reading is cached on the track instead, and the local lane + keeps running on every attempt. + """ + session, engine, _, _ = make_session(engine_cls=LocalScaleEngine) + # Below the text accept line, so the track stays undecided and retries: + # the retry is what would re-run OCR without the cache. + engine.lane_answers = [({}, [("sku_a", 0.3)], "コクうま 65%")] * 6 + for frame_id in range(1, 14): + process(session, frame_id) + self.assertEqual(engine.ocr_calls, 1, "OCR must run once per track") + engine.shutdown() + class TagPassIntegrationTests(unittest.TestCase): """The tag pass must reach the live result without blocking frames."""