feat: add stable-centered auto-detect capture strategy

This commit is contained in:
pyr0ball 2026-07-13 10:36:54 -07:00
parent aaac91f03c
commit d5f5b47cef
2 changed files with 131 additions and 0 deletions

View file

@ -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

View file

@ -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