fix(magazine): boxes grow over cut-off lines without entering neighbours; re-read grown boxes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
e5bf4e70f9
commit
d1c15b0e2b
@ -12,7 +12,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
- **Pronunciation & spelling panel (per article).** Acronyms are spoken from the article's language (decided once per article, with per-block override for quotations) using an extended built-in list, letter-by-letter spelling for `USB`/`HDMI`-style acronyms, and an editable per-article glossary. Unfamiliar acronyms are listed with their sentence, and the language model can decide expansion / letters / word from context.
|
||||
- **German and English spellchecker with fix-or-mark.** Unknown words are underlined in the review list and listed with suggestions per page and per article; click a suggestion (undoable), or *Keep* to add the word to the issue's dictionary. *Fix with language model* corrects only what passes strict guards (a dictionary suggestion, or a known word within two edits) and keeps names/terms; the whole-magazine quality check now runs the same pass and reports pages with words still marked.
|
||||
|
||||
- **Clean up boxes now also fits text boxes to their text.** A side that cuts through letters grows (at most a letter or two, never into a neighbouring box) and empty margins are trimmed. Boxes you drew or resized by hand are never touched. It also runs automatically after **OCR again (this page)**; on page 61 of the measured issue it widened the left-column paragraphs ~10 pt to cover their text, cutting clipped edges from 7 to 1.
|
||||
- **Clean up boxes now also fits text boxes to their text.** A side that cuts through letters grows (at most a letter or two, never into a neighbouring box) and empty margins are trimmed. Boxes you drew or resized by hand are never touched. It also runs automatically after **OCR again (this page)**; on page 61 of the measured issue it widened the left-column paragraphs ~10 pt to cover their text, cutting clipped edges from 7 to 0. A box never grows into a neighbouring box; when it gains whole lines, its text is re-read from the enlarged crop and replaced only by a longer reading that still starts with the old text.
|
||||
|
||||
### Fixed
|
||||
- **The quality check no longer flags correct text as "suspect OCR".** The word check guessed the language per fragment (`iPhones statt Token` read as English) and flagged valid German; it now uses the page's language, the same lenient German rules as the spellchecker, and treats words that recur on the page (names, brands) as vocabulary. Page 61 of the measured issue went from 11 suspect regions to 3 (one a genuinely truncated word), and the issue from 758 to 610 before the language fix's full effect.
|
||||
|
||||
@ -2906,7 +2906,9 @@ def _clipped_edges(pixels, page_meta: dict[str, Any], text_blocks: list[dict[str
|
||||
|
||||
|
||||
_SNAP_TEXT_STEP_PX = 2 # grow/shrink granularity
|
||||
_SNAP_TEXT_MAX_GROW_PX = 16 # never grow a side further than about a letter or two
|
||||
_SNAP_TEXT_MAX_GROW_PX = 16 # never grow a side sideways further than about a letter or two
|
||||
_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
|
||||
_SNAP_TEXT_MIN_PX = 8 # a snapped box keeps at least this size
|
||||
_SNAP_TEXT_MIN_MOVE_PX = 3 # smaller corrections are not worth changing a box for
|
||||
@ -2977,18 +2979,24 @@ def snap_text_boxes(doc_dir: Path, layout: dict[str, Any],
|
||||
free = ~others[r0:r1, c0:c1]
|
||||
return bool(free.sum() >= 12 and (pixels[r0:r1, c0:c1] & free).sum() / free.sum() >= _AUDIT_CLIP_INK)
|
||||
|
||||
def blocked(r0: int, r1: int, c0: int, c1: int) -> bool:
|
||||
"""The strip a side would grow into already belongs to another box."""
|
||||
r0, c0, r1, c1 = max(0, r0), max(0, c0), min(height, r1), min(width, c1)
|
||||
return bool(r1 > r0 and c1 > c0 and others[r0:r1, c0:c1].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,
|
||||
"top": _SNAP_TEXT_MAX_GROW_PX, "bottom": _SNAP_TEXT_MAX_GROW_PX}
|
||||
"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
|
||||
if budget["left"] > 0 and left - step >= 0 and strip_has_text(top, bottom, left - step, left):
|
||||
if budget["left"] > 0 and left - step >= 0 and not blocked(top, bottom, left - step, left) and strip_has_text(top, bottom, left - step, left):
|
||||
left -= step; budget["left"] -= step; grew = True
|
||||
if budget["right"] > 0 and right + step <= width and strip_has_text(top, bottom, right, right + step):
|
||||
if budget["right"] > 0 and right + step <= width and not blocked(top, bottom, right, right + step) and strip_has_text(top, bottom, right, right + step):
|
||||
right += step; budget["right"] -= step; grew = True
|
||||
if budget["top"] > 0 and top - step >= 0 and strip_has_text(top - step, top, left, right):
|
||||
if budget["top"] > 0 and top - step >= 0 and not blocked(top - step, top, left, right) and strip_has_text(top - gap, top, left, right):
|
||||
top -= step; budget["top"] -= step; grew = True
|
||||
if budget["bottom"] > 0 and bottom + step <= height and strip_has_text(bottom, bottom + step, left, right):
|
||||
if budget["bottom"] > 0 and bottom + step <= height and not blocked(bottom, bottom + step, left, right) and strip_has_text(bottom, bottom + gap, left, right):
|
||||
bottom += step; budget["bottom"] -= step; grew = True
|
||||
if not grew:
|
||||
break
|
||||
@ -3010,6 +3018,7 @@ def snap_text_boxes(doc_dir: Path, layout: dict[str, Any],
|
||||
if (left, top, right, bottom) != original:
|
||||
left, top = max(0, left - _SNAP_TEXT_PAD_PX), max(0, top - _SNAP_TEXT_PAD_PX)
|
||||
right, bottom = min(width, right + _SNAP_TEXT_PAD_PX), min(height, bottom + _SNAP_TEXT_PAD_PX)
|
||||
grown_down = bottom - original[3]
|
||||
moves = [abs(a - b) for a, b in zip((left, top, right, bottom), original)]
|
||||
if max(moves) < _SNAP_TEXT_MIN_MOVE_PX:
|
||||
continue
|
||||
@ -3017,11 +3026,48 @@ def snap_text_boxes(doc_dir: Path, layout: dict[str, Any],
|
||||
round((right - left) / sx, 1), round((bottom - top) / sy, 1)]
|
||||
block["box_snapped"] = True
|
||||
moved.append({"page": page_no, "id": block["id"],
|
||||
"grew": (left, top) < original[:2] or (right, bottom) > original[2:]})
|
||||
"grew": (left, top) < original[:2] or (right, bottom) > original[2:],
|
||||
# lines were added under/over the box: its text was probably cut short
|
||||
"lines_added": grown_down > 8 or original[1] - top > 8})
|
||||
layout["blocks"] = blocks
|
||||
return layout, moved
|
||||
|
||||
|
||||
def reread_grown_regions(doc_dir: Path, layout: dict[str, Any], moved: Sequence[dict[str, Any]],
|
||||
vision_ocr_url: str | None, vision_ocr_model: str | None,
|
||||
vision_ocr_fallback_url: str | None = None,
|
||||
vision_ocr_fallback_model: str | None = None,
|
||||
timeout: float = 150.0) -> tuple[dict[str, Any], int]:
|
||||
"""Re-read boxes that snap_text_boxes extended by whole lines: their text was cut short too.
|
||||
|
||||
The new reading replaces the old only when it is longer, still starts with the old text (so
|
||||
a re-read that wandered into another paragraph is refused) and reads like prose. Returns the
|
||||
layout and how many regions were extended.
|
||||
"""
|
||||
layout = dict(layout)
|
||||
blocks = [dict(b) for b in layout.get("blocks", [])]
|
||||
by_id = {b["id"]: b for b in blocks}
|
||||
extended = 0
|
||||
for row in moved:
|
||||
block = by_id.get(row["id"])
|
||||
if not row.get("lines_added") or block is None:
|
||||
continue
|
||||
old = " ".join((block.get("text") or "").split())
|
||||
try:
|
||||
new = extract_region_text(doc_dir, layout, block["page"], block["bbox"], vision_ocr_url,
|
||||
vision_ocr_model, vision_ocr_fallback_url, vision_ocr_fallback_model, timeout)
|
||||
except Exception:
|
||||
continue
|
||||
new = " ".join((new or "").split())
|
||||
head = re.sub(r"\W+", "", old.casefold())[:24]
|
||||
if (new and len(new) > len(old) + 8 and head and head in re.sub(r"\W+", "", new.casefold())
|
||||
and ocr_quality_score(new) >= 0.55):
|
||||
block["text"] = new
|
||||
extended += 1
|
||||
layout["blocks"] = blocks
|
||||
return layout, extended
|
||||
|
||||
|
||||
def duplicate_article_groups(articles: Sequence[dict[str, Any]]) -> list[list[str]]:
|
||||
"""Groups of articles that share most of their text regions -- the same words would be
|
||||
narrated once per article. Two articles are grouped when the smaller one shares at least
|
||||
|
||||
@ -858,7 +858,10 @@ def _reocr_page_sync(doc_id: str, job_id: str, page: int,
|
||||
_recognise_semantic_layout(updated, _load_settings(), page, doc_dir=d)
|
||||
mag.refresh_visual_regions(d, updated, page)
|
||||
updated, cleanup = mag.clean_up_regions(updated, pages=[page])
|
||||
updated, _ = mag.snap_text_boxes(d, updated, pages=[page]) # boxes cover their whole text
|
||||
updated, snapped = mag.snap_text_boxes(d, updated, pages=[page]) # boxes cover their whole text
|
||||
if any(m.get("lines_added") for m in snapped):
|
||||
updated, _ = mag.reread_grown_regions(d, updated, snapped, vision_ocr_url, vision_ocr_model,
|
||||
vision_ocr_fallback_url, vision_ocr_fallback_model)
|
||||
articles = mag.load_articles(d)
|
||||
mag.remap_reviewed_page(articles, layout, updated, page)
|
||||
_add_split_paragraphs_to_articles(articles, cleanup)
|
||||
|
||||
@ -2723,6 +2723,11 @@ class SnapTextBoxesTests(unittest.TestCase):
|
||||
got, _, _ = self.snap([("a", [100, 100, 190, 100], {}), ("b", [296, 90, 220, 120], {})])
|
||||
self.assertLessEqual(got["a"][0] + got["a"][2], 300)
|
||||
|
||||
def test_a_box_never_grows_into_the_box_below_it(self):
|
||||
# text continues under 'a' (y 100..200) but 'b' already owns y 150.. of the same column
|
||||
got, _, _ = self.snap([("a", [100, 100, 200, 45], {}), ("b", [100, 150, 200, 60], {})])
|
||||
self.assertLessEqual(got["a"][1] + got["a"][3], 150)
|
||||
|
||||
def test_boxes_placed_by_hand_and_labelled_regions_are_left_alone(self):
|
||||
boxes = [("m", [60, 60, 250, 200], {"source": "manual"}), ("e", [60, 60, 250, 200], {"box_manual": True}),
|
||||
("l", [60, 60, 250, 200], {"type": "furniture", "region_label": "in_picture"})]
|
||||
@ -2767,3 +2772,31 @@ class BoxFlagsSurviveRebuildTests(unittest.TestCase):
|
||||
layout = {"pages": [{"page": 1, "width": 100, "height": 100}], "images": [],
|
||||
"blocks": [{"id": "a", "page": 1, "bbox": [0, 0, 10, 10], "type": "body", "text": "t", "box_snapped": True}]}
|
||||
self.assertEqual([Block(**b).id for b in layout["blocks"]], ["a"])
|
||||
|
||||
|
||||
class RereadGrownRegionsTests(unittest.TestCase):
|
||||
def _layout(self, text):
|
||||
return {"pages": [{"page": 1, "width": 100, "height": 100}], "images": [],
|
||||
"blocks": [{"id": "a", "page": 1, "type": "body", "bbox": [0, 0, 50, 50], "text": text}]}
|
||||
|
||||
def run_reread(self, old, new, moved=({"id": "a", "lines_added": True},)):
|
||||
from core.magazine import reread_grown_regions
|
||||
with mock.patch('core.magazine.extract_region_text', return_value=new):
|
||||
layout, n = reread_grown_regions(pathlib.Path('/tmp/x'), self._layout(old), list(moved), 'u', 'm')
|
||||
return layout["blocks"][0]["text"], n
|
||||
|
||||
def test_a_longer_reading_that_starts_with_the_old_text_replaces_it(self):
|
||||
old = "Allein hilft dieser Index allerdings wenig."
|
||||
new = old + " Erst die sogenannten App Intents verraten dem System, was Apps mit den Inhalten anfangen."
|
||||
self.assertEqual(self.run_reread(old, new), (new, 1))
|
||||
|
||||
def test_a_reading_that_wandered_elsewhere_or_is_not_longer_is_refused(self):
|
||||
old = "Allein hilft dieser Index allerdings wenig."
|
||||
self.assertEqual(self.run_reread(old, "Ganz anderer Absatz aus der anderen Spalte mit viel mehr Text drin.")[1], 0)
|
||||
self.assertEqual(self.run_reread(old, old)[1], 0)
|
||||
|
||||
def test_boxes_that_did_not_gain_lines_are_not_re_read(self):
|
||||
with mock.patch('core.magazine.extract_region_text') as read:
|
||||
from core.magazine import reread_grown_regions
|
||||
reread_grown_regions(pathlib.Path('/tmp/x'), self._layout("t"), [{"id": "a", "lines_added": False}], 'u', 'm')
|
||||
read.assert_not_called()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user