From d5f5b47cef44f9e052b283ed812f018870aa1307 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Jul 2026 10:36:54 -0700 Subject: [PATCH] feat: add stable-centered auto-detect capture strategy --- .../capture_strategies/stable_centered.py | 78 +++++++++++++++++++ tests/test_stable_centered_strategy.py | 53 +++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 bookmark/capture_strategies/stable_centered.py create mode 100644 tests/test_stable_centered_strategy.py diff --git a/bookmark/capture_strategies/stable_centered.py b/bookmark/capture_strategies/stable_centered.py new file mode 100644 index 0000000..2921e3f --- /dev/null +++ b/bookmark/capture_strategies/stable_centered.py @@ -0,0 +1,78 @@ +import cv2 +import numpy as np + +from .base import CaptureStrategy + + +class StableCenteredStrategy(CaptureStrategy): + """ + Fires when a book-sized region is detected filling the guide box and + its center position holds steady for `stable_frames` consecutive + observe() calls. + """ + + def __init__( + self, + reference_frame: np.ndarray, + guide_box: tuple[int, int, int, int], + diff_threshold: int = 30, + min_fill_ratio: float = 0.5, + center_tolerance: float = 10.0, + stable_frames: int = 5, + ) -> None: + self.reference_frame = reference_frame + self.guide_box = guide_box + self.diff_threshold = diff_threshold + self.min_fill_ratio = min_fill_ratio + self.center_tolerance = center_tolerance + self.stable_frames = stable_frames + self._previous_center: tuple[float, float] | None = None + self._stable_count = 0 + self._fired = False + + def reset(self) -> None: + self._previous_center = None + self._stable_count = 0 + self._fired = False + + def _detect(self, frame: np.ndarray) -> tuple[tuple[float, float], float] | None: + diff = cv2.absdiff(frame, self.reference_frame) + gray = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY) + _, mask = cv2.threshold(gray, self.diff_threshold, 255, cv2.THRESH_BINARY) + contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + if not contours: + return None + largest = max(contours, key=cv2.contourArea) + x, y, w, h = cv2.boundingRect(largest) + _, _, gw, gh = self.guide_box + fill_ratio = (w * h) / float(gw * gh) + center = (x + w / 2.0, y + h / 2.0) + return center, fill_ratio + + def observe(self, frame: np.ndarray) -> bool: + if self._fired: + return False + + detection = self._detect(frame) + if detection is None: + self._stable_count = 0 + self._previous_center = None + return False + + center, fill_ratio = detection + if fill_ratio < self.min_fill_ratio: + self._stable_count = 0 + self._previous_center = None + return False + + if self._previous_center is not None: + dx = center[0] - self._previous_center[0] + dy = center[1] - self._previous_center[1] + distance = (dx**2 + dy**2) ** 0.5 + self._stable_count = self._stable_count + 1 if distance <= self.center_tolerance else 0 + self._previous_center = center + + if self._stable_count >= self.stable_frames: + self._fired = True + return True + return False diff --git a/tests/test_stable_centered_strategy.py b/tests/test_stable_centered_strategy.py new file mode 100644 index 0000000..3314482 --- /dev/null +++ b/tests/test_stable_centered_strategy.py @@ -0,0 +1,53 @@ +from bookmark.capture_strategies.stable_centered import StableCenteredStrategy +import numpy as np + + +def make_reference(size=100, color=200): + return np.full((size, size, 3), color, dtype=np.uint8) + + +def make_book_frame(size=100, ref_color=200, book_color=50, box=(20, 20, 60, 60)): + frame = np.full((size, size, 3), ref_color, dtype=np.uint8) + x, y, w, h = box + frame[y:y + h, x:x + w] = book_color + return frame + + +def test_no_fire_on_empty_reference_frame(): + reference = make_reference() + strategy = StableCenteredStrategy(reference_frame=reference, guide_box=(20, 20, 60, 60), stable_frames=3) + assert strategy.observe(reference) is False + + +def test_no_fire_when_book_too_small(): + reference = make_reference() + small_book = make_book_frame(box=(40, 40, 10, 10)) + strategy = StableCenteredStrategy( + reference_frame=reference, guide_box=(20, 20, 60, 60), min_fill_ratio=0.5, stable_frames=3 + ) + for _ in range(5): + assert strategy.observe(small_book) is False + + +def test_fires_after_stable_frames_with_book_filling_guide_box(): + reference = make_reference() + book_frame = make_book_frame(box=(20, 20, 60, 60)) + strategy = StableCenteredStrategy( + reference_frame=reference, guide_box=(20, 20, 60, 60), min_fill_ratio=0.5, stable_frames=3 + ) + assert strategy.observe(book_frame) is False + assert strategy.observe(book_frame) is False + assert strategy.observe(book_frame) is False + assert strategy.observe(book_frame) is True + + +def test_fires_only_once(): + reference = make_reference() + book_frame = make_book_frame(box=(20, 20, 60, 60)) + strategy = StableCenteredStrategy( + reference_frame=reference, guide_box=(20, 20, 60, 60), min_fill_ratio=0.5, stable_frames=2 + ) + strategy.observe(book_frame) + strategy.observe(book_frame) + assert strategy.observe(book_frame) is True + assert strategy.observe(book_frame) is False