"""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)