Order OCR lines in the page's own frame, so slanted scans read right

Lines were grouped into rows and sorted by their centres' image y and
their left x. On a page scanned at an angle (5.4 degrees on the latest
scan) a short line's centre sits lower than that of a long line beside
it, so lines got swapped or merged into one row and paragraphs read as
nonsense. Rows now use positions along and across the text direction,
the length-weighted median direction of the lines (ocr.text_direction,
ocr.page_coords); a rotated page gives the same text.

Paragraph joining also follows a list item's hanging indent (continuation
lines lined up after "(a)", "1.", a bullet ...) and lets a heading whose
number was read as a separate item run on to its next line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erik
2026-10-09 12:44:40 +00:00
co-authored by Claude Opus 5.5
parent 6631073be5
commit a1942faa41
+65 -26
View File
@@ -14,6 +14,7 @@ Each line knows where its characters are (from the recogniser's per-character bo
so text can be selected on the image: Line.locate maps a point to a character
position, Line.quad gives the outline of a range of characters.
"""
import re
import threading
from dataclasses import dataclass
@@ -32,12 +33,9 @@ class Line:
self.edges = np.linspace(0.0, 1.0, len(self.text) + 1)
@property
def cy(self):
return float(self.box[:, 1].mean())
@property
def left(self):
return float(self.box[:, 0].min())
def width(self):
"""Length along the box's top edge."""
return float(np.hypot(*(self.box[1] - self.box[0])))
@property
def height(self):
@@ -133,18 +131,49 @@ class Ocr:
return out
def text_direction(lines):
"""Unit vector along the text lines: the length-weighted median direction of the
lines' top edges (short lines, whose angle is unreliable, only if there is nothing
else). A scan held at an angle has every line slanted the same way."""
if not lines:
return np.array([1.0, 0.0])
tops = np.array([ln.box[1] - ln.box[0] for ln in lines])
length = np.hypot(tops[:, 0], tops[:, 1])
long = length > 3 * np.array([ln.height for ln in lines])
if long.any():
tops, length = tops[long], length[long]
angle = np.arctan2(tops[:, 1], tops[:, 0])
order = np.argsort(angle)
cum = np.cumsum(length[order])
a = angle[order][np.searchsorted(cum, cum[-1] / 2)]
return np.array([np.cos(a), np.sin(a)])
def page_coords(lines):
"""(along, across) position of each line in the page's own frame, unaffected by the
scan's slant: where the line starts along the text direction, and the offset of its
centre perpendicular to it (the same anywhere along a straight line)."""
d = text_direction(lines)
n = np.array([-d[1], d[0]])
return [(float((ln.box @ d).min()), float(ln.box.mean(0) @ n)) for ln in lines]
def rows(lines):
"""Group lines into visual rows (top to bottom), each sorted left to right. A line
joins a row when its centre is within half a line height of the row's."""
"""Group lines into visual rows (top to bottom), each sorted left to right, in the
page's frame (page_coords). A line joins a row when its centre is within half a line
height of the row's. Returns (rows, the rows' centre offsets)."""
pos = {id(ln): p for ln, p in zip(lines, page_coords(lines))}
across = lambda ln: pos[id(ln)][1]
out = []
for ln in sorted(lines, key=lambda ln: ln.cy):
for ln in sorted(lines, key=across):
for row in out[-3:]:
if abs(ln.cy - np.mean([r.cy for r in row])) < 0.5 * min(ln.height, row[0].height):
if abs(across(ln) - np.mean([across(r) for r in row])) < 0.5 * min(ln.height, row[0].height):
row.append(ln)
break
else:
out.append([ln])
return [sorted(row, key=lambda ln: ln.left) for row in out]
return ([sorted(row, key=lambda ln: pos[id(ln)][0]) for row in out],
[float(np.mean([across(ln) for ln in row])) for row in out])
def layout(lines):
@@ -153,8 +182,7 @@ def layout(lines):
row are separated by a tab. Returns (text, spans), spans[i] = (start, end) of
lines[i] in text."""
index = {id(ln): i for i, ln in enumerate(lines)}
rs = rows(lines)
cys = [float(np.mean([ln.cy for ln in row])) for row in rs]
rs, cys = rows(lines)
steps = np.diff(cys)
pitch = float(np.median(steps)) if len(steps) else 0.0
spans, parts, pos = [None] * len(lines), [], 0
@@ -179,30 +207,38 @@ def _extent(ln, o, d):
return float(t.min()), float(t.max())
# a list item's marker: "(a)", "a)", "1.", "iv)", a bullet or dash, then a space
MARKER = re.compile(r"(\(?[0-9A-Za-z]{1,3}[.)]|[•·▪◦*\-–])\s+")
def _continues(rs, k):
"""Whether row k + 1 continues the paragraph of row k (both single lines): similar
text height, the next line isn't indented, and line k is full, i.e. the next line's
first word would not have fitted in the space left at its end (which works for
justified and ragged-right text alike)."""
a, b = rs[k][0], rs[k + 1][0]
"""Whether the first line of row k + 1 continues the paragraph of the last line of row
k: similar text height, the next line isn't indented (except to line up with the
text after a list marker, a hanging indent), and line k is full, i.e. the next
line's first word would not have fitted in the space left at its end (which works
for justified and ragged-right text alike)."""
a, b = rs[k][-1], rs[k + 1][0]
h = min(a.height, b.height)
if not a.text.strip() or not b.text.strip() or abs(a.height - b.height) > 0.3 * max(a.height, b.height):
return False
o = a.box[0]
d = (a.box[1] - a.box[0]) / np.hypot(*(a.box[1] - a.box[0]))
d = (a.box[1] - a.box[0]) / a.width
la, ra = _extent(a, o, d)
lb, _ = _extent(b, o, d)
if lb > la + 0.8 * h:
m = MARKER.match(a.text)
if not m or abs(lb - float((a.quad(m.end(), m.end())[0] - o) @ d)) > 0.8 * h:
return False
left = min(la, lb)
right = ra
for j in range(max(k - 2, 0), min(k + 4, len(rs))): # the paragraph's right margin
if len(rs[j]) == 1 and abs(rs[j][0].height - h) < 0.3 * h:
lj, rj = _extent(rs[j][0], o, d)
c = rs[j][-1]
if abs(c.height - h) < 0.3 * h:
lj, rj = _extent(c, o, d)
if abs(lj - left) < 3 * h:
right = max(right, rj)
_, e = b.word(0)
word = (b.edges[e] - b.edges[0]) * np.hypot(*(b.box[1] - b.box[0]))
word = (b.edges[e] - b.edges[0]) * b.width
return right - ra < word + 0.5 * h
@@ -210,14 +246,17 @@ def paragraph_joins(lines, text, spans):
"""Where layout()'s text breaks a paragraph across lines, as edits for reflow():
(start, end, replacement) replacing a line break with a space, or removing it along
with the hyphen of a word split over two lines (the hyphen stays, without the
break, when the next line starts with a capital, as in "Nord-Amerika")."""
break, when the next line starts with a capital, as in "Nord-Amerika"). Only a row
holding a single line continues a paragraph, and only a single line or a wide last
item (as after a separately read section number) runs on into it; rows of several
short items are a table."""
index = {id(ln): i for i, ln in enumerate(lines)}
rs = rows(lines)
rs, _ = rows(lines)
out = []
for k in range(len(rs) - 1):
if len(rs[k]) != 1 or len(rs[k + 1]) != 1: # several items: a table, not prose
a, b = rs[k][-1], rs[k + 1][0]
if len(rs[k + 1]) != 1 or (len(rs[k]) != 1 and a.width < 10 * a.height):
continue
a, b = rs[k][0], rs[k + 1][0]
e = spans[index[id(a)]][1]
if text[e:spans[index[id(b)]][0]] != "\n" or not _continues(rs, k):
continue