feat(magazine): auto-add figures for unboxed pictures, junk filter for linking, keep quality report in step

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-28 13:33:11 +02:00
parent bf5c795f02
commit c62cc6953d
5 changed files with 181 additions and 1 deletions

View File

@ -31,6 +31,10 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **"Article ends" is only claimed when it is true.** An article counts as ended when it is signed off (`(rah) et`, `(hag)` — OCR noise after the code is tolerated) or when the next region, on the same page or at the top of the next, is a headline, deck or byline. Otherwise the last box shows a clickable *no end marker — link page N ›* chip that adds the next page's text in one click. - **"Article ends" is only claimed when it is true.** An article counts as ended when it is signed off (`(rah) et`, `(hag)` — OCR noise after the code is tolerated) or when the next region, on the same page or at the top of the next, is a headline, deck or byline. Otherwise the last box shows a clickable *no end marker — link page N ›* chip that adds the next page's text in one click.
- **Photos and graphics that no box covers get a figure region automatically** (a dense unboxed blob of real size; thin, line-structured ink is text and is left alone). New figures join the article with the most text on their page.
- **Text that is not narratable page furniture stays out of linked articles:** running heads that recur on 3+ pages, lists of page numbers and reference/source lists are no longer pulled in when continuation pages are linked. An article made only of text you already approved becomes approved.
- The per-page fix and the whole-issue run keep `quality_report.json` in step, so the thumbnail badges and the list of pages needing a decision match the layout as it is now. Running heads and adverts are no longer audited for clipped edges or overlaps.
### Fixed ### Fixed
- **Reading order is now applied automatically after import, *OCR again* and every per-page fix**, not only by the manual *Fix reading order* button. Until now the order after import was whatever the vision/LLM step returned, so a column's short last box could be read after the whole other column; on the measured issue 101 of 204 pages had a saved order that differs from the column-aware order. Pages whose order you fixed by hand stay untouched, except after *OCR again*, when every region is new and a hand order can no longer apply. - **Reading order is now applied automatically after import, *OCR again* and every per-page fix**, not only by the manual *Fix reading order* button. Until now the order after import was whatever the vision/LLM step returned, so a column's short last box could be read after the whole other column; on the measured issue 101 of 204 pages had a saved order that differs from the column-aware order. Pages whose order you fixed by hand stay untouched, except after *OCR again*, when every region is new and a hand order can no longer apply.
- **The review list shows each region's whole text**, not the first 220 characters, so every misspelling gets its red wavy underline (like a word processor). The engine's `[Non-Text]` marker and hairline boxes (under 3 pt tall) are now treated as OCR noise by *Clean up boxes* and after a page re-OCR, so they no longer show up as near-invisible regions. Fitted boxes keep a little air on grown sides. - **The review list shows each region's whole text**, not the first 220 characters, so every misspelling gets its red wavy underline (like a word processor). The engine's `[Non-Text]` marker and hairline boxes (under 3 pt tall) are now treated as OCR noise by *Clean up boxes* and after a page re-OCR, so they no longer show up as near-invisible regions. Fitted boxes keep a little air on grown sides.

View File

