Select and copy text directly on the page
The text pane is gone: recognised text is selected on the page image like in a PDF viewer (drag in reading order, double-click a word, triple-click a line, shift-click to extend, Ctrl+A), copied with Ctrl+C or the context menu, and previewed in the status bar; on X11/Wayland it also goes to the primary selection. Middle-drag or Space+drag pans. OCR asks RapidOCR for per-character boxes and keeps each line's character boundaries (Line.edges), with Line.locate/quad/word for hit testing and drawing; lines whose boxes don't match the text fall back to even spacing. Errors are logged to the terminal with their traceback, and Ctrl+C in the terminal closes the app (restoring mouse mode), dismissing any open dialog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+78
-3
@@ -5,8 +5,12 @@ multilingual PP-OCRv6 models ship inside the wheel, so it works offline. They re
|
||||
Latin script (English, Swedish, German, French, ...) as well as Chinese and Japanese.
|
||||
Independent of the scanner hardware, like myscan.stitching.
|
||||
|
||||
lines = Ocr()(bgr) # [Line(box, text, score)], box = 4x2 corner points
|
||||
lines = Ocr()(bgr) # [Line(box, text, score, edges)], box = 4x2 corner points
|
||||
text, spans = layout(lines) # plain text; spans[i] = (start, end) of lines[i] in it
|
||||
|
||||
Each line knows where its characters are (from the recogniser's per-character boxes),
|
||||
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 threading
|
||||
from dataclasses import dataclass
|
||||
@@ -19,6 +23,11 @@ class Line:
|
||||
box: np.ndarray # 4x2 float corners (clockwise from top left), image pixels
|
||||
text: str
|
||||
score: float
|
||||
edges: np.ndarray = None # len(text) + 1 character boundaries, 0..1 along the line
|
||||
|
||||
def __post_init__(self):
|
||||
if self.edges is None: # no character boxes: assume evenly spaced characters
|
||||
self.edges = np.linspace(0.0, 1.0, len(self.text) + 1)
|
||||
|
||||
@property
|
||||
def cy(self):
|
||||
@@ -34,6 +43,67 @@ class Line:
|
||||
grows with the slant of a skewed page)."""
|
||||
return float(np.hypot(*(self.box[3] - self.box[0])))
|
||||
|
||||
def local(self, p):
|
||||
"""Image point -> (u, v): u runs 0..1 along the line, v 0..1 from its top to
|
||||
its bottom edge."""
|
||||
b = self.box
|
||||
m = np.array([b[1] - b[0], b[3] - b[0]]).T
|
||||
return np.linalg.solve(m, np.asarray(p, float) - b[0])
|
||||
|
||||
def locate(self, p):
|
||||
"""(distance in pixels from the line's box, character position nearest p)."""
|
||||
u, v = self.local(p)
|
||||
b = self.box
|
||||
du = max(-u, u - 1, 0) * np.hypot(*(b[1] - b[0]))
|
||||
dv = max(-v, v - 1, 0) * self.height
|
||||
return float(np.hypot(du, dv)), int(np.abs(self.edges - u).argmin())
|
||||
|
||||
def quad(self, a, b):
|
||||
"""4x2 outline of characters a..b (positions in text)."""
|
||||
top, bot = self.box[[0, 1]], self.box[[3, 2]]
|
||||
ta, tb = self.edges[a], self.edges[b]
|
||||
lerp = lambda e, t: e[0] + t * (e[1] - e[0])
|
||||
return np.array([lerp(top, ta), lerp(top, tb), lerp(bot, tb), lerp(bot, ta)])
|
||||
|
||||
def word(self, k):
|
||||
"""(start, end) of the word around character position k."""
|
||||
a = b = min(k, len(self.text))
|
||||
while a > 0 and not self.text[a - 1].isspace():
|
||||
a -= 1
|
||||
while b < len(self.text) and not self.text[b].isspace():
|
||||
b += 1
|
||||
return a, b
|
||||
|
||||
|
||||
def char_edges(box, text, chars):
|
||||
"""Character boundaries along a line (0..1) from the recogniser's boxes for its
|
||||
non-space characters (4x2 corners each); a space spans the gap between its
|
||||
neighbours. None if the boxes don't match the text."""
|
||||
if not chars or len(chars) != len(text) - text.count(" "):
|
||||
return None
|
||||
m0, m1 = (box[0] + box[3]) / 2, (box[1] + box[2]) / 2
|
||||
d = m1 - m0
|
||||
t = lambda p: float(np.dot(p - m0, d) / np.dot(d, d))
|
||||
ext = iter([(t((c[0] + c[3]) / 2), t((c[1] + c[2]) / 2)) for c in (np.asarray(c, float) for c in chars)])
|
||||
lo, hi = [None] * len(text), [None] * len(text)
|
||||
for i, ch in enumerate(text):
|
||||
if ch != " ":
|
||||
lo[i], hi[i] = next(ext)
|
||||
end = 0.0
|
||||
for i in range(len(text)): # spaces start where the previous character ends
|
||||
if lo[i] is None:
|
||||
lo[i] = end
|
||||
else:
|
||||
end = hi[i]
|
||||
start = 1.0
|
||||
for i in reversed(range(len(text))): # ... and end where the next one starts
|
||||
if hi[i] is None:
|
||||
hi[i] = start
|
||||
else:
|
||||
start = lo[i]
|
||||
edges = [0.0] + [(hi[i - 1] + lo[i]) / 2 for i in range(1, len(text))] + [1.0]
|
||||
return np.clip(np.maximum.accumulate(edges), 0.0, 1.0)
|
||||
|
||||
|
||||
class Ocr:
|
||||
"""Lazily loads the engine (about 0.3 s) on first use; calls are serialised."""
|
||||
@@ -50,10 +120,15 @@ class Ocr:
|
||||
|
||||
def __call__(self, bgr):
|
||||
with self._lock:
|
||||
r = self.engine()(bgr)
|
||||
r = self.engine()(bgr, return_word_box=True, return_single_char_box=True)
|
||||
if r.boxes is None:
|
||||
return []
|
||||
return [Line(np.asarray(b, float), t, float(s)) for b, t, s in zip(r.boxes, r.txts, r.scores)]
|
||||
words = r.word_results or [None] * len(r.txts)
|
||||
out = []
|
||||
for b, t, s, w in zip(r.boxes, r.txts, r.scores, words):
|
||||
b = np.asarray(b, float)
|
||||
out.append(Line(b, t, float(s), char_edges(b, t, [c[2] for c in w]) if w else None))
|
||||
return out
|
||||
|
||||
|
||||
def rows(lines):
|
||||
|
||||
Reference in New Issue
Block a user