diff --git a/CHANGELOG.md b/CHANGELOG.md index bd2868e..feb2955 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,34 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] +### Added +- **Split-Screen Article Review, "OCR Again", Reading-Order Fixes.** Review shows the rendered page on the left with a + numbered box per detected region and the extracted text on the right with matching numbers — clicking either side + highlights the other. Building this immediately surfaced a real bug on live production data (see Fixed below). + A per-page **"OCR again"** button forces that one page through the configured vision engine again without touching + article grouping or approvals (`reocr_page`); a magazine-level **"OCR again (whole magazine)"** re-reads every page + and re-runs detection, with a confirmation when articles were already reviewed since that resets their status. + ### Fixed +- **Two Real Magazine Columns Interleaved Into One.** Found live using the new split-screen review's numbered boxes on + a real scanned c't magazine page: a photo caption at 49.9% of the page width sat in the gutter between two genuinely + separate columns and merged them into a single detected column, so every block on the page came back `column=0` and + reading order fell back to pure top-to-bottom — splicing the two columns' sentences together (a box the review + numbered 6, physically above the box numbered 1, was read after it). Column boundary detection now excludes blocks + much wider than the page's median block width; separately, the real gutter measured on that page was itself only + ~2.1% of the page width, narrower than `detect_columns`' 3.5% threshold — lowered to 1.5%. +- **Magazine "Forgets" the Open Document.** A full page reload or the browser's own Back button tears down all + in-memory state; the magazine section always landed back on the empty library with no way back to what was open. + Now persists the last-opened magazine id and reopens it automatically. The actual root cause of a reported duplicate: + Studio/Reader's book library and Library → Books both listed every document with no `kind` filter, so the magazine + was already showing up there disguised as a plain book — it had not vanished, it was misfiled, which is what led to + a re-upload and a genuine duplicate under a different kind. Both listings now exclude `kind: "magazine"`. +- **Confidence Badge Layout (Studio Characters Review).** The badge rendered on its own line far below the speaker + name because both the name and the article text were `display: block`, forcing a line break regardless of markup + order. Wrapped the name, badge and check button in a shared flex row so the badge now sits directly beside the name. +- **docker-socket-proxy Crash Loop.** `read_only: true` left the container's own entrypoint nowhere to write its + `haproxy.cfg`, crash-looping it and leaving the app without Docker access. `cap_drop: ALL`, `no-new-privileges` and + the `:ro` socket mount already provide the real hardening, so `read_only` was removed for this one service. - **Magazine Dropzone Opened the Dropped File in the Browser Instead of Accepting It.** `dragover`'s `preventDefault()` was gated on `dataTransfer.types` including `'Files'`, which Chromium does not reliably populate synchronously on every `dragover` event during a real OS file drag — the condition failed often enough that the element never diff --git a/architecture/magazine-ingestion.md b/architecture/magazine-ingestion.md index 80451be..e6ce877 100644 --- a/architecture/magazine-ingestion.md +++ b/architecture/magazine-ingestion.md @@ -78,6 +78,24 @@ magazines change column count per page — a fixed grid is wrong. Blocks spannin >60% of the page width sort ahead of both columns, so a full-width headline precedes the body it introduces. +**Column boundary detection excludes unusually wide blocks, and the gutter +threshold is narrower than it looks.** Confirmed live on a real scanned c't +page, using the split-screen review's numbered boxes (built specifically to +make exactly this kind of error visible): a photo caption at 49.9% of the +page width — under the 60% "spans the page" threshold above, so not given +special handling — sat in the whitespace gap between two genuinely separate +columns and merged them into one detected column. Every block on the page +came back `column=0`, and reading order fell back to pure top-to-bottom, +splicing the two real columns' sentences together — the box numbered 6 in +review (a caption physically above the box numbered 1) was read *after* it. +Column boundaries are now detected from blocks close to the page's median +width only, and the real gutter measured on that page was itself only ~2.1% +of the page width, narrower than the 3.5% `detect_columns` started with — now +1.5%. A single block much narrower than the surrounding column (a stray +caption fragment) can still form a spurious extra column of its own; the +main failure (two real columns interleaved) is fixed, that residual +narrower one is not. + **Furniture is identified by repetition, not position.** A running head and a 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 diff --git a/core/magazine.py b/core/magazine.py index da75e1d..d6ebe95 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -141,13 +141,20 @@ def bbox_overlaps(a: Sequence[float], b: Sequence[float], tol: float = 0.0) -> b def detect_columns(blocks: Sequence[Block], page_width: float, - min_gap_ratio: float = 0.035) -> list[tuple[float, float]]: + min_gap_ratio: float = 0.015) -> list[tuple[float, float]]: """Infer the column grid from where blocks are *absent*. Magazines vary the column count per page, so a fixed grid is wrong. The reliable signal is vertical whitespace running the height of the page: we project every block onto the x axis, then split wherever the uncovered gap is wider than a few percent of the page. Returns (left, right) spans. + + 1.5%, not the rounder 3.5% this started at: measured on a real scanned + c't magazine page, the actual gutter between two genuine columns was + only ~2.1% of the page width -- narrower than the old threshold, which + made detect_columns see one merged column and, combined with a wide + caption bridging the same gap (see assign_reading_order), produced + every block on the page landing in column=0. """ spans = sorted( ((b.bbox[0], b.bbox[0] + b.bbox[2]) for b in blocks if b.bbox[2] > 1), @@ -171,8 +178,23 @@ def assign_reading_order(blocks: list[Block], page_width: float) -> list[Block]: Sorting purely by y interleaves parallel columns, which is precisely the failure that splices one article's sentence into another's. + + Column BOUNDARIES are detected from blocks close to the page's typical + width only. Confirmed live on a real scanned magazine: a caption running + under a photo, at 49.9% of the page width -- under the "spans the whole + page" threshold below, so not given special handling -- sat squarely in + the whitespace gap between two real text columns and bridged them into + one merged span, which meant EVERY block on the page got column=0 and + reading order fell back to pure top-to-bottom, splicing the two + columns' sentences together (the exact failure this function exists to + prevent). Every block, wide ones included, is still assigned to + whichever column its centre or overlap lands in once boundaries are + known -- only the boundary DETECTION excludes them. """ - columns = detect_columns(blocks, page_width) + widths = sorted(b.bbox[2] for b in blocks if b.bbox[2] > 1) + median_w = widths[len(widths) // 2] if widths else 0 + narrow = [b for b in blocks if median_w <= 0 or b.bbox[2] <= median_w * 1.4] + columns = detect_columns(narrow if len(narrow) >= 2 else blocks, page_width) def column_of(b: Block) -> int: centre = b.bbox[0] + b.bbox[2] / 2 @@ -1003,6 +1025,7 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110, vision_ocr_fallback_url: str | None = None, vision_ocr_fallback_model: str | None = None, vision_ocr_timeout: float = 150.0, + force_vision_ocr: bool = False, progress: Any = None) -> dict[str, Any]: """Run the full extraction for one PDF, writing page renders and images. @@ -1111,7 +1134,7 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110, 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 + needs_escalation = force_vision_ocr or needs_ocr or native_quality < _VISION_QUALITY_THRESHOLD vision_blocks: list[Block] | None = None if vision_enabled and needs_escalation: @@ -1326,6 +1349,69 @@ def build_articles(layout: dict[str, Any]) -> list[dict[str, Any]]: return out +def reocr_page(doc_dir: Path, layout: dict[str, Any], page_no: int, + vision_ocr_url: str, vision_ocr_model: str, + vision_ocr_fallback_url: str | None = None, + vision_ocr_fallback_model: str | None = None, + timeout: float = 150.0) -> dict[str, Any] | None: + """Force one page through the vision OCR chain again with the engine the + user picked, replacing only that page's blocks in an already-ingested + layout. + + Unlike extract_pages()'s automatic escalation, this always calls the + engine regardless of the page's existing text quality -- a user pressing + "OCR again" has already judged the result unusable and is not asking for + another quality gate. Deliberately does NOT call build_articles(): an + article the user already reviewed and approved must not be silently + re-grouped and lose that approval just because one of its pages was + re-read. The caller decides whether to re-run detection separately. + + Returns the updated layout, or None if the page has no rendered PNG or + the engine returned nothing usable (network failure, empty response) -- + the caller keeps the existing blocks in that case, never discards good + data for a failed retry. + """ + fitz = _require_fitz() + png = doc_dir / "pages" / f"{page_no:04d}.png" + if not png.is_file(): + return None + + page_meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None) + if not page_meta: + return None + + engines = [(vision_ocr_url, vision_ocr_model), + (vision_ocr_fallback_url, vision_ocr_fallback_model)] + regions = vision_ocr_page_chain(png, engines, timeout=timeout) + if not regions: + return None + + scale_x = page_meta["width"] / max(1.0, _png_width(png, fitz)) + new_blocks = build_vision_blocks(regions, page_no, scale_x, id_prefix="r") + + all_blocks = [Block(**b) for b in layout.get("blocks", [])] + kept = [b for b in all_blocks if b.page != page_no] + + # The baseline must come from the rest of the document, not this page + # alone -- see classify_blocks' docstring on why a single page (which may + # have no body copy of its own) cannot be its own reference. + doc_base = body_font_size(kept) or body_font_size(all_blocks) + classify_blocks(new_blocks, page_meta["height"], page_meta["width"], base_size=doc_base) + mark_advert_pages(new_blocks) + mark_pullquotes(new_blocks) + assign_reading_order(new_blocks, page_meta["width"]) + mark_sidebars(new_blocks, page_meta["width"]) + + merged = kept + new_blocks + page_heights = {p["page"]: p["height"] for p in layout.get("pages", [])} + cluster_running_furniture(merged, page_heights) + + layout = dict(layout) + layout["blocks"] = [b.to_json() for b in merged] + layout["vision_ocr_pages"] = int(layout.get("vision_ocr_pages") or 0) + 1 + return layout + + def save_layout(doc_dir: Path, layout: dict[str, Any]) -> None: (doc_dir / "layout.json").write_text( json.dumps(layout, ensure_ascii=False), "utf-8") diff --git a/routes/magazine.py b/routes/magazine.py index 7ab4cc9..3a462e8 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -111,7 +111,8 @@ async def magazine_create(request: Request): 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, vision_ocr_fallback_url: str | None = None, - vision_ocr_fallback_model: str | None = None) -> None: + vision_ocr_fallback_model: str | None = None, + force_vision_ocr: bool = False) -> None: """Run the extraction pipeline; recorded as a restart-safe job.""" d = _LIB / doc_id pdf = d / "source.pdf" @@ -131,6 +132,7 @@ def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int, vision_ocr_model=vision_ocr_model, vision_ocr_fallback_url=vision_ocr_fallback_url, vision_ocr_fallback_model=vision_ocr_fallback_model, + force_vision_ocr=force_vision_ocr, progress=progress) mag.save_layout(d, layout) @@ -203,6 +205,13 @@ async def magazine_ingest(doc_id: str, request: Request): if not (vision_ocr_fallback_url and vision_ocr_fallback_model): vision_ocr_fallback_url = vision_ocr_fallback_model = None + # "OCR again" for the whole magazine: skip the quality gate and send + # every page through the vision engine regardless of how good its + # existing text already looks. Only meaningful with vision OCR actually + # enabled -- silently ignored otherwise, since there is no engine to + # force a page through. + force_vision_ocr = bool(body.get("force_vision_ocr")) and vision_ocr_url is not None + job_id = uuid.uuid4().hex background_job_create(job_id, "magazine-ingest", fingerprint=doc_id) meta["ingest"] = {"status": "running", "job_id": job_id} @@ -211,7 +220,8 @@ async def magazine_ingest(doc_id: str, request: Request): asyncio.create_task( asyncio.to_thread(_ingest_sync, doc_id, job_id, ocr_lang, render_dpi, vision_ocr_url, vision_ocr_model, - vision_ocr_fallback_url, vision_ocr_fallback_model) + vision_ocr_fallback_url, vision_ocr_fallback_model, + force_vision_ocr) ) return {"job_id": job_id} @@ -356,6 +366,70 @@ async def magazine_page_image(doc_id: str, page: int): return FileResponse(png, media_type="image/png") +def _reocr_page_sync(doc_id: str, job_id: str, page: int, + vision_ocr_url: str, vision_ocr_model: str, + vision_ocr_fallback_url: str | None, + vision_ocr_fallback_model: str | None) -> None: + """"OCR again" on one page with the engine the user picked. Restart-safe + job for the same reason ingestion is: a vision OCR call can take up to + ~40s (confirmed live), too long for a plain request/response cycle. + """ + d = _LIB / doc_id + try: + layout = mag.load_layout(d) + if not layout: + raise FileNotFoundError("No layout yet -- run ingestion first") + background_job_update(job_id, stage="reocr", detail=f"Re-reading page {page}") + updated = mag.reocr_page(d, layout, page, vision_ocr_url, vision_ocr_model, + vision_ocr_fallback_url, vision_ocr_fallback_model) + if updated is None: + raise RuntimeError("The OCR engine returned nothing usable for this page") + mag.save_layout(d, updated) + background_job_update(job_id, stage="done", done=True, + detail=f"Page {page} re-read", result={"page": page}) + except Exception as exc: # noqa: BLE001 - surfaced to UI + background_job_update(job_id, stage="failed", done=True, error=str(exc)[:1000]) + + +@router.post("/api/magazine/{doc_id}/pages/{page}/reocr") +async def magazine_reocr_page(doc_id: str, page: int, request: Request): + """Force one page through the configured vision OCR engine again, + regardless of its current text quality -- for when the user has already + judged the result unusable. Only replaces that page's blocks; article + boundaries and approvals are left untouched (call the ingest endpoint's + detection separately if the article needs re-grouping too). + """ + d, _ = _require_magazine(doc_id) + if page < 1 or page > 10000: + raise HTTPException(400, "Bad page") + if not (d / "pages" / f"{page:04d}.png").is_file(): + raise HTTPException(404, "Page not rendered -- run ingestion first") + + body = {} + try: + body = await request.json() + except Exception: + pass + settings = _load_settings() + 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): + raise HTTPException(400, "No vision OCR engine configured -- set one in Settings") + vision_ocr_fallback_url = str( + body.get("vision_ocr_fallback_url") or settings.get("vision_ocr_fallback_url") or "").strip() or None + vision_ocr_fallback_model = str( + body.get("vision_ocr_fallback_model") or settings.get("vision_ocr_fallback_model") or "").strip() or None + + job_id = uuid.uuid4().hex + background_job_create(job_id, "magazine-reocr-page", fingerprint=f"{doc_id}:{page}") + asyncio.create_task( + asyncio.to_thread(_reocr_page_sync, doc_id, job_id, page, + vision_ocr_url, vision_ocr_model, + vision_ocr_fallback_url, vision_ocr_fallback_model) + ) + return {"job_id": job_id} + + @router.get("/api/magazine/{doc_id}/images/{name}") async def magazine_image(doc_id: str, name: str): d, _ = _require_magazine(doc_id) diff --git a/static/js/magazine.js b/static/js/magazine.js index 0f73fdb..63e52eb 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -233,6 +233,30 @@ async function magIngest() { } } +// "OCR again" for the whole issue: forces every page through the vision +// engine regardless of its current quality, then re-detects articles -- +// which is why this asks for confirmation. A single page's "OCR again" in +// review does not touch article grouping; re-reading the whole document +// does, since detection runs again over the freshly re-read text. +async function magReocrMagazine() { + if (!state.doc) return; + const hasReviewed = state.articles.some((a) => a.status !== 'detected'); + if (hasReviewed && !confirm( + 'Re-reading the whole magazine runs article detection again. ' + + 'Any articles you already approved or rejected will need reviewing again. Continue?' + )) return; + try { + await api('/api/magazine/' + state.doc.id + '/ingest', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ force_vision_ocr: true }), + }); + pollIngest(); + } catch (e) { + say('Could not start OCR: ' + e.message, 'err'); + } +} + // "Analysing page 28 of 204" -> a real percentage, not just spinner text. function magParsePageProgress(detail) { const m = /page (\d+) of (\d+)/i.exec(detail || ''); @@ -364,6 +388,11 @@ async function magReview(articleId) { if (!article) return; state.article = article; await ensureLayout(); + // The whole review is one page at a time -- showing every page of a + // 10-page feature stacked at once was unreadable and made the boxes tiny. + state.reviewSpan = magReviewSpan(article); + state.reviewPage = state.reviewSpan[0] || null; + _magReviewChecked = null; magShow('review'); renderReview(); } @@ -385,18 +414,35 @@ function blocksForPages(pages) { .sort((a, b) => a.page - b.page || a.reading_order - b.reading_order); } +// Offer neighbouring pages too, so a boundary that started a page late (or a +// jump tail that should have been included) can be corrected without +// leaving the screen. +function magReviewSpan(article) { + if (!article.pages.length) return []; + return Array.from(new Set(article.pages.concat( + [article.pages[0] - 1, article.pages[article.pages.length - 1] + 1]))) + .filter((p) => p >= 1 && p <= (state.layout.page_count || 0)).sort((x, y) => x - y); +} + +function magReviewGoPage(page) { + if (!state.reviewSpan.includes(page)) return; + state.reviewPage = page; + renderReview(); +} + function renderReview() { const host = $('mag-review'); const a = state.article; if (!host || !a) return; const included = new Set(a.blocks || []); - // Offer neighbouring pages too, so a boundary that started a page late can be - // corrected without leaving the screen. - const span = a.pages.length - ? Array.from(new Set(a.pages.concat([a.pages[0] - 1, a.pages[a.pages.length - 1] + 1]))) - .filter((p) => p >= 1 && p <= (state.layout.page_count || 0)).sort((x, y) => x - y) - : []; const conf = confidenceLabel(a.confidence); + const page = state.reviewPage; + const pageMeta = (state.layout.pages || []).find((p) => p.page === page); + const pageBlocks = page == null ? [] : blocksForPages([page]); + + const spanIdx = state.reviewSpan.indexOf(page); + const prevPage = spanIdx > 0 ? state.reviewSpan[spanIdx - 1] : null; + const nextPage = spanIdx >= 0 && spanIdx < state.reviewSpan.length - 1 ? state.reviewSpan[spanIdx + 1] : null; let html = '
Untick anything that is not part of this article — a sidebar, an advert, ' + - 'or a neighbouring piece. Only ticked text is read aloud.
'; + 'or a neighbouring piece. Only ticked text is read aloud. Click a numbered region on the page, ' + + 'or its matching entry on the right, to line the two up.'; - let currentPage = null; + html += 'No page to show.
'; + } + html += 'Nothing detected on this page.
'; + html += '