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 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. position, Line.quad gives the outline of a range of characters.
""" """
import re
import threading import threading
from dataclasses import dataclass from dataclasses import dataclass
@@ -32,12 +33,9 @@ class Line:
self.edges = np.linspace(0.0, 1.0, len(self.text) + 1) self.edges = np.linspace(0.0, 1.0, len(self.text) + 1)
@property @property
def cy(self): def width(self):
return float(self.box[:, 1].mean()) """Length along the box's top edge."""
return float(np.hypot(*(self.box[1] - self.box[0])))
@property
def left(self):
return float(self.box[:, 0].min())
@property @property
def height(self): def height(self):
@@ -133,18 +131,49 @@ class Ocr:
return out 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): def rows(lines):
"""Group lines into visual rows (top to bottom), each sorted left to right. A line """Group lines into visual rows (top to bottom), each sorted left to right, in the
joins a row when its centre is within half a line height of the row's.""" 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 = [] out = []
for ln in sorted(lines, key=lambda ln: ln.cy): for ln in sorted(lines, key=across):
for row in out[-3:]: 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) row.append(ln)
break break
else: else:
out.append([ln]) 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): def layout(lines):
@@ -153,8 +182,7 @@ def layout(lines):
row are separated by a tab. Returns (text, spans), spans[i] = (start, end) of row are separated by a tab. Returns (text, spans), spans[i] = (start, end) of
lines[i] in text.""" lines[i] in text."""
index = {id(ln): i for i, ln in enumerate(lines)} index = {id(ln): i for i, ln in enumerate(lines)}
rs = rows(lines) rs, cys = rows(lines)
cys = [float(np.mean([ln.cy for ln in row])) for row in rs]
steps = np.diff(cys) steps = np.diff(cys)
pitch = float(np.median(steps)) if len(steps) else 0.0 pitch = float(np.median(steps)) if len(steps) else 0.0
spans, parts, pos = [None] * len(lines), [], 0 spans, parts, pos = [None] * len(lines), [], 0
@@ -179,30 +207,38 @@ def _extent(ln, o, d):
return float(t.min()), float(t.max()) 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): def _continues(rs, k):
"""Whether row k + 1 continues the paragraph of row k (both single lines): similar """Whether the first line of row k + 1 continues the paragraph of the last line of row
text height, the next line isn't indented, and line k is full, i.e. the next line's k: similar text height, the next line isn't indented (except to line up with the
first word would not have fitted in the space left at its end (which works for text after a list marker, a hanging indent), and line k is full, i.e. the next
justified and ragged-right text alike).""" line's first word would not have fitted in the space left at its end (which works
a, b = rs[k][0], rs[k + 1][0] for justified and ragged-right text alike)."""
a, b = rs[k][-1], rs[k + 1][0]
h = min(a.height, b.height) 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): 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 return False
o = a.box[0] 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) la, ra = _extent(a, o, d)
lb, _ = _extent(b, o, d) lb, _ = _extent(b, o, d)
if lb > la + 0.8 * h: 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 return False
left = min(la, lb) left = min(la, lb)
right = ra right = ra
for j in range(max(k - 2, 0), min(k + 4, len(rs))): # the paragraph's right margin 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: c = rs[j][-1]
lj, rj = _extent(rs[j][0], o, d) if abs(c.height - h) < 0.3 * h:
lj, rj = _extent(c, o, d)
if abs(lj - left) < 3 * h: if abs(lj - left) < 3 * h:
right = max(right, rj) right = max(right, rj)
_, e = b.word(0) _, 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 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(): """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 (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 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)} index = {id(ln): i for i, ln in enumerate(lines)}
rs = rows(lines) rs, _ = rows(lines)
out = [] out = []
for k in range(len(rs) - 1): 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 continue
a, b = rs[k][0], rs[k + 1][0]
e = spans[index[id(a)]][1] e = spans[index[id(a)]][1]
if text[e:spans[index[id(b)]][0]] != "\n" or not _continues(rs, k): if text[e:spans[index[id(b)]][0]] != "\n" or not _continues(rs, k):
continue continue