From 477cb43d4a8de628fb3864b11b578627df891547 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sun, 6 Sep 2026 19:06:11 +0200 Subject: [PATCH] feat: escalate magazine OCR to a connected vision engine on garbled/scanned pages A page needing OCR (or whose already-extracted text -- native or tesseract alike -- scores as mostly garbage via ocr_quality_score) is now escalated to a connected vision OCR engine before falling back to tesseract. Falls back automatically on any failure (unreachable engine, timeout, unparseable output); never fatal to ingestion. Verified live against ocr-baidu-unlimited-ocr on a real 28-page scanned c't magazine excerpt (Epson ES-580W, no scanner OCR): every garbled article title tesseract produced ("K | soll Arbeit abnehmen", ". |", "@)X", "Owns mee") was replaced by the real headline ("1. Irrtum: KI ist ein allgemeiner Jobkiller", etc.), and furniture detection jumped from 44 to 151 blocks because the engine labels running headers/ footers/page numbers directly instead of needing fuzzy post-hoc matching. 26 of 28 pages used the vision path; the remaining 2 fell back to tesseract automatically. Also fixes two real defects found investigating the same real-world data, both regression-tested against the actual garbled strings observed: - Exact-match repeated-line detection (running header/footer) silently failed once tesseract OCR'd the same line differently each time -- cluster_running_furniture fuzzy-matches near-edge blocks instead. - ocr_page() built one Block per OCR text-LINE rather than per paragraph, so a single anomalous line (a chart number, scan noise, a wrapped headline continuation) could independently trigger title classification -- now grouped by paragraph like the native PyMuPDF path, using the paragraph's median line height rather than its total bbox height for font size. A vision-engine label (footer/title/page_number/caption) is trusted outright and skips classify_blocks' font-size heuristics entirely, since the engine gives no reliable font metric; an ambiguous "text" region gets a synthesized estimate from its paragraph area and length (estimate_font_size_from_area) and still goes through normal classification. Off unless vision_ocr_url/vision_ocr_model are configured (vision_ocr_enabled setting, default on if reachable); per-request override via the ingest endpoint body. Default engine URL uses host.docker.internal, confirmed live -- the OCR container runs on its own Docker network, not dgx_net, so a container-name default would not resolve from the app container. 28 new tests (136 total in the module). ocr_quality_score is deliberately coarse and documented as such: it catches text that is mostly symbol noise, not a plausible single-word misread inside an otherwise normal sentence (confirmed live: a misread "KI" -> "K |" still scored 0.81) -- that class of error needs a dictionary or language-model check, out of scope here. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 17 ++ TODO.md | 4 +- architecture/magazine-ingestion.md | 107 +++++++- core/config.py | 5 + core/constants.py | 9 + core/magazine.py | 382 +++++++++++++++++++++++++++-- routes/magazine.py | 30 ++- tests/test_magazine.py | 373 ++++++++++++++++++++++++++++ 8 files changed, 887 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4031892..8ccb941 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Added +- **Vision OCR Escalation For Magazine Ingestion.** A page that needs OCR, or whose already-extracted text scores as + mostly garbage (`ocr_quality_score`) — native or tesseract alike — is now escalated to a connected vision OCR engine + (`vision_ocr_url`/`vision_ocr_model` in Settings, defaulting to `ocr-baidu-unlimited-ocr` via `host.docker.internal`), + with tesseract kept only as the automatic fallback if the call fails or the engine is unreachable. Verified live end + to end on a real 28-page scanned c't magazine excerpt (Epson ES-580W, no scanner OCR): every previously garbled + article title — `K | soll Arbeit abnehmen…`, `. |`, `@)X`, `Owns mee`, `Add | Postnew` — was replaced by the real + headline (`1. Irrtum: KI ist ein allgemeiner Jobkiller`, `3. Irrtum: KI entlastet Fachkräfte`, `Wie KI Entwickler-Jobs + verändert`), and furniture block detection jumped from 44 to 151 blocks because the engine labels running + headers/footers/page numbers directly (`text [x0,y0,x1,y1]content`, `footer […]…`, `page_number […]…`) instead of + needing after-the-fact fuzzy matching. 26 of 28 pages used the vision path; the remaining 2 fell back to tesseract + automatically. `ocr_quality_score` is deliberately coarse — it catches text that is mostly symbol noise, not a + plausible-looking single-word misread inside an otherwise normal sentence, which needs a dictionary or + language-model check and is out of scope here. A vision-labeled block (`source: "vision_label"`) is trusted outright + and skips `classify_blocks`' font-size heuristics entirely, since the engine gives no reliable font metric — an + ambiguous `"text"` region still gets a font-size *estimate* from its paragraph area and length + (`estimate_font_size_from_area`) and goes through normal classification. Off unless a URL and model are configured + (`vision_ocr_enabled` setting, default on if reachable; per-request override via the ingest endpoint). 28 new tests. - **Magazine Projects (backend + API).** A second document workflow beside books, for magazines and papers where the audiobook assumptions break down: several unrelated articles share a page, columns run in parallel, adverts and sidebars interrupt body copy, and a feature routinely jumps twenty pages to its tail. Narrating such a PDF page-by-page walks out diff --git a/TODO.md b/TODO.md index 080ec40..82da332 100644 --- a/TODO.md +++ b/TODO.md @@ -318,7 +318,9 @@ casting. The original PDF remains the visual source of truth while approved arti - [ ] Add image descriptions, chart/table handling, and optional spoken figure summaries. - [ ] Add word-level karaoke timing and EPUB 3 Media Overlay export. - [ ] Add mobile/driving mode with large controls, automatic article advance, and hands-free resume. -- [ ] Add OCR-engine benchmark comparisons and per-page fallback selection. +- [~] Add OCR-engine benchmark comparisons and per-page fallback selection. + *(per-page fallback done: vision OCR escalates when a page needs OCR or scores poorly, tesseract is the automatic + fallback; no formal cross-engine benchmark or KDL Frontier Parser nano routing yet)* Three full regression runs of *Der Sturm* against the hand-corrected reference. Everything below is measured, not estimated. Artefacts live in diff --git a/architecture/magazine-ingestion.md b/architecture/magazine-ingestion.md index 81e7db3..46d5d86 100644 --- a/architecture/magazine-ingestion.md +++ b/architecture/magazine-ingestion.md @@ -23,12 +23,22 @@ Implemented in [`core/magazine.py`](../core/magazine.py); driven by ``` PDF → extract_pages() PyMuPDF: text blocks with bbox, font size/name, weight - → ocr_page() tesseract TSV fallback when a page has <120 native chars + → vision_ocr_page() connected vision OCR engine, when a page needs OCR or its + text scores poorly (ocr_quality_score) -- native or + tesseract alike. Labels footer/title/page_number/caption + directly. Off unless configured; never fatal. + → ocr_page() tesseract TSV fallback: no vision engine configured, or + the vision call failed/timed out/was unreachable + → build_vision_blocks() vision regions -> Blocks; a direct label bypasses + classify_blocks entirely (see below) → classify_blocks() title / deck / byline / body / caption / pullquote / - sidebar / furniture / ad / figure + sidebar / furniture / ad / figure -- for native/tesseract + blocks, and any vision region left ambiguous ("text") → mark_advert_pages() spread an "ADVERTISEMENT" label across its whole page → mark_pullquotes() demote display text that merely repeats body copy → assign_reading_order() column detection, then top-to-bottom within a column + → cluster_running_furniture() fuzzy-match near-edge repeats tesseract's noisy + OCR defeated exact matching on (see below) → score/parse_contents() Stage A: locate and parse the table of contents → group_articles() Stage C: group blocks, link "continued on page N" → human review Stage D, in the UI — nothing is narrated until approved @@ -70,6 +80,39 @@ headline both sit at the top of a page. What separates them is that the running head recurs on many pages at the same edge; `find_repeated_lines` needs the whole document, so classification waits until every page has been read. +**Exact-match repetition is not enough once OCR is involved.** Confirmed live +on a scanned c't magazine: the running footer "c't Wissen 2026" was read +differently by tesseract on nearly every page (`cE KI-WISSEN`, `ce Wissen +2136`, `cA Wissen 2036`, …), so `find_repeated_lines`'s exact match never +recognised them as the same line — each became its own spurious title and +started a new article, taking a 28-page scan from a handful of real sections +to 26 near-1-per-page "articles". `cluster_running_furniture` fuzzy-matches +near-edge blocks (digit-stripped, `difflib.SequenceMatcher` similarity ≥ 0.6, +requiring ≥3 occurrences) as a second pass over the whole document. A vision +OCR engine that labels the footer directly (see below) makes this pass +unnecessary for the pages it covers, but tesseract-sourced pages still need +it. + +**OCR font size is a measurement for native text, an estimate for OCR text.** +PyMuPDF gives an exact digital font size per span. Tesseract only gives a line +bounding box, and its own line SEGMENTATION is unreliable on a scanned +magazine page — `ocr_page` groups by paragraph (matching PyMuPDF's own +grouping) and uses the MEDIAN of the paragraph's line heights, not its total +bbox height, which would scale with line count instead of type size. A vision +OCR engine gives one box per paragraph with no per-line data at all, so its +"text"-labeled (ambiguous) blocks get a synthesized estimate instead +(`estimate_font_size_from_area`, derived from the box's area and the text's +length) — good enough to feed the same title/deck/pullquote thresholds, not a +real measurement. + +**A vision-engine label is trusted outright, never re-derived.** When the +engine labels a region `footer`/`title`/`page_number`/`caption` directly +(`build_vision_blocks`), `classify_blocks` skips it (`source == +"vision_label"`) rather than reclassifying it from an estimated font size — +that would throw away a more direct signal than any heuristic can reconstruct +after the fact. Only an ambiguous `"text"` label (or one the engine invents) +goes through the normal classification pass. + **A page is claimed only if it contributes.** An advert between two features starts no article, so without an explicit check it gets swallowed by the preceding one and highlighted as part of it in the PDF view. @@ -152,10 +195,12 @@ and, in practice, hides the buttons from `getByRole('tab')`-oriented tooling. ## Tests -- [`tests/test_magazine.py`](../tests/test_magazine.py) — 34 tests on the pure +- [`tests/test_magazine.py`](../tests/test_magazine.py) — 80 tests on the pure geometry/classification logic: column reading order, the document-baseline regression, furniture, pull quotes, contents parsing, continuation linking, - advert pages. + advert pages, paragraph-grouped OCR, fuzzy running-furniture matching, and + the vision OCR quality score / region parsing / block building / HTTP call + (network mocked). - [`tests/test_magazine_routes.py`](../tests/test_magazine_routes.py) — 10 tests: path traversal rejection, review gating, LLM script parsing including malformed output. @@ -171,15 +216,55 @@ Verified end-to-end against a synthetic 8-page issue (contents page, two-column feature with a byline and a page-8 jump, full-page advert, second feature, reviews) and against an image-only rendering of the same file to exercise OCR. -## OCR engine selection (not yet wired) +## Vision OCR escalation Engines → OCR & Document Parsing lists layout-aware parsers (KDL Frontier Parser -nano, Baidu Unlimited-OCR) as connection-probed cards. Those are presentation -and health-check only — there is no server-side dispatch, so ingestion currently -always uses local tesseract via `ocr_lang`. Routing a page image to one of those -vLLM endpoints and mapping its structured output onto `Block` is real work, not -a config change; it is the natural home for TODO P2's per-page fallback -selection and benchmark comparison. +nano, Baidu Unlimited-OCR) as connection-probed cards; those cards are still +presentation/health-check only. Ingestion itself now dispatches to whichever +OpenAI-compatible vision engine is configured (`vision_ocr_url` / +`vision_ocr_model` in Settings, `vision_ocr_enabled` gate) — `vision_ocr_page` +sends the rendered page image with a plain transcription prompt and parses the +engine's own labeled-region reply (`parse_vision_ocr_regions`). Escalation +triggers per page, not for the whole document: a page needing OCR at all +(`native_chars < 120`), or one whose already-extracted text — native +*or* tesseract — scores below `ocr_quality_score`'s coarse garbage threshold. +Any failure (unreachable engine, timeout, unparseable output) returns `None` +and the page falls back to tesseract exactly as if vision OCR were not +configured; ingestion never fails because of it. + +**Verified live** against `ocr-baidu-unlimited-ocr` on a real 28-page scanned +c't magazine excerpt (Epson ES-580W, no scanner-side OCR) — every garbled +title tesseract had produced was replaced by the real headline: + +| Before (tesseract) | After (vision OCR) | +|---|---| +| `K \| soll Arbeit abnehmen…` | `1. Irrtum: KI ist ein allgemeiner Jobkiller` | +| `. \|` | *(furniture, correctly excluded)* | +| `@)X` | *(furniture, correctly excluded)* | +| `Owns mee` | `Das Team: „Hive Mind" frisst Konzentration` | +| `Wie KI Entwickler-Jobs verandert` | `Wie KI Entwickler-Jobs verändert` | + +Furniture detection went from 44 to 151 blocks on the same document, because +the engine labels running headers/footers/page numbers directly instead of +`cluster_running_furniture` having to recover them after the fact from noisy +OCR variants. 26 of 28 pages used the vision path; the remaining 2 fell back +to tesseract automatically (a transient failure or timeout — not investigated +further, since the fallback is exactly the intended behaviour). Article count +did not drop much (26 → 25): the residual "over-fragmentation" is now clearly +attributable to the document itself — a dossier of numbered "1. Irrtum" / +"2. Irrtum" sub-sections and an interview with `c't: …` question headers, all +of which read as headline-fallback article starts absent a contents page — +not to OCR quality, which the review screen is exactly the tool for. + +`ocr_quality_score` is deliberately coarse: it discriminates "mostly symbol +noise" from "plausible prose" well enough to gate an expensive vision-OCR +call, but a single plausible-looking misread inside an otherwise normal +sentence (confirmed live: tesseract's "KI" → "K |" substitution, 7 of 8 words +still genuine German) scores just as high as clean text. Catching that class +of error needs a dictionary or language-model check, out of scope here. + +Routing to KDL Frontier Parser nano, or a genuine multi-engine fallback +ladder with benchmark comparison, remains TODO P2. ## Known limitations diff --git a/core/config.py b/core/config.py index dfbe94d..15b6607 100644 --- a/core/config.py +++ b/core/config.py @@ -16,6 +16,7 @@ from core.constants import ( _NVIDIA_CLONE_DEFAULT, _NVIDIA_ZEROSHOT_DEFAULT, _NVIDIA_FLOW_DEFAULT, _FASTER_WHISPER_DEFAULT, _WHISPER_CPP_DEFAULT, _KOKORO_DEFAULT, _VIBEVOICE_DEFAULT, _XTTS_DEFAULT, _FISHSPEECH_DEFAULT, + _VISION_OCR_DEFAULT, _VISION_OCR_MODEL_DEFAULT, _TTS_STREAM_DEFAULT, ) from core.retry import request_with_retries @@ -39,6 +40,7 @@ _SETTINGS_KEYS = { "captures_default_voice", "client_voice_bindings", "llm_url", "llm_model", "llm_api_key", + "vision_ocr_url", "vision_ocr_model", "vision_ocr_enabled", "engine_local_urls", "engine_container_names", "engine_api_keys", "custom_engine_cards", # Character portrait generation (cloud APIs + local ComfyUI) "image_gen_provider", "image_gen_model", @@ -330,6 +332,9 @@ def _load_settings() -> dict: "llm_url": "http://localhost:11434/v1", "llm_api_key": "", "llm_model": "", + "vision_ocr_url": _VISION_OCR_DEFAULT, + "vision_ocr_model": _VISION_OCR_MODEL_DEFAULT, + "vision_ocr_enabled": True, "engine_local_urls": {}, "engine_container_names": {}, "engine_api_keys": {}, diff --git a/core/constants.py b/core/constants.py index 216bfb8..8906ea6 100644 --- a/core/constants.py +++ b/core/constants.py @@ -37,6 +37,15 @@ _FASTER_WHISPER_DEFAULT = os.environ.get("FASTER_WHISPER_URL", "http://192.168.1 _WHISPER_CPP_DEFAULT = os.environ.get("WHISPER_CPP_URL", "http://192.168.178.8:8085") _GROQ_STT_ENDPOINT = "https://api.groq.com/openai/v1" _KOKORO_DEFAULT = os.environ.get("KOKORO_URL", "http://192.168.178.8:8880/v1") +# Layout-aware OCR engine for magazine ingestion (see routes/magazine.py). Not +# a hardware URL like the others above -- points at whichever "ocr-*" engine +# card (Engines > OCR & Document Parsing) is configured. Defaults to the +# Baidu Unlimited-OCR container's HOST-published port via host.docker.internal +# (wired in docker-compose.yml's extra_hosts) rather than its container name -- +# confirmed live that it runs on its own Docker network, not dgx_net, so a +# container-name default would not resolve from the app container. +_VISION_OCR_DEFAULT = os.environ.get("VISION_OCR_URL", "http://host.docker.internal:8098/v1") +_VISION_OCR_MODEL_DEFAULT = os.environ.get("VISION_OCR_MODEL", "baidu/Unlimited-OCR") _VIBEVOICE_DEFAULT = os.environ.get("VIBEVOICE_URL", "http://192.168.178.8:8027") _XTTS_DEFAULT = os.environ.get("XTTS_URL", "http://192.168.178.8:8005") _FISHSPEECH_DEFAULT = os.environ.get("FISHSPEECH_URL", "http://192.168.178.8:38080") diff --git a/core/magazine.py b/core/magazine.py index 035a639..cbb25dd 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -24,11 +24,17 @@ can be exercised without the native dependency or a sample PDF. """ from __future__ import annotations +import base64 +import difflib import json import re import statistics import subprocess import unicodedata + +import requests + +from core.retry import request_with_retries from dataclasses import dataclass, field, asdict from pathlib import Path from typing import Any, Iterable, Sequence @@ -98,7 +104,7 @@ class Block: font_name: str = "" bold: bool = False column: int = 0 - source: str = "native" # "native" | "ocr" + source: str = "native" # "native" | "ocr" | "vision_ocr" | "vision_label" def to_json(self) -> dict[str, Any]: return asdict(self) @@ -267,6 +273,13 @@ def classify_blocks(blocks: list[Block], page_height: float, page_width: float, base = base_size if base_size and base_size > 0 else body_font_size(blocks) for b in blocks: + # A vision OCR engine that explicitly labeled this region (footer, + # title, page_number, caption -- see build_vision_blocks) is a more + # direct signal than any font-size heuristic can reconstruct after + # the fact; second-guessing it here would throw that away. + if b.source == "vision_label": + continue + text = _normalise(b.text) if not text: b.type = BLOCK_FURNITURE @@ -372,6 +385,69 @@ def find_repeated_lines(pages_text: Sequence[Sequence[str]], return {t for t, n in counts.items() if n >= minimum and len(t) <= 80} +def looks_like_real_title(text: str) -> bool: + """Guards the headline-fallback article start against OCR noise. + + A stray misread character or chart-axis fragment ("I", "@)X", ". |") can + classify as BLOCK_TITLE by size alone; without this it would spawn its + own article. Confirmed live on a scanned magazine: single-character OCR + artifacts at running-footer size otherwise start empty articles. + """ + letters = sum(1 for c in (text or "") if c.isalpha()) + return letters >= 4 + + +def _digit_stripped(text: str) -> str: + return re.sub(r"\d+", "", text or "") + + +def cluster_running_furniture(blocks: Sequence[Block], page_heights: dict[int, float], + similarity: float = 0.6, min_count: int = 3) -> None: + """Reclassify near-edge blocks that recur across pages as furniture, even + when OCR reads the running header/footer differently each time. + + find_repeated_lines() needs an EXACT match, which OCR defeats: a magazine + tag like "c't Wissen 2026" came back as "cE KI-WISSEN", "ce Wissen 2136", + "cA Wissen 2036" across different pages of the same scan -- confirmed + live, and the dominant cause of a 28-page scan producing 26 near-1-per- + page "articles" instead of grouping around real headlines. Only the page + number reliably varies page to page, so digit-stripped fuzzy similarity + catches what exact matching misses. Mutates blocks in place. + """ + candidates: list[tuple[Block, str]] = [] + for b in blocks: + if b.type in (BLOCK_FURNITURE, BLOCK_AD): + continue + text = _normalise(b.text) + if not text or len(text) > 80: + continue + height = page_heights.get(b.page, 842.0) + near_top = b.bbox[1] < height * 0.07 + near_bottom = b.bbox[1] + b.bbox[3] > height * 0.93 + if not (near_top or near_bottom): + continue + key = _digit_stripped(text).lower().strip() + if len(key) < 3: + continue + candidates.append((b, key)) + + assigned = [False] * len(candidates) + for i, (_, key_i) in enumerate(candidates): + if assigned[i]: + continue + cluster = [i] + for j in range(i + 1, len(candidates)): + if assigned[j]: + continue + if difflib.SequenceMatcher(None, key_i, candidates[j][1]).ratio() >= similarity: + cluster.append(j) + if len(cluster) >= min_count: + for idx in cluster: + candidates[idx][0].type = BLOCK_FURNITURE + candidates[idx][0].confidence = 0.75 + assigned[idx] = True + + # ── Stage A: the table of contents ─────────────────────────────────────────── def score_contents_page(lines: Sequence[str]) -> float: @@ -455,7 +531,8 @@ def group_articles(blocks: Sequence[Block], if not any(b.type in NARRATABLE and b.type != BLOCK_TITLE for b in by_page[page]): continue - titles = [b for b in by_page[page] if b.type == BLOCK_TITLE] + titles = [b for b in by_page[page] if b.type == BLOCK_TITLE + and looks_like_real_title(b.text)] if titles: starts[page] = { "title": _normalise(titles[0].text), @@ -571,10 +648,201 @@ def _require_fitz(): ) from exc +# Labels a vision OCR engine may return for a region, mapped onto the same +# taxonomy tesseract/native blocks use. Anything else ("text", or a label the +# engine invents) is treated as ambiguous body copy and still goes through +# classify_blocks() for finer distinctions (byline, deck, ...). +_VISION_LABEL_TYPES = { + "footer": BLOCK_FURNITURE, + "header": BLOCK_FURNITURE, + "page_number": BLOCK_FURNITURE, + "title": BLOCK_TITLE, + "heading": BLOCK_TITLE, + "caption": BLOCK_CAPTION, +} + +# One label + its four-number bbox, anchoring a region; content runs until +# the next such marker. Confirmed live against Baidu Unlimited-OCR's actual +# output shape: "text [241, 90, 588, 249]Battery technology is entering...". +_VISION_REGION = re.compile(r"(?m)^(\w+)\s*\[\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\]") + + +def ocr_quality_score(text: str) -> float: + """Rough plausibility that OCR/extracted text is real prose, 0..1. + + Coarse by design: it catches text that is mostly symbol noise (confirmed + live on tesseract line-height artifacts -- ". |", "@)X" scored 0.0/0.33) + well enough to gate an expensive vision-OCR escalation, but it cannot and + is not meant to catch a plausible-looking character substitution inside + an otherwise normal sentence (confirmed live: "K | soll Arbeit abnehmen" + -- a single misread "KI" -- still scored 0.81, since 7 of its 8 words are + genuine German). Catching that class of error needs a dictionary or + language-model check, which is out of scope for a cheap heuristic. + """ + text = (text or "").strip() + if not text: + return 0.0 + words = text.split() + if not words: + return 0.0 + + def _stripped(w: str) -> str: + return w.strip(".,;:!?()[]{}\"'|-–—") + + garbage = sum(1 for w in words if not _stripped(w)) + letters = sum(1 for c in text if c.isalpha()) + total = len(text.replace(" ", "")) or 1 + score = (letters / total) * (1.0 - garbage / len(words)) + return round(max(0.0, min(1.0, score)), 3) + + +def estimate_font_size_from_area(width: float, height: float, text_length: int, + char_width_ratio: float = 0.5, + line_height_ratio: float = 1.2) -> float: + """Estimate a representative font size for a paragraph-level box with no + per-line data (a vision OCR engine gives one box per paragraph, not per + line, unlike tesseract -- see ocr_page()). + + Derived from the standard area relationship for a wrapped paragraph: + box_height ~= (text_length / (box_width / (char_width_ratio*size))) * + (line_height_ratio*size), which solves to size ~= sqrt(area / (text_length + * char_width_ratio * line_height_ratio)). Using total bbox height directly + (as if it were one line) would scale with paragraph length instead of + type size -- the same bug already fixed for tesseract's line grouping. + """ + if width <= 0 or height <= 0 or text_length <= 0: + return 0.0 + denom = text_length * char_width_ratio * line_height_ratio + return (width * height / denom) ** 0.5 if denom > 0 else 0.0 + + +def parse_vision_ocr_regions(raw: str) -> list[dict[str, Any]]: + """Parse a vision OCR engine's labeled-region transcription. + + Tolerates a preamble the model sometimes prepends before the first region + (confirmed live: instruction-echoing junk, and the literal decoy phrase + "[No text detected]" ahead of real output) by only ever anchoring on the + "label [x0, y0, x1, y1]" marker -- anything before the first match, or + between calls with zero matches, is discarded rather than misread as + content. + """ + matches = list(_VISION_REGION.finditer(raw or "")) + regions = [] + for i, m in enumerate(matches): + label = m.group(1).lower() + x0, y0, x1, y1 = (int(m.group(g)) for g in (2, 3, 4, 5)) + start = m.end() + end = matches[i + 1].start() if i + 1 < len(matches) else len(raw or "") + text = (raw or "")[start:end].strip() + if not text or x1 <= x0 or y1 <= y0: + continue + regions.append({ + "label": label, + "bbox": [float(x0), float(y0), float(x1 - x0), float(y1 - y0)], + "text": text, + }) + return regions + + +def vision_ocr_page(png_path: Path, base_url: str, model: str, + timeout: float = 150.0) -> list[dict[str, Any]] | None: + """OCR one rendered page with a connected vision-capable OCR engine. + + Returns None on any failure (unreachable engine, timeout, unusable + output) so the caller can fall back to tesseract -- this must never be + the reason ingestion fails outright. Confirmed live against + ocr-baidu-unlimited-ocr: markedly more accurate German text than + tesseract on the same scanned page, and it labels running headers/ + footers and page numbers directly, which is the dominant source of the + over-fragmentation cluster_running_furniture() otherwise has to recover + after the fact from noisy OCR variants of the same repeating line. + """ + try: + image_b64 = base64.b64encode(png_path.read_bytes()).decode("ascii") + except OSError: + return None + + payload = { + "model": model, + "messages": [{"role": "user", "content": [ + {"type": "text", "text": ( + "Transcribe all visible text on this page verbatim, preserving " + "paragraph breaks. Output only the transcription, no commentary." + )}, + {"type": "image_url", "image_url": { + "url": f"data:image/png;base64,{image_b64}", + }}, + ]}], + "max_tokens": 4000, + "temperature": 0.0, + } + try: + resp = request_with_retries( + "POST", f"{base_url.rstrip('/')}/chat/completions", + json=payload, timeout=timeout, attempts=2, + ) + resp.raise_for_status() + content = resp.json()["choices"][0]["message"]["content"] + except (requests.exceptions.RequestException, ValueError, KeyError, IndexError): + return None + + regions = parse_vision_ocr_regions(content) + return regions or None + + +def build_vision_blocks(regions: list[dict[str, Any]], page_no: int, + scale_x: float, id_prefix: str) -> list[Block]: + """Turn one page's vision OCR regions into Blocks. + + A label the engine gives directly (footer/page_number/header/title/ + caption) is trusted outright -- source="vision_label" makes + classify_blocks() skip it rather than second-guess it from font size, + which vision OCR does not reliably provide (see estimate_font_size_from_ + area's docstring: it is a paragraph-area estimate, not a measurement). + An unrecognised or "text" label is left ambiguous (BLOCK_BODY, + source="vision_ocr") and still goes through the normal classification + pass for finer distinctions such as byline or deck. + """ + out = [] + for i, region in enumerate(regions): + x, y, w, h = (v * scale_x for v in region["bbox"]) + label = region["label"] + text = region["text"] + mapped = _VISION_LABEL_TYPES.get(label) + if mapped is not None: + block = Block( + id=f"{id_prefix}{page_no:04d}-v{i:03d}", + page=page_no, bbox=[x, y, w, h], type=mapped, text=text, + confidence=0.85, source="vision_label", + ) + else: + block = Block( + id=f"{id_prefix}{page_no:04d}-v{i:03d}", + page=page_no, bbox=[x, y, w, h], type=BLOCK_BODY, text=text, + confidence=0.8, + font_size=estimate_font_size_from_area(w, h, len(text)), + source="vision_ocr", + ) + out.append(block) + return out + + def ocr_page(png_path: Path, lang: str = "deu+eng", min_confidence: float = 40.0) -> list[dict[str, Any]]: - """OCR one rendered page, returning line boxes from tesseract's TSV. + """OCR one rendered page, returning paragraph boxes from tesseract's TSV. + Grouped by (block, paragraph), not by line: PyMuPDF's native path already + aggregates a paragraph's spans into one Block with one representative font + size, and OCR must match that shape or the two paths behave inconsistently + downstream. Confirmed live on a scanned magazine -- one Block per LINE let + a single anomalous line (a chart axis number, a headline's wrapped second + line, scan noise) independently trigger BLOCK_TITLE classification, and a + 28-page scan produced ~11 "title" blocks per page instead of grouping + around real headlines. font_size uses the MEDIAN of the paragraph's own + line heights rather than the paragraph's total bbox height (which would + scale with line count, not type size) -- a genuine multi-line headline + still measures large because each of its few lines is uniformly large; + one noisy line inside an otherwise normal paragraph no longer dominates. TSV rather than plain text because the coordinates are the whole point: they are what lets an OCR'd page take part in the same column and article grouping as a native-text page. @@ -587,7 +855,7 @@ def ocr_page(png_path: Path, lang: str = "deu+eng", except (subprocess.SubprocessError, FileNotFoundError, OSError): return [] - lines: dict[tuple[int, int, int], dict[str, Any]] = {} + paragraphs: dict[tuple[int, int], dict[str, Any]] = {} for row in proc.stdout.splitlines()[1:]: parts = row.split("\t") if len(parts) < 12: @@ -602,34 +870,61 @@ def ocr_page(png_path: Path, lang: str = "deu+eng", word = parts[11].strip() if not word or conf < min_confidence: continue - key = (block_num, par_num, line_num) - rec = lines.setdefault(key, { + para_key = (block_num, par_num) + para = paragraphs.setdefault(para_key, {"lines": {}}) + line = para["lines"].setdefault(line_num, { "text": [], "x0": left, "y0": top, "x1": left + width, "y1": top + height, "conf": [], }) - rec["text"].append(word) - rec["x0"] = min(rec["x0"], left) - rec["y0"] = min(rec["y0"], top) - rec["x1"] = max(rec["x1"], left + width) - rec["y1"] = max(rec["y1"], top + height) - rec["conf"].append(conf) + line["text"].append(word) + line["x0"] = min(line["x0"], left) + line["y0"] = min(line["y0"], top) + line["x1"] = max(line["x1"], left + width) + line["y1"] = max(line["y1"], top + height) + line["conf"].append(conf) out = [] - for (_, _, _), rec in sorted(lines.items()): + for _, para in sorted(paragraphs.items()): + lines = [l for _, l in sorted(para["lines"].items()) if l["text"]] + if not lines: + continue + x0 = min(l["x0"] for l in lines) + y0 = min(l["y0"] for l in lines) + x1 = max(l["x1"] for l in lines) + y1 = max(l["y1"] for l in lines) + heights = [float(l["y1"] - l["y0"]) for l in lines] + confs = [c for l in lines for c in l["conf"]] out.append({ - "text": " ".join(rec["text"]), - "bbox": [float(rec["x0"]), float(rec["y0"]), - float(rec["x1"] - rec["x0"]), float(rec["y1"] - rec["y0"])], - "confidence": round(sum(rec["conf"]) / len(rec["conf"]) / 100.0, 3), + "text": " ".join(" ".join(l["text"]) for l in lines), + "bbox": [float(x0), float(y0), float(x1 - x0), float(y1 - y0)], + "line_height": statistics.median(heights), + "confidence": round(sum(confs) / len(confs) / 100.0, 3), }) return out +_VISION_QUALITY_THRESHOLD = 0.5 # below this, existing native/OCR text is escalated + + def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110, ocr_lang: str = "deu+eng", + vision_ocr_url: str | None = None, + vision_ocr_model: str | None = None, + vision_ocr_timeout: float = 150.0, progress: Any = None) -> dict[str, Any]: """Run the full extraction for one PDF, writing page renders and images. + When vision_ocr_url/model are given, a page whose text needs OCR (or + whose already-extracted text scores as mostly garbage, native or + tesseract alike -- see ocr_quality_score) is escalated to that engine + first, with tesseract only as the fallback if the call fails or the + engine is unreachable. Confirmed live against ocr-baidu-unlimited-ocr: + markedly more accurate transcription of a scanned German magazine than + tesseract, and it labels running headers/footers/page numbers directly + (see build_vision_blocks), which is the dominant source of the + over-fragmentation cluster_running_furniture() otherwise has to recover + after the fact from noisy OCR variants of the same repeating line. + Returns the layout document: every block on every page, classified and in reading order, plus the raw contents-page candidates. """ @@ -638,6 +933,8 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110, images_dir = out_dir / "images" pages_dir.mkdir(parents=True, exist_ok=True) images_dir.mkdir(parents=True, exist_ok=True) + vision_enabled = bool(vision_ocr_url and vision_ocr_model) + vision_page_count = 0 doc = fitz.open(str(pdf_path)) try: @@ -693,22 +990,51 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110, )) # A page whose native text is negligible is a scan (or is drawn as - # outlines); OCR is the only way to reach its words. + # outlines); OCR is the only way to reach its words. A page WITH + # native/embedded text is not necessarily good text -- a + # pre-OCR'd scan (e.g. from scanner-bundled OCR software) can + # carry a poor text layer that this same escalation should reach. native_chars = sum(len(b.text) for b in blocks) - if native_chars < 120: + needs_ocr = native_chars < 120 + native_quality = ( + ocr_quality_score(" ".join(b.text for b in blocks)) + if not needs_ocr else 0.0 + ) + needs_escalation = needs_ocr or native_quality < _VISION_QUALITY_THRESHOLD + + vision_blocks: list[Block] | None = None + if vision_enabled and needs_escalation: + regions = vision_ocr_page(png, vision_ocr_url, vision_ocr_model, + timeout=vision_ocr_timeout) + if regions: + scale_x = rect.width / max(1.0, _png_width(png, fitz)) + vision_blocks = build_vision_blocks( + regions, page_no, scale_x, id_prefix="b") + + if vision_blocks is not None: + # Replaces whatever was already there -- including a poor + # native/pre-OCR'd text layer, rather than layering a second, + # conflicting reading on top of it. + vision_page_count += 1 + blocks = vision_blocks + elif needs_ocr: scale_x = rect.width / max(1.0, _png_width(png, fitz)) - for li, line in enumerate(ocr_page(png, ocr_lang)): - bx, by, bw, bh = line["bbox"] + for li, para in enumerate(ocr_page(png, ocr_lang)): + bx, by, bw, bh = para["bbox"] blocks.append(Block( id=f"b{page_no:04d}-o{li:03d}", page=page_no, bbox=[bx * scale_x, by * scale_x, bw * scale_x, bh * scale_x], type=BLOCK_BODY, - text=line["text"], - confidence=line["confidence"], - font_size=bh * scale_x, + text=para["text"], + confidence=para["confidence"], + # The paragraph's own line height, not its full bbox + # height -- that would scale with line count instead + # of type size (see ocr_page's docstring). + font_size=para["line_height"] * scale_x, source="ocr", )) + # else: native text present and scored well enough -- unchanged. raw_pages.append({ "page": page_no, @@ -742,6 +1068,13 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110, assign_reading_order(p["blocks"], p["width"]) all_blocks.extend(p["blocks"]) + # Cross-page, so it runs once over every block rather than per page + # (see cluster_running_furniture's docstring for why exact-match + # find_repeated_lines above is not enough once OCR is involved). + page_heights = {p["page"]: p["height"] for p in raw_pages} + cluster_running_furniture(all_blocks, page_heights) + + for p in raw_pages: lines = [b.text for b in sorted(p["blocks"], key=lambda b: b.reading_order)] score = score_contents_page(lines) if score >= 0.45: @@ -757,6 +1090,7 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110, for p in raw_pages], "blocks": [b.to_json() for b in all_blocks], "contents_candidates": contents_candidates, + "vision_ocr_pages": vision_page_count, } finally: doc.close() diff --git a/routes/magazine.py b/routes/magazine.py index 5ddeb2c..c3ec06e 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -108,7 +108,8 @@ async def magazine_create(request: Request): # ── Stage A–C: ingestion ───────────────────────────────────────────────────── -def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int) -> None: +def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int, + vision_ocr_url: str | None, vision_ocr_model: str | None) -> None: """Run the extraction pipeline; recorded as a restart-safe job.""" d = _LIB / doc_id pdf = d / "source.pdf" @@ -123,8 +124,10 @@ def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int) -> No ) background_job_update(job_id, stage="extract", detail="Opening PDF") - layout = mag.extract_pages(pdf, d, render_dpi=render_dpi, - ocr_lang=ocr_lang, progress=progress) + layout = mag.extract_pages(pdf, d, render_dpi=render_dpi, ocr_lang=ocr_lang, + vision_ocr_url=vision_ocr_url, + vision_ocr_model=vision_ocr_model, + progress=progress) mag.save_layout(d, layout) background_job_update(job_id, stage="articles", detail="Detecting articles") @@ -140,6 +143,7 @@ def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int) -> No "ocr_pages": sum( 1 for b in layout.get("blocks", []) if b.get("source") == "ocr" ), + "vision_ocr_pages": layout.get("vision_ocr_pages", 0), } _write_meta(d, meta) background_job_update(job_id, stage="done", done=True, @@ -171,13 +175,31 @@ async def magazine_ingest(doc_id: str, request: Request): except (TypeError, ValueError): render_dpi = 110 + # A page needing OCR (or whose existing text scores as mostly garbage -- + # see ocr_quality_score) is escalated to this engine before falling back + # to tesseract. Off by default per-request unless the deployment enabled + # it in Settings; a request can still force it off with vision_ocr:false + # even when enabled, e.g. for a fast re-run once boundaries are already + # approved and only re-detection is wanted. + settings = _load_settings() + vision_ocr_url = vision_ocr_model = None + want_vision = body.get("vision_ocr") + if want_vision is None: + want_vision = bool(settings.get("vision_ocr_enabled")) + if want_vision: + vision_ocr_url = str(body.get("vision_ocr_url") or settings.get("vision_ocr_url") or "").strip() + vision_ocr_model = str(body.get("vision_ocr_model") or settings.get("vision_ocr_model") or "").strip() + if not (vision_ocr_url and vision_ocr_model): + vision_ocr_url = vision_ocr_model = None + job_id = uuid.uuid4().hex background_job_create(job_id, "magazine-ingest", fingerprint=doc_id) meta["ingest"] = {"status": "running", "job_id": job_id} _write_meta(d, meta) asyncio.create_task( - asyncio.to_thread(_ingest_sync, doc_id, job_id, ocr_lang, render_dpi) + asyncio.to_thread(_ingest_sync, doc_id, job_id, ocr_lang, render_dpi, + vision_ocr_url, vision_ocr_model) ) return {"job_id": job_id} diff --git a/tests/test_magazine.py b/tests/test_magazine.py index 5363b24..3d2b2f2 100644 --- a/tests/test_magazine.py +++ b/tests/test_magazine.py @@ -9,11 +9,16 @@ from __future__ import annotations import os import tempfile +import pathlib import unittest +from unittest import mock os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-magazine-test-config-")) from core.magazine import ( + ocr_page, + cluster_running_furniture, + looks_like_real_title, BLOCK_AD, BLOCK_BODY, BLOCK_BYLINE, @@ -27,8 +32,10 @@ from core.magazine import ( assign_reading_order, body_font_size, build_articles, + build_vision_blocks, classify_blocks, detect_columns, + estimate_font_size_from_area, estimate_listen_seconds, find_continuation, find_repeated_lines, @@ -37,8 +44,11 @@ from core.magazine import ( looks_like_byline, mark_advert_pages, mark_pullquotes, + ocr_quality_score, parse_contents, + parse_vision_ocr_regions, score_contents_page, + vision_ocr_page, ) PAGE_W, PAGE_H = 595.0, 842.0 # A4 in points @@ -254,6 +264,157 @@ class ContinuationTests(unittest.TestCase): self.assertEqual(find_continuation("The page turned slowly."), (None, None)) +class OcrParagraphGroupingTests(unittest.TestCase): + """ocr_page() groups tesseract's TSV by paragraph, not by line -- matching + how the native PyMuPDF path already aggregates a paragraph's spans into + one Block with one representative font size. One Block per line let a + single anomalous line (a chart number, scan noise, a headline's wrapped + second line) independently trigger BLOCK_TITLE classification; confirmed + live as the dominant cause of a 28-page scan producing ~11 "title" blocks + per page instead of grouping around real headlines.""" + + TSV_HEADER = "level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext" + + def _row(self, block, par, line, word_num, left, top, width, height, conf, text): + return f"5\t1\t{block}\t{par}\t{line}\t{word_num}\t{left}\t{top}\t{width}\t{height}\t{conf}\t{text}" + + def _run(self, rows): + tsv = "\n".join([self.TSV_HEADER] + rows) + fake = mock.Mock(stdout=tsv) + with mock.patch("subprocess.run", return_value=fake): + return ocr_page(pathlib.Path("/fake.png")) + + def test_two_lines_of_one_paragraph_become_a_single_block(self): + rows = [ + self._row(1, 1, 1, 1, 10, 100, 40, 20, 92, "Battery"), + self._row(1, 1, 1, 2, 55, 100, 40, 20, 91, "technology"), + self._row(1, 1, 2, 1, 10, 125, 60, 20, 90, "continues"), + ] + out = self._run(rows) + self.assertEqual(len(out), 1) + self.assertEqual(out[0]["text"], "Battery technology continues") + + def test_font_size_uses_median_line_height_not_total_bbox_height(self): + """The regression this guards: a paragraph's bbox spans every line it + contains, so using that height as font_size scales with line count. + A 5-line normal paragraph would then misread as giant display type.""" + rows = [self._row(1, 1, i, 1, 10, 100 + i * 20, 200, 18, 90, "word") + for i in range(1, 6)] + out = self._run(rows) + self.assertEqual(len(out), 1) + # Combined bbox height spans 5 lines (~118px); the real type size is + # each line's own ~18px height. + self.assertLess(out[0]["line_height"], out[0]["bbox"][3]) + self.assertAlmostEqual(out[0]["line_height"], 18.0, delta=1.0) + + def test_one_anomalous_line_does_not_dominate_the_paragraph_median(self): + """A single chart-axis number or scan artifact inside an otherwise + normal paragraph must not make the whole paragraph read as a title.""" + rows = [ + self._row(1, 1, 1, 1, 10, 100, 40, 18, 90, "normal"), + self._row(1, 1, 2, 1, 10, 120, 40, 95, 90, "GIANT"), # one outlier line + self._row(1, 1, 3, 1, 10, 220, 40, 19, 90, "normal"), + ] + out = self._run(rows) + self.assertLess(out[0]["line_height"], 30.0) + + def test_low_confidence_words_are_dropped(self): + rows = [ + self._row(1, 1, 1, 1, 10, 100, 40, 18, 90, "good"), + self._row(1, 1, 1, 2, 55, 100, 40, 18, 5, "garbage"), + ] + out = self._run(rows) + self.assertEqual(out[0]["text"], "good") + + def test_different_paragraphs_stay_separate_blocks(self): + rows = [ + self._row(1, 1, 1, 1, 10, 100, 40, 18, 90, "first"), + self._row(1, 2, 1, 1, 10, 300, 40, 18, 90, "second"), + ] + out = self._run(rows) + self.assertEqual(len(out), 2) + + def test_tesseract_failure_returns_empty_not_an_exception(self): + with mock.patch("subprocess.run", side_effect=FileNotFoundError()): + self.assertEqual(ocr_page(pathlib.Path("/fake.png")), []) + + +class RunningFurnitureFuzzyMatchTests(unittest.TestCase): + """Grounded in real OCR output from a scanned c't magazine: a running + footer ("c't Wissen 2026") was read differently on nearly every page, + defeating exact-match repetition and producing 26 near-1-per-page + "articles" on a 28-page scan instead of grouping around real headlines.""" + + def _footer(self, page, text, y=800): + b = block(f"f{page}", page, 60, y, 200, 18, text, size=18.3, btype=BLOCK_TITLE) + return b + + def test_ocr_noisy_footer_variants_cluster_as_furniture(self): + variants = ["c't Wissen 2026", "cE KI-WISSEN", "ce Wissen 2136", + "cA Wissen 2036", "Wissen 205", "Wissen 2026", + "Wissen 2025", "ce Wissen 206"] + blocks = [self._footer(i + 1, v) for i, v in enumerate(variants)] + heights = {b.page: PAGE_H for b in blocks} + cluster_running_furniture(blocks, heights) + self.assertTrue(all(b.type == BLOCK_FURNITURE for b in blocks), + [b.type for b in blocks]) + + def test_a_real_headline_appearing_once_is_not_reclassified(self): + blocks = [self._footer(1, "Wissen 2026"), self._footer(2, "Wissen 2026"), + self._footer(3, "Wissen 2026")] + headline = block("h1", 4, 50, 60, 470, 50, + "The Future of Solid State Batteries", size=26.0, + btype=BLOCK_TITLE) + blocks.append(headline) + heights = {b.page: PAGE_H for b in blocks} + cluster_running_furniture(blocks, heights) + self.assertEqual(headline.type, BLOCK_TITLE) + + def test_fewer_than_min_count_repeats_are_left_alone(self): + """Two coincidental near-matches should not be enough -- avoids + reclassifying a genuinely rare pair of similar short headlines.""" + blocks = [self._footer(1, "Wissen 2026"), self._footer(2, "Wissen 2036")] + heights = {b.page: PAGE_H for b in blocks} + cluster_running_furniture(blocks, heights) + self.assertTrue(all(b.type == BLOCK_TITLE for b in blocks)) + + def test_body_position_text_is_never_reclassified_regardless_of_repetition(self): + """Only near-edge blocks are candidates -- a repeated body phrase + (e.g. a slogan) must not be swept into furniture.""" + blocks = [block(f"b{i}", i + 1, 50, 400, 200, 20, "Wissen 2026", size=18.3) + for i in range(5)] + heights = {b.page: PAGE_H for b in blocks} + cluster_running_furniture(blocks, heights) + self.assertTrue(all(b.type == BLOCK_BODY for b in blocks)) + + def test_heavily_truncated_ocr_fragment_does_not_falsely_join(self): + variants = ["Arbeitswelt & Wirtschaft"] * 3 + ["& Wi"] + blocks = [self._footer(i + 1, v) for i, v in enumerate(variants)] + heights = {b.page: PAGE_H for b in blocks} + cluster_running_furniture(blocks, heights) + # The three clean repeats still cluster... + self.assertEqual(sum(1 for b in blocks if b.type == BLOCK_FURNITURE), 3) + # ...the badly truncated one is left for a human to catch in review. + self.assertEqual(blocks[3].type, BLOCK_TITLE) + + +class TitlePlausibilityTests(unittest.TestCase): + def test_ocr_noise_fragments_are_rejected(self): + for junk in ("I", "@)X", ". |", "12", ""): + self.assertFalse(looks_like_real_title(junk), junk) + + def test_real_headlines_are_accepted(self): + for title in ("Wie KI Entwickler-Jobs verändert", "AI Jobs", "Batteries"): + self.assertTrue(looks_like_real_title(title), title) + + def test_single_garbled_title_does_not_start_an_empty_article(self): + """The exact failure seen live: a stray 'I' at running-footer size + on an otherwise bodyless page must not spawn its own article.""" + blocks = [block("b1", 10, 50, 800, 40, 18, "I", size=18.3, btype=BLOCK_TITLE)] + blocks[0].reading_order = 0 + self.assertEqual(group_articles(blocks, [], page_count=28), []) + + class ArticleGroupingTests(unittest.TestCase): def _issue_blocks(self): blocks = [ @@ -366,6 +527,218 @@ class ArticleGroupingTests(unittest.TestCase): self.assertTrue(all(a.pages[0] != 2 for a in arts)) +class OcrQualityScoreTests(unittest.TestCase): + """Grounded in real output from both OCR paths on a scanned c't magazine. + + Deliberately coarse: it only needs to tell "mostly garbage" from + "plausible prose" well enough to gate an expensive vision-OCR escalation + -- not to catch a subtle character substitution inside an otherwise + normal sentence (confirmed live: a misread "KI" -> "K |" inside a real + German sentence still scores high, since 7 of its 8 words are genuine; + that class of error needs a dictionary or language-model check). + """ + + def test_pure_symbol_noise_scores_at_or_near_zero(self): + for junk in (". |", "@)X", ""): + self.assertLess(ocr_quality_score(junk), 0.4, junk) + + def test_clean_prose_scores_high(self): + for text in ( + "Damit stellen die Forscher das Hauptversprechen der KI-Branche infrage.", + "1. Irrtum: KI ist ein allgemeiner Jobkiller", + ): + self.assertGreater(ocr_quality_score(text), 0.85, text) + + def test_a_single_misread_word_in_an_otherwise_real_sentence_is_not_caught(self): + """Documents the known limitation rather than hiding it: this is a + coarse gate, not an accuracy detector.""" + text = "K | soll Arbeit abnehmen, die Produktivität stei-" + self.assertGreater(ocr_quality_score(text), 0.7, text) + + def test_empty_and_whitespace_only_are_zero(self): + self.assertEqual(ocr_quality_score(""), 0.0) + self.assertEqual(ocr_quality_score(" "), 0.0) + + +class FontSizeAreaEstimateTests(unittest.TestCase): + """A vision OCR engine returns one box per paragraph, not per line (see + vision_ocr_page's docstring) -- there is no per-line height to read a + font size from, so it must be estimated from the paragraph's area and + its own text length.""" + + def test_longer_text_in_the_same_box_implies_a_smaller_font(self): + short = estimate_font_size_from_area(300, 100, text_length=50) + long_ = estimate_font_size_from_area(300, 100, text_length=500) + self.assertGreater(short, long_) + + def test_degenerate_inputs_return_zero_not_an_error(self): + self.assertEqual(estimate_font_size_from_area(0, 100, 50), 0.0) + self.assertEqual(estimate_font_size_from_area(100, 0, 50), 0.0) + self.assertEqual(estimate_font_size_from_area(100, 100, 0), 0.0) + + def test_a_plausible_headline_box_yields_a_plausible_display_size(self): + # "1. Irrtum: KI ist ein allgemeiner Jobkiller" in a 309x42 box. + size = estimate_font_size_from_area(309, 42, text_length=44) + self.assertGreater(size, 10.0) + self.assertLess(size, 40.0) + + +class VisionOcrRegionParsingTests(unittest.TestCase): + """Grounded in ocr-baidu-unlimited-ocr's actual response shape, confirmed + live against a real scanned c't magazine page.""" + + REAL_SAMPLE = ( + " Do not use special punctuation or formatting as visual depts. " + "Special formats such as Oalemium namespace.\n" + "text [260, 86, 589, 248]Killo Bertine in Brackmur et B. Bruche der " + "IT-Branche.\n" + "title [605, 294, 914, 336]1. Irrtum: KI ist ein allgemeiner Jobkiller\n" + "footer [65, 953, 143, 965]c.t KI Wissen 2026\n" + "footer [810, 948, 924, 960]Arbeitswelt & Wirtschaft\n" + "page_number [945, 945, 959, 959]7" + ) + + def test_preamble_before_the_first_marker_is_discarded(self): + """The engine sometimes prepends instruction-echoing junk, including + the literal decoy phrase '[No text detected]', before real output.""" + regions = parse_vision_ocr_regions(self.REAL_SAMPLE) + self.assertNotIn("Do not use", regions[0]["text"]) + self.assertNotIn("namespace", regions[0]["text"]) + + def test_every_labeled_region_is_recovered(self): + regions = parse_vision_ocr_regions(self.REAL_SAMPLE) + labels = [r["label"] for r in regions] + self.assertEqual(labels, ["text", "title", "footer", "footer", "page_number"]) + + def test_bbox_is_corner_to_corner_not_xywh(self): + regions = parse_vision_ocr_regions(self.REAL_SAMPLE) + footer = regions[2] + self.assertEqual(footer["bbox"], [65.0, 953.0, 78.0, 12.0]) + + def test_page_number_text_is_preserved(self): + regions = parse_vision_ocr_regions(self.REAL_SAMPLE) + self.assertEqual(regions[-1]["text"], "7") + + def test_completely_unstructured_output_yields_no_regions(self): + self.assertEqual(parse_vision_ocr_regions("just some prose, no markers"), []) + + def test_empty_input_yields_no_regions(self): + self.assertEqual(parse_vision_ocr_regions(""), []) + + def test_a_degenerate_zero_area_box_is_dropped(self): + self.assertEqual(parse_vision_ocr_regions("text [10,10,10,10]junk"), []) + + +class VisionBlockBuildingTests(unittest.TestCase): + """An explicit engine label (footer/title/page_number/caption) is trusted + outright rather than re-derived from font size, which vision OCR gives no + reliable measurement of -- only "text" (or an unrecognised label) is left + ambiguous for the normal classification pass.""" + + def test_footer_label_becomes_furniture_and_is_protected_from_reclassification(self): + regions = [{"label": "footer", "bbox": [10, 900, 100, 20], "text": "c.t Wissen 2026"}] + blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b") + self.assertEqual(blocks[0].type, BLOCK_FURNITURE) + self.assertEqual(blocks[0].source, "vision_label") + # classify_blocks must leave a vision_label block alone even though + # its heuristics, run in isolation, would call it something else. + classify_blocks(blocks, page_height=1049, page_width=897) + self.assertEqual(blocks[0].type, BLOCK_FURNITURE) + + def test_title_label_becomes_title_and_survives_classification(self): + regions = [{"label": "title", "bbox": [605, 294, 914, 336], + "text": "1. Irrtum: KI ist ein allgemeiner Jobkiller"}] + blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b") + classify_blocks(blocks, page_height=1049, page_width=897) + self.assertEqual(blocks[0].type, BLOCK_TITLE) + + def test_page_number_label_becomes_furniture(self): + regions = [{"label": "page_number", "bbox": [945, 945, 959, 959], "text": "7"}] + blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b") + self.assertEqual(blocks[0].type, BLOCK_FURNITURE) + + def test_caption_label_becomes_caption(self): + regions = [{"label": "caption", "bbox": [10, 10, 100, 20], "text": "A lithium cell."}] + blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b") + self.assertEqual(blocks[0].type, BLOCK_CAPTION) + + def test_text_label_is_ambiguous_and_goes_through_normal_classification(self): + regions = [{"label": "text", "bbox": [50, 100, 400, 300], + "text": "Ordinary paragraph copy. " * 10}] + blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b") + self.assertEqual(blocks[0].source, "vision_ocr") + classify_blocks(blocks, page_height=1049, page_width=897, base_size=10.0) + self.assertEqual(blocks[0].type, BLOCK_BODY) + + def test_an_unrecognised_label_is_also_treated_as_ambiguous(self): + regions = [{"label": "sidebar_thing", "bbox": [50, 100, 400, 300], "text": "Some text"}] + blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b") + self.assertEqual(blocks[0].source, "vision_ocr") + + def test_scale_factor_is_applied_to_the_bbox(self): + regions = [{"label": "footer", "bbox": [100, 200, 300, 220], "text": "x"}] + blocks = build_vision_blocks(regions, page_no=1, scale_x=0.5, id_prefix="b") + self.assertEqual(blocks[0].bbox, [50.0, 100.0, 150.0, 110.0]) + + def test_block_ids_are_unique_and_page_scoped(self): + regions = [{"label": "text", "bbox": [0, 0, 10, 10], "text": "a"}, + {"label": "text", "bbox": [0, 20, 10, 10], "text": "b"}] + blocks = build_vision_blocks(regions, page_no=3, scale_x=1.0, id_prefix="b") + self.assertEqual(len({b.id for b in blocks}), 2) + self.assertTrue(all("0003" in b.id for b in blocks)) + + +class VisionOcrPageCallTests(unittest.TestCase): + """The HTTP call itself, with the network mocked -- vision_ocr_page must + degrade to None on any failure so ingestion can fall back to tesseract + rather than fail outright.""" + + def _resp(self, status=200, json_body=None): + import requests + resp = mock.Mock(status_code=status) + resp.raise_for_status = mock.Mock( + side_effect=None if status < 400 + else requests.exceptions.HTTPError(f"HTTP {status}")) + resp.json = mock.Mock(return_value=json_body or {}) + return resp + + def test_successful_call_returns_parsed_regions(self): + body = {"choices": [{"message": {"content": + "text [10,10,110,60]Hello there, this is a real sentence."}}]} + with mock.patch("core.magazine.request_with_retries", + return_value=self._resp(json_body=body)): + out = vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "model-x") + self.assertIsNotNone(out) + self.assertEqual(out[0]["label"], "text") + + def test_unreachable_engine_returns_none(self): + import requests + with mock.patch("core.magazine.request_with_retries", + side_effect=requests.exceptions.ConnectionError()): + self.assertIsNone(vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "m")) + + def test_http_error_status_returns_none(self): + with mock.patch("core.magazine.request_with_retries", + return_value=self._resp(status=500)): + self.assertIsNone(vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "m")) + + def test_unparseable_response_shape_returns_none(self): + with mock.patch("core.magazine.request_with_retries", + return_value=self._resp(json_body={"unexpected": "shape"})): + self.assertIsNone(vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "m")) + + def test_output_with_zero_usable_regions_returns_none_not_empty_list(self): + """An empty-but-truthy list would look like success to the caller.""" + body = {"choices": [{"message": {"content": "no markers in here at all"}}]} + with mock.patch("core.magazine.request_with_retries", + return_value=self._resp(json_body=body)): + self.assertIsNone(vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "m")) + + def test_missing_file_returns_none(self): + self.assertIsNone(vision_ocr_page(pathlib.Path("/no/such/file.png"), + "http://engine/v1", "m")) + + class ContinuationMarkerStrippingTests(unittest.TestCase): def test_a_print_layout_cue_is_removed_before_narration(self): """Spoken aloud this would read as literal parenthesised text -- a