From a1942faa416e8390bad07fe2fcae8c6432ab7c0c Mon Sep 17 00:00:00 2001 From: erik Date: Fri, 9 Oct 2026 12:44:40 +0000 Subject: [PATCH] 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 --- myscan/ocr.py | 93 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 66 insertions(+), 27 deletions(-) diff --git a/myscan/ocr.py b/myscan/ocr.py index 35e45cc..27043ae 100644 --- a/myscan/ocr.py +++ b/myscan/ocr.py @@ -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