Consecutive captures whose printed PAGE X OF Y markers advance under the same total are grouped into one multi-page PDF, with duplicate pages keeping the better read. Naming gains doc-title/party components and a scan-date fallback so nothing lands in UNSORTED. Vision LLM default switches to minicpm-v (faster, fewer false blanks on dense mono pages).
182 lines
7.0 KiB
Python
182 lines
7.0 KiB
Python
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
|
|
|