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:
+65
-26
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user