From b9ddbee81ef1b73eff6b9920afd11ebca91c6957 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Jul 2026 10:19:10 -0700 Subject: [PATCH] feat: add deterministic marker-based size bucketing --- bookmark/sizing.py | 39 ++++++++++++++++++++++++++++++++++++ tests/test_sizing.py | 47 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 bookmark/sizing.py create mode 100644 tests/test_sizing.py diff --git a/bookmark/sizing.py b/bookmark/sizing.py new file mode 100644 index 0000000..9b66ec0 --- /dev/null +++ b/bookmark/sizing.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass + +import numpy as np + + +@dataclass(frozen=True) +class MarkerRegion: + name: str + bucket: str + x: int + y: int + width: int + height: int + + +def _mean_region_diff(frame: np.ndarray, reference_frame: np.ndarray, marker: MarkerRegion) -> float: + frame_region = frame[marker.y:marker.y + marker.height, marker.x:marker.x + marker.width] + reference_region = reference_frame[marker.y:marker.y + marker.height, marker.x:marker.x + marker.width] + diff = np.abs(frame_region.astype(int) - reference_region.astype(int)) + return float(diff.mean()) + + +def determine_size_bucket( + frame: np.ndarray, + reference_frame: np.ndarray, + markers: list[MarkerRegion], + threshold: float = 30.0, +) -> str: + """ + `markers` must be ordered smallest -> largest bucket boundary. Returns + the bucket of the largest boundary whose marker region is occluded + (covered by the book) relative to `reference_frame`. Defaults to the + smallest bucket if no marker is occluded. + """ + bucket = markers[0].bucket + for marker in markers: + if _mean_region_diff(frame, reference_frame, marker) > threshold: + bucket = marker.bucket + return bucket diff --git a/tests/test_sizing.py b/tests/test_sizing.py new file mode 100644 index 0000000..f3c71b3 --- /dev/null +++ b/tests/test_sizing.py @@ -0,0 +1,47 @@ +import numpy as np + +from bookmark.sizing import MarkerRegion, determine_size_bucket + + +def make_reference(size=100, color=200): + return np.full((size, size, 3), color, dtype=np.uint8) + + +MARKERS = [ + MarkerRegion(name="small_boundary", bucket="small", x=0, y=0, width=10, height=10), + MarkerRegion(name="medium_boundary", bucket="medium", x=50, y=0, width=10, height=10), + MarkerRegion(name="large_boundary", bucket="large", x=0, y=50, width=10, height=10), +] + + +def occlude(frame, marker, color=50): + frame = frame.copy() + frame[marker.y:marker.y + marker.height, marker.x:marker.x + marker.width] = color + return frame + + +def test_no_markers_occluded_returns_smallest_bucket(): + reference = make_reference() + frame = reference.copy() + assert determine_size_bucket(frame, reference, MARKERS) == "small" + + +def test_only_small_marker_occluded_returns_small(): + reference = make_reference() + frame = occlude(reference, MARKERS[0]) + assert determine_size_bucket(frame, reference, MARKERS) == "small" + + +def test_small_and_medium_occluded_returns_medium(): + reference = make_reference() + frame = occlude(reference, MARKERS[0]) + frame = occlude(frame, MARKERS[1]) + assert determine_size_bucket(frame, reference, MARKERS) == "medium" + + +def test_all_markers_occluded_returns_large(): + reference = make_reference() + frame = reference.copy() + for marker in MARKERS: + frame = occlude(frame, marker) + assert determine_size_bucket(frame, reference, MARKERS) == "large"