Add page-marker grouping and party-aware naming

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).
This commit is contained in:
2026-07-26 15:37:28 -04:00
parent b11f46b00f
commit 12e5d1aeb1
10 changed files with 986 additions and 135 deletions
+116 -3
View File
@@ -1,8 +1,11 @@
from paperpod.capture.video import Frame
from paperpod.vision.capture_events import find_document_events
from paperpod.vision.document import DocumentDetection
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))
@@ -66,3 +69,113 @@ def test_area_change_splits_documents():
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