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:
+66
-27
@@ -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:
|
||||
return False
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user