@ -3144,6 +3144,40 @@ def detect_inset_pullquotes(doc_dir: Path, layout: dict[str, Any], pages: Iterab
return layout, added return layout, added
_PICTURE_DENSITY = 0.35 # inked share of an unboxed blob above which it is a picture, not text
_PICTURE_MIN_PT = 30.0
def add_uncovered_pictures(doc_dir: Path, layout: dict[str, Any], page_no: int) -> tuple[dict[str, Any], list[str]]:
"""Give a figure region to each photo or graphic that no box covers.
The page audit lists ink outside every box. A dense blob of a real size is a picture the
layout model missed (text is thin and line-structured, ink share well under a third), so it
becomes a figure -- cropped for playback, and no longer reported as missing text. Returns the
layout and the new image ids. Idempotent: a covered picture is not found again.
"""
audit = audit_page_coverage(doc_dir, layout, page_no)
if not audit.get("uncovered"):
return layout, []
meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None)
ink = page_ink_mask(doc_dir / "pages" / f"{page_no:04d}.png")
if meta is None or ink is None:
return layout, []
height, width = ink.shape
sx, sy = width / max(1.0, float(meta["width"])), height / max(1.0, float(meta["height"]))
added: list[str] = []
for x, y, w, h in audit["uncovered"]:
if w < _PICTURE_MIN_PT or h < _PICTURE_MIN_PT:
continue
region = ink[int(y * sy):int((y + h) * sy), int(x * sx):int((x + w) * sx)]
if region.size == 0 or float(region.mean()) < _PICTURE_DENSITY:
continue
before = {i["id"] for i in layout.get("images", [])}
layout = add_manual_region(doc_dir, layout, page_no, [x, y, w, h], "image")
added += [i["id"] for i in layout.get("images", []) if i["id"] not in before]
return layout, added
def split_around_pullquotes(doc_dir: Path, layout: dict[str, Any], def split_around_pullquotes(doc_dir: Path, layout: dict[str, Any],
pages: Iterable[int] | None = None) -> tuple[dict[str, Any], list[dict[str, str]]]: pages: Iterable[int] | None = None) -> tuple[dict[str, Any], list[dict[str, str]]]:
"""Cut a paragraph's box where its text wraps around an inset pull quote. """Cut a paragraph's box where its text wraps around an inset pull quote.
@ -3373,7 +3407,8 @@ def audit_page_coverage(doc_dir: Path, layout: dict[str, Any], page_no: int) ->
suspects = [b["id"] for b in blocks suspects = [b["id"] for b in blocks
if b["type"] in eligible if b["type"] in eligible
and suspect_ocr_tokens(" ".join((b.get("text") or "").split()), page_language, page_vocabulary)] and suspect_ocr_tokens(" ".join((b.get("text") or "").split()), page_language, page_vocabulary)]
text_blocks = [b for b in blocks if b["type"] not in VISUAL_TYPES] # Running heads and adverts are never narrated: a box edge or an overlap there is not a problem.
text_blocks = [b for b in blocks if b["type"] not in VISUAL_TYPES and b["type"] not in (BLOCK_FURNITURE, BLOCK_AD)]
overlaps = [[a["id"], b["id"]] for i, a in enumerate(text_blocks) for b in text_blocks[i + 1:] overlaps = [[a["id"], b["id"]] for i, a in enumerate(text_blocks) for b in text_blocks[i + 1:]
if max(_bbox_intersection_ratio(a["bbox"], b["bbox"]), if max(_bbox_intersection_ratio(a["bbox"], b["bbox"]),
_bbox_intersection_ratio(b["bbox"], a["bbox"])) >= _AUDIT_OVERLAP_RATIO] _bbox_intersection_ratio(b["bbox"], a["bbox"])) >= _AUDIT_OVERLAP_RATIO]
@ -4679,6 +4714,26 @@ def _is_placeholder_article(article: dict[str, Any]) -> bool:
return bool(_PLACEHOLDER_TITLE.fullmatch((article.get("title") or "").strip())) return bool(_PLACEHOLDER_TITLE.fullmatch((article.get("title") or "").strip()))
_REFERENCE_HEAD_RE = re.compile(r"^\s*(literatur|quellen|studien, weitere quellen|weiterführende|links?\b|anmerkungen?\b)", re.I)
def looks_like_page_junk(text: str, repeats: int = 1) -> bool:
"""Text that is on a page but is not something to narrate: a running head that recurs on many
pages, a list of page numbers, or a reference/source list. Never used to delete anything the
reviewer approved -- only to keep such text out of an article automatically."""
t = " ".join((text or "").split())
if not t:
return True
letters = sum(c.isalpha() for c in t)
if letters < 0.5 * len(t.replace(" ", "")) and len(t) < 200:
return True # "66 90,98 50,56 82, 114 ..."
if repeats >= 3 and len(t) < 80:
return True # the same short line on 3+ pages: a running head
if _REFERENCE_HEAD_RE.match(t) and len(t) < 400:
return True
return False
def auto_link_pages(layout: dict[str, Any], articles: list[dict[str, Any]], def auto_link_pages(layout: dict[str, Any], articles: list[dict[str, Any]],
max_gap_pages: int = 2) -> tuple[list[dict[str, Any]], dict[str, Any]]: max_gap_pages: int = 2) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""Attach text that no real article owns to the article running from the page before. """Attach text that no real article owns to the article running from the page before.
@ -4700,8 +4755,16 @@ def auto_link_pages(layout: dict[str, Any], articles: list[dict[str, Any]],
approved_ids = {b for a in articles if a.get("status") == "approved" for b in a["blocks"]} approved_ids = {b for a in articles if a.get("status") == "approved" for b in a["blocks"]}
blocks = {b["id"]: b for b in layout.get("blocks", [])} blocks = {b["id"]: b for b in layout.get("blocks", [])}
by_page: dict[int, list[dict[str, Any]]] = {} by_page: dict[int, list[dict[str, Any]]] = {}
seen: dict[str, int] = {}
for b in layout.get("blocks", []): for b in layout.get("blocks", []):
if b["type"] in NARRATABLE and (b.get("text") or "").strip(): if b["type"] in NARRATABLE and (b.get("text") or "").strip():
key = re.sub(r"\d+", "#", " ".join(b["text"].split()).casefold())
seen[key] = seen.get(key, 0) + 1
for b in layout.get("blocks", []):
if b["type"] in NARRATABLE and (b.get("text") or "").strip():
key = re.sub(r"\d+", "#", " ".join(b["text"].split()).casefold())
if b["id"] not in owner and looks_like_page_junk(b["text"], seen[key]):
continue # not narratable page furniture: leave it out
by_page.setdefault(b["page"], []).append(b) by_page.setdefault(b["page"], []).append(b)
report = {"linked": 0, "created": 0, "unlinked": [], "dropped": 0} report = {"linked": 0, "created": 0, "unlinked": [], "dropped": 0}
taken = {int(m.group(1)) for a in articles if (m := re.fullmatch(r"article-(\d+)", a["id"]))} taken = {int(m.group(1)) for a in articles if (m := re.fullmatch(r"article-(\d+)", a["id"]))}
@ -4783,6 +4846,12 @@ def auto_link_pages(layout: dict[str, Any], articles: list[dict[str, Any]],
report["dropped"] += 1 report["dropped"] += 1
continue continue
kept.append(a) kept.append(a)
# An article whose every text region the reviewer had already approved (in whichever article
# it sat before) is as reviewed as it was: do not make it be approved a second time.
for a in kept:
narrated = [b for b in a["blocks"] if blocks.get(b, {}).get("type") in NARRATABLE]
if a.get("status") == "detected" and narrated and all(b in approved_ids for b in narrated):
a["status"] = "approved"
return kept, report return kept, report

View File

@ -1066,6 +1066,34 @@ def _vision_args(settings: dict[str, Any]) -> tuple[str | None, str | None, str
pick("vision_ocr_fallback_url"), pick("vision_ocr_fallback_model")) pick("vision_ocr_fallback_url"), pick("vision_ocr_fallback_model"))
def _refresh_quality_rows(d: Path, layout: dict[str, Any], pages: list[int]) -> None:
"""Re-measure `pages` and update their rows in quality_report.json, so the thumbnail badges and
the list of pages needing a decision match the layout as it is now (a per-page fix otherwise
left the last whole-issue run's numbers standing)."""
path = d / _QUALITY_REPORT
try:
report = json.loads(path.read_text("utf-8"))
except (OSError, ValueError):
return
rows = {r["page"]: r for r in report.get("pages", [])}
for page in pages:
if mag.page_is_advertisement(layout, page):
continue
fresh = mag.audit_page_coverage(d, layout, page)
row = rows.get(page, {"page": page})
row.update({k: fresh[k] for k in ("uncovered", "uncovered_ratio", "suspect_blocks", "overlaps", "clipped", "measured")})
spell = row.get("spelling") or []
row["needs_review"] = bool(row["uncovered"] or row["overlaps"] or row["clipped"] or row.get("error")
or row.get("duplicates"))
row["words_to_check"] = bool(row["suspect_blocks"] or spell)
rows[page] = row
report["pages"] = [rows[p] for p in sorted(rows)]
summary = report.setdefault("summary", {})
summary["pages_needing_review"] = [r["page"] for r in report["pages"] if r.get("needs_review")]
summary["pages_with_words"] = [r["page"] for r in report["pages"] if r.get("words_to_check") and not r.get("needs_review")]
path.write_text(json.dumps(report, ensure_ascii=False), encoding="utf-8")
def _fix_page_geometry(d: Path, layout: dict[str, Any], articles: list[dict[str, Any]], page: int, def _fix_page_geometry(d: Path, layout: dict[str, Any], articles: list[dict[str, Any]], page: int,
settings: dict[str, Any]) -> tuple[dict[str, Any], dict[str, int], bool]: settings: dict[str, Any]) -> tuple[dict[str, Any], dict[str, int], bool]:
"""The deterministic box fixes for one page, in the order that lets each rely on the last. """The deterministic box fixes for one page, in the order that lets each rely on the last.
@ -1087,8 +1115,17 @@ def _fix_page_geometry(d: Path, layout: dict[str, Any], articles: list[dict[str,
layout = mag.reorder_page(layout, page) layout = mag.reorder_page(layout, page)
if url and model and any(m.get("lines_added") for m in snapped): if url and model and any(m.get("lines_added") for m in snapped):
layout, _ = mag.reread_grown_regions(d, layout, snapped, url, model, fb_url, fb_model) layout, _ = mag.reread_grown_regions(d, layout, snapped, url, model, fb_url, fb_model)
layout, pictures = mag.add_uncovered_pictures(d, layout, page)
changed = _add_split_paragraphs_to_articles(articles, cleanup) changed = _add_split_paragraphs_to_articles(articles, cleanup)
if pictures and articles:
# A new figure belongs to the article with the most text on its page.
on_page = {b["id"] for b in layout.get("blocks", []) if b["page"] == page}
owner = max(articles, key=lambda a: len(on_page & set(a.get("blocks") or [])), default=None)
if owner is not None and on_page & set(owner.get("blocks") or []):
owner["images"] = list(dict.fromkeys([*(owner.get("images") or []), *pictures]))
changed = True
counts = {"noise_retyped": noise, "pullquotes_found": len(quotes), "paragraphs_cut": len(wrap), counts = {"noise_retyped": noise, "pullquotes_found": len(quotes), "paragraphs_cut": len(wrap),
"pictures_added": len(pictures),
"boxes_fitted": len(snapped), "bylines_split": len(cleanup.get("byline_split", []))} "boxes_fitted": len(snapped), "bylines_split": len(cleanup.get("byline_split", []))}
return layout, counts, changed return layout, counts, changed
@ -1259,6 +1296,7 @@ def _autofix_page_sync(doc_id: str, job_id: str, page: int, mode: str, settings:
mag.save_layout(d, layout) mag.save_layout(d, layout)
if moved: if moved:
mag.save_articles(d, articles) mag.save_articles(d, articles)
_refresh_quality_rows(d, layout, [page])
_seal_undo(d, token) _seal_undo(d, token)
done["undo"] = token done["undo"] = token
fixed = sum(v for k, v in done.items() if k != "undo" and isinstance(v, int)) fixed = sum(v for k, v in done.items() if k != "undo" and isinstance(v, int))

View File

@ -3002,6 +3002,15 @@ class AutoLinkPagesTests(unittest.TestCase):
self.assertEqual(out[0]["status"], "approved") # the stand-ins were approved: still reviewed self.assertEqual(out[0]["status"], "approved") # the stand-ins were approved: still reviewed
self.assertEqual((report["linked"], report["dropped"]), (2, 2)) self.assertEqual((report["linked"], report["dropped"]), (2, 2))
def test_a_detected_article_made_only_of_already_approved_text_becomes_approved(self):
blocks = [self.blk("t", 1, "title", "Der Butler", 0), self.blk("a", 1, "body", "Der Text beginnt und", 1),
self.blk("b", 2, "body", "geht weiter.", 0)]
arts = [self.art("article-001", "Der Butler", [1], ["t", "a"], "detected"),
self.art("article-002", "Page 1", [1], ["t", "a"], "approved"),
self.art("article-003", "Page 2", [2], ["b"], "approved")]
out = self.link(blocks, arts)[0]
self.assertEqual([(a["id"], a["status"]) for a in out], [("article-001", "approved")])
def test_text_nobody_approved_sends_the_article_back_to_review(self): def test_text_nobody_approved_sends_the_article_back_to_review(self):
blocks = [self.blk("t", 1, "title", "Der Butler", 0), self.blk("a", 1, "body", "Der Text beginnt und", 1), blocks = [self.blk("t", 1, "title", "Der Butler", 0), self.blk("a", 1, "body", "Der Text beginnt und", 1),
self.blk("b", 2, "body", "geht weiter.", 0)] self.blk("b", 2, "body", "geht weiter.", 0)]
@ -3078,3 +3087,62 @@ class ArticleEndedTests(unittest.TestCase):
def test_a_headline_two_pages_on_does_not_end_it(self): def test_a_headline_two_pages_on_does_not_end_it(self):
self.assertFalse(self.ended([("a", 1, "body", "Ohne Kürzel am Ende"), ("h", 3, "title", "Viel später")], ["a"])) self.assertFalse(self.ended([("a", 1, "body", "Ohne Kürzel am Ende"), ("h", 3, "title", "Viel später")], ["a"]))
class PageJunkTests(unittest.TestCase):
def test_page_lists_running_heads_and_reference_lists_are_junk(self):
from core.magazine import looks_like_page_junk
self.assertTrue(looks_like_page_junk("66 90,98 50,56 82, 114, 120 148, 168 172, 194"))
self.assertTrue(looks_like_page_junk("46 Arbeitswelt. Wirtschaft", repeats=5))
self.assertTrue(looks_like_page_junk("Literatur [1] Trinkwalder, Wunderbare Einhörner"))
self.assertTrue(looks_like_page_junk("Studien, weitere Quellen und Tools:"))
self.assertFalse(looks_like_page_junk("Der Konzern braucht eine, die iPhone, iPad und Mac unentbehrlich macht."))
self.assertFalse(looks_like_page_junk("Ein kurzer Satz.", repeats=1))
def test_auto_link_leaves_such_text_out_of_the_article(self):
from core.magazine import auto_link_pages
blk = lambda i, pg, ty, t, n: {"id": i, "page": pg, "type": ty, "text": t, "reading_order": n, "bbox": [0, 0, 1, 1]}
layout = {"pages": [], "images": [], "blocks": [
blk("t", 1, "title", "Der Butler", 0), blk("a", 1, "body", "Der Text beginnt und", 1),
blk("b", 2, "body", "geht auf Seite zwei weiter.", 0), blk("j", 2, "body", "66 90,98 50,56 82, 114", 1)]}
art = lambda i, ti, pg, bl, st: {"id": i, "title": ti, "author": "", "pages": pg, "blocks": bl, "images": [], "status": st,
"confidence": 0.5, "section": "", "continued_to": [], "audio_status": "none"}
out, _ = auto_link_pages(layout, [art("article-001", "Der Butler", [1], ["t", "a"], "approved"), art("article-002", "Page 2", [2], ["b"], "approved")])
self.assertEqual(out[0]["blocks"], ["t", "a", "b"])
class UncoveredPictureTests(unittest.TestCase):
def setUp(self):
from pathlib import Path
from PIL import Image, ImageDraw
self.dir = Path(tempfile.mkdtemp(prefix="ttvc-pic-"))
(self.dir / "pages").mkdir()
im = Image.new("RGB", (600, 800), (248, 242, 244))
draw = ImageDraw.Draw(im)
draw.rectangle([100, 200, 300, 350], fill=(60, 90, 140)) # a photo: dense ink, 200x150
for y in range(500, 560, 12): # text lines: thin, sparse
draw.rectangle([100, y, 300, y + 3], fill=(93, 91, 92))
im.save(self.dir / "pages" / "0001.png")
self.layout = {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": []}
def tearDown(self):
shutil.rmtree(self.dir, ignore_errors=True)
def test_a_dense_unboxed_blob_becomes_a_figure_and_text_lines_do_not(self):
from core.magazine import add_uncovered_pictures
out, added = add_uncovered_pictures(self.dir, self.layout, 1)
self.assertEqual(len(added), 1)
x, y, w, h = out["images"][0]["bbox"]
self.assertTrue(90 <= x <= 105 and 190 <= y <= 205 and w >= 195 and h >= 145, out["images"][0]["bbox"])
self.assertTrue((self.dir / "images" / out["images"][0]["filename"]).is_file())
def test_running_it_again_finds_nothing_new(self):
from core.magazine import add_uncovered_pictures
once, _ = add_uncovered_pictures(self.dir, self.layout, 1)
self.assertEqual(add_uncovered_pictures(self.dir, once, 1)[1], [])
def test_running_heads_and_adverts_are_not_audited_for_clipped_edges(self):
from core.magazine import audit_page_coverage
layout = dict(self.layout, blocks=[{"id": "f", "page": 1, "type": "furniture", "text": "45", "bbox": [100, 500, 100, 10]},
{"id": "a", "page": 1, "type": "ad", "text": "Kaufen", "bbox": [100, 520, 100, 10]}])
self.assertEqual(audit_page_coverage(self.dir, layout, 1)["clipped"], [])

View File

@ -1462,6 +1462,7 @@ class QualityRunJobTests(unittest.TestCase):
patch('routes.magazine.mag.recover_missing_text', return_value=(layout, 1)), \ patch('routes.magazine.mag.recover_missing_text', return_value=(layout, 1)), \
patch('routes.magazine.mag.verify_ocr_recognition', return_value=(layout, 3, 2)) as verify, \ patch('routes.magazine.mag.verify_ocr_recognition', return_value=(layout, 3, 2)) as verify, \
patch('routes.magazine.mag.audit_page_coverage', side_effect=audit_row), \ patch('routes.magazine.mag.audit_page_coverage', side_effect=audit_row), \
patch('routes.magazine.mag.add_uncovered_pictures', side_effect=lambda d, layout, page: (layout, [])), \
patch('routes.magazine.mag.save_layout'), \ patch('routes.magazine.mag.save_layout'), \
patch('routes.magazine._LIB', lib), \ patch('routes.magazine._LIB', lib), \
patch('routes.magazine.background_job_update') as update: patch('routes.magazine.background_job_update') as update: