From 76913d7b5200171e0f2073567c76bd1951722f0a Mon Sep 17 00:00:00 2001 From: perco Date: Tue, 2 Jun 2026 20:34:22 +0200 Subject: [PATCH] Fix frame selection: use motion score to find vehicle-present frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure sharpness ranking fails when a moving car causes motion blur — blurry car frames score lower than the sharp static background, so YOLO never sees the vehicle. Now compute inter-frame motion diff (downscaled to 320px, fast) alongside sharpness, then combine (65% motion + 35% sharpness) to select the top 15 frames for YOLO. This ensures the vehicle-present frames reach YOLO and PlateRecognizer even when they are slightly blurred. Co-Authored-By: Claude Sonnet 4.6 --- app/watcher.py | 43 +++++++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/app/watcher.py b/app/watcher.py index 086646d..cf145ba 100644 --- a/app/watcher.py +++ b/app/watcher.py @@ -145,29 +145,52 @@ def _process_clip(event_id: str, event_dir: str, clip_path: str, camera_name: st shutil.rmtree(event_dir, ignore_errors=True) return None - # Pass 1: rank all frames by sharpness (fast, no LPR) - sharpness: list[tuple[float, np.ndarray, str]] = [] + # Pass 1: load all frames, compute sharpness + inter-frame motion + # Motion detection uses a downscaled gray image (fast) to find frames where + # the vehicle is present — a moving car causes high frame diff even when blurry. + _MOTION_W = 320 + loaded: list[tuple[np.ndarray, str, float, float]] = [] # frame, path, sharpness, motion + prev_small: np.ndarray | None = None + for path in frame_files: frame = cv2.imread(path) if frame is None: continue gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) lap = cv2.Laplacian(gray, cv2.CV_64F).var() - sharpness.append((lap, frame, path)) - sharpness.sort(key=lambda x: -x[0]) + h, w = gray.shape + small = cv2.resize(gray, (_MOTION_W, _MOTION_W * h // w)) + motion = float(np.mean(np.abs(small.astype(np.float32) - prev_small.astype(np.float32)))) if prev_small is not None else 0.0 + prev_small = small + loaded.append((frame, path, lap, motion)) - # Pass 2: run YOLO only on top 10 sharpest frames → LPR-based score - top10 = sharpness[:10] + # Frame 0 has no predecessor — inherit frame 1's motion so car entering on first frame isn't missed + if len(loaded) > 1: + loaded[0] = (loaded[0][0], loaded[0][1], loaded[0][2], loaded[1][3]) + + max_sharp = max((x[2] for x in loaded), default=1.0) or 1.0 + max_motion = max((x[3] for x in loaded), default=1.0) or 1.0 + + # Combined score: motion weighted higher to prioritise car-present frames, + # sharpness as tiebreaker within those frames. + combined: list[tuple[float, np.ndarray, str]] = sorted( + [(0.65 * m / max_motion + 0.35 * s / max_sharp, frame, path) + for frame, path, s, m in loaded], + key=lambda x: -x[0], + ) + + # Pass 2: YOLO on top 15 by combined score → plate-area × conf ranking + top15 = combined[:15] scored: list[tuple[float, np.ndarray, str]] = [] - for _, frame, path in top10: + for _, frame, path in top15: plates = _analyzer.detect_plates(frame) if _analyzer else [] score = _frame_score(frame, plates) scored.append((score, frame, path)) scored.sort(key=lambda x: -x[0]) - # Merge: LPR-ranked top10 first, then remaining by sharpness - lpr_paths = {path for _, _, path in scored} - rest = [(lap * 0.001, frame, path) for lap, frame, path in sharpness if path not in lpr_paths] + # Merge: YOLO-scored top15 first, then remaining frames by combined score + scored_paths = {path for _, _, path in scored} + rest = [(score, frame, path) for score, frame, path in combined if path not in scored_paths] full_sorted = scored + rest for i, (_, _, old_path) in enumerate(full_sorted):