fix(magazine): clipped-edge audit requires ink on the box's own text lines
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
9fa661fb6a
commit
1b9a4bd3bc
@ -40,6 +40,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
- **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.
|
||||
- **Rules and dotted leaders are not missed text.** The dots between a table-of-contents entry and its page number (and thin printed rules) are ink but no letters; the audit now ignores ink that is only a few pixel rows tall.
|
||||
- **Stacked boxes of a column now meet at the gap between two lines** (*seam mending*). Detection often cuts a column into slices that overlap or leave a line in the seam, so that line was in neither box and both reported a clipped edge. The shared boundary moves to the nearest blank gap (below the line for a paragraph that is still running, above it after a finished one, in the middle of an overlap); a line that had fallen into the seam is read on its own and attached to the box that now owns it, and the box takes the column's width. Page 32 of the measured issue: clipped edges 2 → 0 and the lost line "Entwicklungen auseinander. „KI ist bei uns in allen" is back in the text. 57 seams were mended across the issue.
|
||||
- **"Edge cuts text" needs the box's own lines.** Ink beside a left/right edge only counts as cut-off letters when it sits on the rows where the box itself has text and is intermittent (letters and gaps); a solid vertical line (a screenshot frame, a chart border, a photo edge) or ink between the box's lines does not, and a thin horizontal rule above or below is not a cut line. On the measured issue clipped edges fell from 82 to 48 and pages needing a decision from 53 to 42.
|
||||
- **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).
|
||||
|
||||
### Changed
|
||||
|
||||
5
TODO.md
5
TODO.md
@ -84,8 +84,9 @@ marked on the page for a human. Order of work:
|
||||
The current issue (c't KI-Wissen 2026) is the test bed. The point is to measure whether the newest
|
||||
rules beat what was built up by hand, and to feed every difference back into the rules. Do these in order.
|
||||
|
||||
- [ ] **A — Finish verifying this magazine.** Work the remaining flags (about 53 pages need a decision:
|
||||
~39 clipped edges, ~21 unboxed areas, ~10 overlaps), review and approve the 33 not-reviewed articles,
|
||||
- [ ] **A — Finish verifying this magazine.** Work the remaining flags (42 pages need a decision as of
|
||||
2026-09-28: 26 with a clipped edge — most sampled ones are cosmetic, the box encloses its text —, 21 with
|
||||
unboxed ink, 10 with overlaps), review and approve the 33 not-reviewed articles,
|
||||
and run the spelling/acronym steps once the language model answers again. Done when the report lists no
|
||||
page needing a decision and every article is approved or rejected.
|
||||
- [ ] **B — Make a backup copy.** Copy the whole magazine folder (`reader_library/<id>/`: layout, articles,
|
||||
|
||||
@ -2922,11 +2922,44 @@ def _clipped_edges(pixels, page_meta: dict[str, Any], text_blocks: list[dict[str
|
||||
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})
|
||||
if not (_AUDIT_CLIP_INK <= ink_share <= _AUDIT_CLIP_MAX_INK):
|
||||
continue
|
||||
if not _strip_continues_the_text(pixels, side, (r0, r1, c0, c1), free, (top, bottom, left, right)):
|
||||
continue
|
||||
found.append({"id": block["id"], "side": side})
|
||||
return found
|
||||
|
||||
|
||||
def _strip_continues_the_text(pixels, side: str, strip: tuple[int, int, int, int], free,
|
||||
box: tuple[int, int, int, int]) -> bool:
|
||||
"""Whether the ink in a strip beside a box looks like the box's own text running on.
|
||||
|
||||
Letters cut off by a box edge sit on the same text lines as the box's own text, so beside a
|
||||
left/right edge the strip's inked pixel rows fall on rows where the box itself has ink; and the
|
||||
ink is intermittent (letters and gaps), not a line. A screenshot frame, a chart border or the
|
||||
edge of a photo is a solid vertical line or does not follow the box's lines. Above/below, a
|
||||
thin horizontal rule is not a cut line. A box with no ink of its own cannot be judged and is
|
||||
kept as reported.
|
||||
"""
|
||||
r0, r1, c0, c1 = strip
|
||||
top, bottom, left, right = box
|
||||
ink = pixels[r0:r1, c0:c1] & free
|
||||
if side in ("left", "right"):
|
||||
inner = pixels[max(0, top):bottom, max(0, left):right]
|
||||
strip_rows = ink.any(axis=1)
|
||||
if inner.size == 0 or not inner.any() or not strip_rows.any():
|
||||
return True
|
||||
inner_rows = inner.any(axis=1)
|
||||
rows = min(len(strip_rows), len(inner_rows))
|
||||
strip_rows, inner_rows = strip_rows[:rows], inner_rows[:rows]
|
||||
on_lines = float((strip_rows & inner_rows).sum()) / float(strip_rows.sum())
|
||||
solid = float(strip_rows.sum()) / max(1, rows)
|
||||
return on_lines >= 0.75 and solid <= 0.9
|
||||
rows_inked = int(ink.any(axis=1).sum())
|
||||
coverage = float(ink.any(axis=0).sum()) / max(1, ink.shape[1])
|
||||
return not (rows_inked <= 3 and coverage >= 0.7)
|
||||
|
||||
|
||||
_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
|
||||
|
||||
@ -2567,7 +2567,7 @@ class ClippedEdgeTests(unittest.TestCase):
|
||||
pixels = np.zeros((200, 200), dtype=bool)
|
||||
pixels[20:100:6, 20:100] = True # ruled "text" lines, 6px pitch
|
||||
pixels[100:112:6, 20:100] = True # an extra line below the box
|
||||
pixels[20:100, 100:110] = True # ink running past the right edge
|
||||
pixels[20:100:6, 100:110] = True # the same lines running on past the right edge
|
||||
meta = {"width": 200, "height": 200}
|
||||
block = {"id": "b", "bbox": [20, 20, 80, 80]}
|
||||
sides = {c["side"] for c in _clipped_edges(pixels, meta, [block], [block["bbox"]])}
|
||||
@ -3389,3 +3389,40 @@ class MendColumnSeamsTests(unittest.TestCase):
|
||||
with mock.patch('core.magazine.extract_region_text', return_value="|"):
|
||||
out, n = read_seam_strips(self.dir, layout, [{"id": "a", "where": "end", "strip": [98, 137, 205, 22]}])
|
||||
self.assertEqual((n, out["blocks"][0]["text"]), (0, "Der Absatz beginnt hier und"))
|
||||
|
||||
|
||||
class ClippedEdgeNeedsTheBoxsOwnLinesTests(unittest.TestCase):
|
||||
"""A box with two text lines (x 100..250); what stands beside its right edge decides."""
|
||||
|
||||
def audit(self, beside):
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw
|
||||
from core.magazine import audit_page_coverage
|
||||
d = Path(tempfile.mkdtemp(prefix="ttvc-clip2-"))
|
||||
self.addCleanup(shutil.rmtree, d, True)
|
||||
(d / "pages").mkdir()
|
||||
im = Image.new("RGB", (600, 800), (248, 242, 244))
|
||||
draw = ImageDraw.Draw(im)
|
||||
for y in range(100, 148, 16): # the box's own two text lines
|
||||
draw.rectangle([100, y, 250, y + 6], fill=(93, 91, 92))
|
||||
beside(draw)
|
||||
im.save(d / "pages" / "0001.png")
|
||||
layout = {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": [
|
||||
{"id": "t", "page": 1, "type": "body", "text": "Zwei Zeilen Text", "bbox": [98, 98, 155, 56], "reading_order": 0}]}
|
||||
return [c["side"] for c in audit_page_coverage(d, layout, 1)["clipped"]]
|
||||
|
||||
def test_letters_running_on_from_the_same_lines_are_clipping(self):
|
||||
def words(draw):
|
||||
for y in range(100, 148, 16):
|
||||
draw.rectangle([256, y, 275, y + 6], fill=(93, 91, 92)) # the lines continue past the edge
|
||||
self.assertEqual(self.audit(words), ["right"])
|
||||
|
||||
def test_a_frame_line_or_picture_edge_beside_the_box_is_not_clipping(self):
|
||||
def frame(draw):
|
||||
draw.rectangle([256, 90, 258, 170], fill=(93, 91, 92)) # a solid vertical frame line
|
||||
self.assertEqual(self.audit(frame), [])
|
||||
|
||||
def off_lines(draw):
|
||||
draw.rectangle([256, 116, 270, 118], fill=(93, 91, 92)) # ink between the box's own lines
|
||||
draw.rectangle([256, 132, 270, 134], fill=(93, 91, 92))
|
||||
self.assertEqual(self.audit(off_lines), [])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user