Files
PaperPod/paperpod/config.py
T
ilia 12e5d1aeb1 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).
2026-07-26 15:37:28 -04:00

168 lines
6.1 KiB
Python

"""Configuration loading with built-in defaults, overridable via config.yaml."""
from __future__ import annotations
from dataclasses import dataclass, field, fields, is_dataclass
from pathlib import Path
from typing import Any
import yaml
@dataclass
class CaptureConfig:
sample_fps: float = 8.0
processing_width: int = 960
@dataclass
class AudioConfig:
sample_rate: int = 16000
@dataclass
class MotionConfig:
blur_ksize: int = 21
pixel_threshold: int = 12
threshold: float = 0.02
stable_min_duration_s: float = 1.0
@dataclass
class DocumentConfig:
min_area_ratio: float = 0.04
# Candidates covering more than this fraction of the frame are rejected
# as likely background/lighting rather than a document.
max_area_ratio: float = 0.92
# Candidates whose long side is more than this many times their short
# side are rejected (fixes thin sliver/edge-strip false detections).
max_aspect: float = 6.0
canny_low: int = 50
canny_high: int = 150
# Contour search runs on a frame downscaled to this width; edge/threshold
# gap-closing kernels are sized relative to it rather than the source
# resolution, and the winning quad is scaled back up for the actual crop.
# Verified fix: a 4K frame's document outline was fragmenting into
# sub-regions with a fixed-pixel kernel tuned for ~1000px-wide frames.
detect_width: int = 1000
# Bake illumination-normalization + contrast enhancement into the saved
# crop/PDF, not just the internal OCR pass (which always applies it).
enhance: bool = False
# Auto-detect and correct 0/90/180/270 rotation before OCR/PDF export.
# Fixes the "placed the document upside down" case, which otherwise
# produces a fine-looking crop that OCR reads as garbage.
auto_rotate: bool = True
# Expand the detected quad outward by this fraction before warping, to
# recover paper margins clipped by curl/wrinkles (see pad_quad).
pad_margin: float = 0.04
# Tighten each crop to the actual paper region (removes mat margins and
# hands hanging off the side of receipts) and inpaint fingers pressing
# on the page. Content *under* a finger can't be recovered — it's filled
# with plausible paper texture — but margins clean up completely.
refine: bool = True
remove_fingers: bool = True
# Frame-based capture: cluster consecutive per-frame detections into
# separate documents when no contour is seen for this many seconds.
event_gap_s: float = 2.0
# Require at least this many sampled frames with a detection before
# committing a capture event (filters single-frame noise).
event_min_samples: int = 2
@dataclass
class SpeechConfig:
window_before_s: float = 3.0
window_after_s: float = 5.0
@dataclass
class OcrConfig:
# Tesseract language pack (e.g. "eng", "eng+fra").
lang: str = "eng"
# Page segmentation mode. 6 = "assume a single uniform block of text",
# which (unlike Tesseract's default of 3) keeps each receipt line's
# item-name and price together instead of splitting them into separate
# column blocks and dropping one. Verified against synthetic Home Depot/
# Metro/Petro-Canada receipts; revisit if real receipts have a layout
# PSM 6 handles worse (e.g. multi-column letters).
psm: int = 6
# Vendor name is assumed to live in this top fraction of the crop
# (receipt headers / letterheads are typically near the top).
vendor_top_fraction: float = 0.4
# Words below this Tesseract confidence (0-100) are ignored when
# picking the vendor line. Kept low deliberately: header text (larger,
# sometimes bold/stylized fonts) can score much lower confidence than
# crisp monospace body text despite being read correctly, and the
# line-height + topmost heuristic already screens out low-confidence
# noise (garbled OCR hallucinations measure much shorter than real text).
min_word_confidence: float = 10.0
@dataclass
class LlmConfig:
# Use a local Ollama vision model to identify/name documents and flag
# quality issues. Falls back to Tesseract heuristics when Ollama is
# down or the model errors. minicpm-v is ~8-12s/doc and much less prone
# to false "blank" labels than qwen2.5vl on dense statement pages.
enabled: bool = True
model: str = "minicpm-v"
base_url: str = "http://localhost:11434"
timeout_s: float = 180.0
@dataclass
class NamingConfig:
max_vendor_len: int = 40
@dataclass
class OutputConfig:
dir: str = "./output"
consume_dir: str | None = None
@dataclass
class Config:
capture: CaptureConfig = field(default_factory=CaptureConfig)
audio: AudioConfig = field(default_factory=AudioConfig)
motion: MotionConfig = field(default_factory=MotionConfig)
document: DocumentConfig = field(default_factory=DocumentConfig)
speech: SpeechConfig = field(default_factory=SpeechConfig)
ocr: OcrConfig = field(default_factory=OcrConfig)
llm: LlmConfig = field(default_factory=LlmConfig)
naming: NamingConfig = field(default_factory=NamingConfig)
output: OutputConfig = field(default_factory=OutputConfig)
def _merge_into(instance: Any, data: dict[str, Any]) -> Any:
"""Overlay a dict of overrides onto a dataclass instance, recursively."""
for f in fields(instance):
if f.name not in data:
continue
value = data[f.name]
current = getattr(instance, f.name)
if is_dataclass(current) and isinstance(value, dict):
_merge_into(current, value)
else:
setattr(instance, f.name, value)
return instance
def load_config(path: str | Path | None = None) -> Config:
"""Load config.yaml if present; unknown keys are ignored, missing keys use defaults."""
cfg = Config()
if path is None:
return cfg
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"Config file not found: {path}")
data = yaml.safe_load(path.read_text()) or {}
return _merge_into(cfg, data)
def config_to_dict(cfg: Config) -> dict[str, Any]:
"""Serialize the effective config (for embedding in run reports)."""
from dataclasses import asdict
return asdict(cfg)