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).
168 lines
6.1 KiB
Python
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)
|