import numpy as np from paperpod.capture.video import Frame from paperpod.vision.capture_events import find_document_events, skin_fraction_in_quad from paperpod.vision.document import DocumentDetection _SKIN_BGR = (80, 120, 180) # inside the YCrCb skin range used by refine def _frame(t: float, idx: int = 0) -> Frame: return Frame(index=idx, t=t, image=np.zeros((100, 100, 3), dtype=np.uint8)) def _det() -> DocumentDetection: quad = np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype=np.float32) return DocumentDetection(quad=quad, area_ratio=0.3, method="quad", source="edges", confidence=0.8) def test_single_event_from_consecutive_detections(): samples = [(_frame(1.0, 0), _det()), (_frame(1.2, 1), _det()), (_frame(1.4, 2), _det())] events = find_document_events(samples, gap_s=2.0, min_samples=2) assert len(events) == 1 assert events[0].t_start == 1.0 assert events[0].sample_count == 3 def test_gap_splits_separate_documents(): samples = [ (_frame(1.0, 0), _det()), (_frame(1.2, 1), _det()), (_frame(5.0, 2), _det()), (_frame(5.2, 3), _det()), ] events = find_document_events(samples, gap_s=2.0, min_samples=2) assert len(events) == 2 assert events[0].t_end == 1.2 assert events[1].t_start == 5.0 def test_min_samples_filters_single_frame_noise(): samples = [(_frame(1.0, 0), _det()), (_frame(5.0, 1), _det()), (_frame(5.2, 2), _det())] events = find_document_events(samples, gap_s=2.0, min_samples=2) assert len(events) == 1 assert events[0].t_start == 5.0 def test_area_change_splits_documents(): small = DocumentDetection( quad=np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype=np.float32), area_ratio=0.25, method="quad", source="threshold", confidence=0.8, ) large = DocumentDetection( quad=np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype=np.float32), area_ratio=0.65, method="quad", source="threshold", confidence=0.8, ) samples = [ (_frame(1.0, 0), small), (_frame(1.2, 1), small), (_frame(1.4, 2), large), (_frame(1.6, 3), large), ] events = find_document_events(samples, gap_s=2.0, min_samples=2) assert len(events) == 2 assert events[0].detection.area_ratio == 0.25 assert events[1].detection.area_ratio == 0.65 def _paper_frame(t: float, idx: int, with_finger: bool, sharper: bool) -> Frame: """White page frame; optionally with a skin-colored blob and extra texture.""" rng = np.random.default_rng(idx) image = np.full((100, 100, 3), 245, dtype=np.uint8) if sharper: # High-frequency noise inflates Laplacian variance (the old # sharpness-only criterion would always pick this frame). noise = rng.integers(0, 60, size=(100, 100, 1), dtype=np.uint8) image = np.clip(image.astype(int) - noise, 0, 255).astype(np.uint8) if with_finger: image[30:70, 40:75] = _SKIN_BGR return Frame(index=idx, t=t, image=image) def _full_quad() -> DocumentDetection: quad = np.array([[0, 0], [100, 0], [100, 100], [0, 100]], dtype=np.float32) return DocumentDetection(quad=quad, area_ratio=0.3, method="quad", source="edges", confidence=0.8) def test_skin_fraction_detects_finger(): clean = _paper_frame(1.0, 0, with_finger=False, sharper=False) finger = _paper_frame(1.2, 1, with_finger=True, sharper=False) quad = _full_quad().quad assert skin_fraction_in_quad(clean.image, quad) < 0.003 assert skin_fraction_in_quad(finger.image, quad) > 0.05 def test_hands_free_frame_beats_equally_sharp_finger_frame(): # When sharpness is similar, prefer the clean frame. samples = [ (_paper_frame(1.0, 0, with_finger=True, sharper=True), _full_quad()), (_paper_frame(1.2, 1, with_finger=False, sharper=True), _full_quad()), (_paper_frame(1.4, 2, with_finger=True, sharper=True), _full_quad()), ] events = find_document_events(samples, gap_s=2.0, min_samples=2) assert len(events) == 1 assert events[0].best_frame_index == 1 assert events[0].skin_fraction < 0.003 def test_much_sharper_touched_frame_beats_blurry_clean_frame(): # Mid-flip blur with no fingers must not beat a sharp frame that has a # fingertip on the margin — that's how page 7 of a statement got lost. samples = [ (_paper_frame(1.0, 0, with_finger=False, sharper=False), _full_quad()), (_paper_frame(1.2, 1, with_finger=True, sharper=True), _full_quad()), (_paper_frame(1.4, 2, with_finger=False, sharper=False), _full_quad()), ] events = find_document_events(samples, gap_s=2.0, min_samples=2) assert len(events) == 1 assert events[0].best_frame_index == 1 def test_sharpest_wins_among_equally_clean_frames(): samples = [ (_paper_frame(1.0, 0, with_finger=False, sharper=False), _full_quad()), (_paper_frame(1.2, 1, with_finger=False, sharper=True), _full_quad()), ] events = find_document_events(samples, gap_s=2.0, min_samples=2) assert len(events) == 1 assert events[0].best_frame_index == 1 def test_long_event_splits_on_page_flip_motion(): """Quiet dwells inside a long placement become separate events. Synthetic: quiet page A, motion/hands, quiet page B. Contour is the same throughout (page flip under a fixed outline), so without dwell splitting only page A would be kept. """ def page_frame(t: float, idx: int, pattern: int) -> Frame: # Nearly identical consecutive frames so inter-sample motion is quiet. img = np.full((180, 320, 3), 240, dtype=np.uint8) img[40:140, 40:280] = 200 if pattern == 1 else 160 # Seeded by pattern only — same noise every page-A frame. noise = np.random.default_rng(pattern).integers(0, 3, size=img.shape, dtype=np.uint8) img = np.clip(img.astype(int) + noise, 0, 255).astype(np.uint8) return Frame(index=idx, t=t, image=img) samples = [] # Quiet page 1 dwell: t=0.0 .. 3.0 for i in range(0, 13): samples.append((page_frame(i * 0.25, i, pattern=1), _full_quad())) # Motion / hands between pages for i, shade in enumerate((40, 80, 120, 60)): img = np.full((180, 320, 3), shade, dtype=np.uint8) samples.append((Frame(index=13 + i, t=3.25 + i * 0.25, image=img), _full_quad())) # Quiet page 2 dwell: t=4.25 .. 5.25 for i in range(0, 5): samples.append((page_frame(4.25 + i * 0.25, 20 + i, pattern=2), _full_quad())) events = find_document_events(samples, gap_s=2.0, min_samples=2) assert len(events) >= 2 assert events[0].t_end <= 3.5 assert events[1].t_start >= 3.5 def test_short_event_not_resegmented(): samples = [ (_paper_frame(1.0, 0, with_finger=False, sharper=True), _full_quad()), (_paper_frame(1.2, 1, with_finger=False, sharper=True), _full_quad()), (_paper_frame(1.4, 2, with_finger=False, sharper=True), _full_quad()), ] events = find_document_events(samples, gap_s=2.0, min_samples=2) assert len(events) == 1