diff --git a/CHANGELOG.md b/CHANGELOG.md index fd19dae..c95d03b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,11 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi - **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. +- **Articles nobody has reviewed are blue.** In the article list an unapproved, unrejected article has a blue background and edge and a *not reviewed* pill (approved keeps its green edge), so what still needs a person stands out. +- **Overlapping boxes are fixed automatically.** Where a lower-ranked box runs over a higher-ranked one (headline > deck/byline > body > caption/sidebar/pull quote), the lower one is cut back to the largest part that no longer overlaps, unless that would leave under 40% of it (then both stay for a human). Hand-placed boxes and boxes of equal rank are never touched. +- **Fewer false alarms and better fits:** a strip of solid ink beside a headline (a photo or filled panel behind it) is no longer counted as letters cut off; headline and deck boxes may grow up to 70 px sideways (body boxes 16 px); an inset pull-quote box that repeats text a box already holds becomes a duplicate. +- **Unboxed ink in or beside a picture, chart or advert is no longer reported as missed text** (labels of a chart, text set into a photo, an advert's own wording). + ### 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. - **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. diff --git a/Manual.md b/Manual.md index 0a46e35..0cc181c 100644 --- a/Manual.md +++ b/Manual.md @@ -293,6 +293,8 @@ unreviewed boundaries may include a neighbouring article or an advert. **Whole magazine:** **Auto-fix all pages** (contents view, formerly *Quality check / run*) applies the same fixes to every page and then lists the pages that still need a look (purple badges). **Errors:** a failed fix shows the reason and changes nothing. **Offline / privacy:** the box fixes and the audit are local; the OCR re-read and spelling steps use only the engines you configured. +**Colours in the article list:** a **blue** row with a *not reviewed* pill is an article nobody has approved or rejected yet; a green edge means approved. + ### Less manual work: what happens automatically At import, and whenever you press **Auto-fix all pages**: boxes are fitted, inset pull quotes found, wrapped paragraphs cut, continuation pages **linked into the article running from the page before**, missed text added, suspicious words re-read and (with a language model) provable misspellings fixed. The report then lists only the pages that need a *decision about their boxes*, with **Start with page N**; pages that only have words to check are collapsed and optional. Articles that gained pages go back to *detected*, so re-approve them (the batch tools in *Select* do many at once). A backup of the layout is kept. diff --git a/core/magazine.py b/core/magazine.py index c42b8ce..6fde51e 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -2873,7 +2873,9 @@ _AUDIT_CLIP_INK = 0.10 # ink density in the cell column just outside a _AUDIT_EDGE_FRACTION = 0.03 # scan borders / spine shadows live here and are never article text -_AUDIT_CLIP_STRIP_PX = 14 # how far outside a box edge to look for ink that belongs to it (~1-2 letters) +_AUDIT_CLIP_MAX_INK = 0.7 # above this the strip is a picture or a filled panel, not text +_AUDIT_GRAPHIC_MARGIN = 10.0 # points: unboxed ink this close to a picture/chart/ad belongs to it +_AUDIT_CLIP_STRIP_PX = 14 # how far outside a box edge to look for ink that belongs to it (~1-2 letters) def _clipped_edges(pixels, page_meta: dict[str, Any], text_blocks: list[dict[str, Any]], @@ -2900,14 +2902,82 @@ def _clipped_edges(pixels, page_meta: dict[str, Any], text_blocks: list[dict[str if r1 <= r0 or c1 <= c0: continue free = ~covered[r0:r1, c0:c1] - if free.sum() >= 40 and (pixels[r0:r1, c0:c1] & free).sum() / free.sum() >= _AUDIT_CLIP_INK: + if free.sum() < 40: + continue + ink_share = (pixels[r0:r1, c0:c1] & free).sum() / free.sum() + # Text is ink between the lines; a strip that is (nearly) solid ink is a photo or a + # filled panel behind the headline, not letters cut off by the box. + if _AUDIT_CLIP_INK <= ink_share <= _AUDIT_CLIP_MAX_INK: found.append({"id": block["id"], "side": side}) return found +_OVERLAP_RANK = {BLOCK_TITLE: 0, BLOCK_HEADING: 0, BLOCK_DECK: 1, BLOCK_BYLINE: 1, BLOCK_BODY: 2, + BLOCK_QUOTE: 2, BLOCK_PULLQUOTE: 3, BLOCK_CAPTION: 3, BLOCK_SIDEBAR: 3} +_OVERLAP_MIN_KEEP = 0.4 # never trim a box down to less than this share of its own area + + +def resolve_box_overlaps(layout: dict[str, Any], pages: Iterable[int] | None = None) -> tuple[dict[str, Any], list[str]]: + """Trim boxes of lower rank where they run over a higher-ranked one. + + A caption or sidebar drawn over a headline, or a headline box over body text, covers the same + printed area twice. The box of lower rank (headline > deck/byline > body > caption/sidebar/ + pull quote) is cut back to the largest part that no longer overlaps the higher one -- a + rectangle above, below, left or right of it -- unless that would leave under 40% of it, in + which case both stay for a human (the audit still reports the pair). Boxes of equal rank, hand + placed boxes and anything not text are left alone; only geometry changes. Returns the layout + and the ids of the trimmed boxes. + """ + wanted = set(pages) if pages is not None else None + layout = dict(layout) + blocks = [dict(b) for b in layout.get("blocks", [])] + trimmed: list[str] = [] + by_page: dict[int, list[dict[str, Any]]] = {} + for b in blocks: + if b["type"] in _OVERLAP_RANK and (wanted is None or b["page"] in wanted): + by_page.setdefault(b["page"], []).append(b) + for page_blocks in by_page.values(): + for i, a in enumerate(page_blocks): + for b in page_blocks[i + 1:]: + ra, rb = _OVERLAP_RANK[a["type"]], _OVERLAP_RANK[b["type"]] + if ra == rb: + continue + low, high = (a, b) if ra > rb else (b, a) + if low.get("box_manual") or low.get("source") == "manual": + continue + if (low["type"] == BLOCK_PULLQUOTE and low.get("source") == "inset_pullquote" + and _text_found_in(low.get("text") or "", high.get("text") or "") >= 0.8 + and _bbox_intersection_ratio(low["bbox"], high["bbox"]) >= 0.5): + # the detector found a quote the layout engine had already read as text + low.update(type=BLOCK_FURNITURE, region_label="duplicate", confidence=0.9) + trimmed.append(low["id"]) + continue + if max(_bbox_intersection_ratio(low["bbox"], high["bbox"]), + _bbox_intersection_ratio(high["bbox"], low["bbox"])) < _AUDIT_OVERLAP_RATIO: + continue + x, y, w, h = low["bbox"] + hx, hy, hw, hh = high["bbox"] + ix0, iy0 = max(x, hx), max(y, hy) + ix1, iy1 = min(x + w, hx + hw), min(y + h, hy + hh) + if ix1 <= ix0 or iy1 <= iy0: + continue + options = [[x, y, w, iy0 - y], [x, iy1, w, y + h - iy1], + [x, y, ix0 - x, h], [ix1, y, x + w - ix1, h]] + best = max(options, key=lambda o: max(0.0, o[2]) * max(0.0, o[3])) + if best[2] < 6 or best[3] < 6 or best[2] * best[3] < _OVERLAP_MIN_KEEP * w * h: + continue + low["bbox"] = [round(v, 1) for v in best] + low["box_snapped"] = True + if low["id"] not in trimmed: + trimmed.append(low["id"]) + layout["blocks"] = blocks + return layout, trimmed + + _MIN_TEXT_BOX_HEIGHT = 3.0 # points: a text box thinner than this holds no line of text _SNAP_TEXT_STEP_PX = 2 # grow/shrink granularity _SNAP_TEXT_MAX_GROW_PX = 16 # never grow a side sideways further than about a letter or two +_SNAP_TEXT_MAX_GROW_TITLE_PX = 70 # headlines, decks: large type, the last word often sticks out _SNAP_TEXT_MAX_GROW_DOWN_PX = 90 # a paragraph cut short at the top/bottom can be a few lines _SNAP_TEXT_LINE_GAP_PX = 14 # blank pixels between two text lines that still count as one block _SNAP_TEXT_PAD_PX = 2 # air left between the text and its snapped edge @@ -2987,7 +3057,9 @@ def snap_text_boxes(doc_dir: Path, layout: dict[str, Any], step = _SNAP_TEXT_STEP_PX gap = _SNAP_TEXT_LINE_GAP_PX - budget = {"left": _SNAP_TEXT_MAX_GROW_PX, "right": _SNAP_TEXT_MAX_GROW_PX, + side = (_SNAP_TEXT_MAX_GROW_TITLE_PX if block["type"] in (BLOCK_TITLE, BLOCK_HEADING, BLOCK_DECK) + else _SNAP_TEXT_MAX_GROW_PX) # display type runs past a box by more + budget = {"left": side, "right": side, "top": _SNAP_TEXT_MAX_GROW_DOWN_PX, "bottom": _SNAP_TEXT_MAX_GROW_DOWN_PX} while True: # each side has its own allowance; stop when none can grow grew = False @@ -3437,6 +3509,8 @@ def audit_page_coverage(doc_dir: Path, layout: dict[str, Any], page_no: int) -> pixels = page_ink_mask(png) if pixels is not None: report["clipped"] = _clipped_edges(pixels, page_meta, text_blocks, boxes) + graphic_boxes = ([i["bbox"] for i in layout.get("images", []) if i["page"] == page_no] + + [b["bbox"] for b in blocks if b["type"] in VISUAL_TYPES or b["type"] == BLOCK_AD]) er, ec = int(ink.shape[0] * _AUDIT_EDGE_FRACTION), int(ink.shape[1] * _AUDIT_EDGE_FRACTION) edge = np.zeros_like(ink) edge[:er, :] = edge[-er:, :] = True @@ -3453,11 +3527,15 @@ def audit_page_coverage(doc_dir: Path, layout: dict[str, Any], page_no: int) -> rows, cols = np.nonzero((labels == index) & stray) if len(rows) < _AUDIT_MIN_CELLS: continue + blob = [round(float(cols.min() * cell_w), 1), round(float(rows.min() * cell_h), 1), + round(float((cols.max() + 1 - cols.min()) * cell_w), 1), + round(float((rows.max() + 1 - rows.min()) * cell_h), 1)] + # Labels of a chart, text set into a photo or an advert's own wording are part of that + # graphic, not narratable text a box forgot: a blob in or beside one is not reported. + if any(bbox_overlaps(blob, g, tol=_AUDIT_GRAPHIC_MARGIN) for g in graphic_boxes): + continue kept_cells += len(rows) - report["uncovered"].append([ - round(float(cols.min() * cell_w), 1), round(float(rows.min() * cell_h), 1), - round(float((cols.max() + 1 - cols.min()) * cell_w), 1), - round(float((rows.max() + 1 - rows.min()) * cell_h), 1)]) + report["uncovered"].append(blob) report["uncovered_ratio"] = round(kept_cells / total, 4) return report diff --git a/routes/magazine.py b/routes/magazine.py index 25c22d4..8d26465 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -1108,6 +1108,7 @@ def _fix_page_geometry(d: Path, layout: dict[str, Any], articles: list[dict[str, layout, quotes = mag.detect_inset_pullquotes(d, layout, [page], url, model, fb_url, fb_model) layout, wrap = mag.split_around_pullquotes(d, layout, [page]) cleanup["wrap_split"] = wrap + layout, trimmed = mag.resolve_box_overlaps(layout, [page]) layout, snapped = mag.snap_text_boxes(d, layout, pages=[page]) # The column-aware reading order is otherwise only applied by the manual "Fix reading order" # button; after import or a re-OCR the order would be whatever the vision/LLM step returned. @@ -1125,7 +1126,7 @@ def _fix_page_geometry(d: Path, layout: dict[str, Any], articles: list[dict[str, owner["images"] = list(dict.fromkeys([*(owner.get("images") or []), *pictures])) changed = True counts = {"noise_retyped": noise, "pullquotes_found": len(quotes), "paragraphs_cut": len(wrap), - "pictures_added": len(pictures), + "pictures_added": len(pictures), "overlaps_fixed": len(trimmed), "boxes_fitted": len(snapped), "bylines_split": len(cleanup.get("byline_split", []))} return layout, counts, changed @@ -1176,7 +1177,8 @@ def _quality_run_sync(doc_id: str, job_id: str, settings: dict[str, Any]) -> Non step("fixing boxes") layout, geometry, moved_articles = _fix_page_geometry(d, layout, articles, page, settings) articles_changed = articles_changed or moved_articles - fixed_boxes += geometry["boxes_fitted"] + geometry["paragraphs_cut"] + geometry["pullquotes_found"] + fixed_boxes += (geometry["boxes_fitted"] + geometry["paragraphs_cut"] + geometry["pullquotes_found"] + + geometry["pictures_added"] + geometry["overlaps_fixed"]) step("finding missed text") layout, added = mag.recover_missing_text(d, layout, page) if verify: diff --git a/static/js/magazine.js b/static/js/magazine.js index 592e97d..33b9ae0 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -759,9 +759,12 @@ function renderContents() { const conf = confidenceLabel(a.confidence); const done = a.status === 'approved'; const progress = (state.doc.progress || {})[a.id]; - const statusBadge = '' + - (done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + ''; - html += '