wormocr 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wormocr/__init__.py +18 -0
- wormocr/__main__.py +4 -0
- wormocr/api.py +229 -0
- wormocr/benchmark/__init__.py +5 -0
- wormocr/benchmark/report.py +403 -0
- wormocr/checkpoints.py +55 -0
- wormocr/cli.py +308 -0
- wormocr/config.py +220 -0
- wormocr/data/__init__.py +5 -0
- wormocr/data/dataset.py +110 -0
- wormocr/data/fonts.py +173 -0
- wormocr/data/handwriting.py +304 -0
- wormocr/data/lexicon.py +105 -0
- wormocr/data/lexicon_medical.py +48 -0
- wormocr/data/mixed.py +211 -0
- wormocr/data/synthetic.py +445 -0
- wormocr/detection/__init__.py +5 -0
- wormocr/detection/lines.py +382 -0
- wormocr/evaluation/__init__.py +5 -0
- wormocr/evaluation/errors.py +145 -0
- wormocr/evaluation/metrics.py +105 -0
- wormocr/evaluation/robustness.py +43 -0
- wormocr/exceptions.py +25 -0
- wormocr/export/__init__.py +5 -0
- wormocr/export/onnx_export.py +75 -0
- wormocr/hub.py +157 -0
- wormocr/inference/__init__.py +5 -0
- wormocr/inference/decode.py +113 -0
- wormocr/inference/engine.py +122 -0
- wormocr/models/__init__.py +5 -0
- wormocr/models/crnn.py +236 -0
- wormocr/preprocessing.py +135 -0
- wormocr/py.typed +0 -0
- wormocr/runtime.py +56 -0
- wormocr/training/__init__.py +5 -0
- wormocr/training/loop.py +645 -0
- wormocr/vocabulary.py +69 -0
- wormocr/web/__init__.py +5 -0
- wormocr/web/server.py +505 -0
- wormocr/web/static/app.js +514 -0
- wormocr/web/static/index.html +141 -0
- wormocr/web/static/style.css +379 -0
- wormocr-1.0.0.dist-info/METADATA +377 -0
- wormocr-1.0.0.dist-info/RECORD +48 -0
- wormocr-1.0.0.dist-info/WHEEL +5 -0
- wormocr-1.0.0.dist-info/entry_points.txt +2 -0
- wormocr-1.0.0.dist-info/licenses/LICENSE +21 -0
- wormocr-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""Projection-profile line detection and reading order.
|
|
2
|
+
|
|
3
|
+
This is a classical layout pass for simple pages, receipts, and screenshots.
|
|
4
|
+
It is not a learned detector and it is not a table parser. The line recognizer
|
|
5
|
+
does not depend on it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from PIL import Image, ImageDraw
|
|
15
|
+
|
|
16
|
+
from wormocr.preprocessing import border_color, to_grayscale
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Box:
|
|
21
|
+
x0: int
|
|
22
|
+
y0: int
|
|
23
|
+
x1: int
|
|
24
|
+
y1: int
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def width(self) -> int:
|
|
28
|
+
return max(0, self.x1 - self.x0)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def height(self) -> int:
|
|
32
|
+
return max(0, self.y1 - self.y0)
|
|
33
|
+
|
|
34
|
+
def as_xywh(self) -> tuple[int, int, int, int]:
|
|
35
|
+
return (self.x0, self.y0, self.width, self.height)
|
|
36
|
+
|
|
37
|
+
def pad(self, amount: int, limit_width: int, limit_height: int) -> "Box":
|
|
38
|
+
return Box(
|
|
39
|
+
max(0, self.x0 - amount),
|
|
40
|
+
max(0, self.y0 - amount),
|
|
41
|
+
min(limit_width, self.x1 + amount),
|
|
42
|
+
min(limit_height, self.y1 + amount),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def otsu_threshold(gray: np.ndarray) -> int:
|
|
47
|
+
hist = np.bincount(gray.ravel().astype(np.uint8), minlength=256).astype(np.float64)
|
|
48
|
+
total = float(gray.size)
|
|
49
|
+
sum_total = float(np.dot(np.arange(256), hist))
|
|
50
|
+
sum_background = 0.0
|
|
51
|
+
weight_background = 0.0
|
|
52
|
+
best = 0
|
|
53
|
+
best_variance = -1.0
|
|
54
|
+
for threshold in range(256):
|
|
55
|
+
weight_background += hist[threshold]
|
|
56
|
+
if weight_background == 0:
|
|
57
|
+
continue
|
|
58
|
+
weight_foreground = total - weight_background
|
|
59
|
+
if weight_foreground == 0:
|
|
60
|
+
break
|
|
61
|
+
sum_background += threshold * hist[threshold]
|
|
62
|
+
mean_background = sum_background / weight_background
|
|
63
|
+
mean_foreground = (sum_total - sum_background) / weight_foreground
|
|
64
|
+
variance = weight_background * weight_foreground * (mean_background - mean_foreground) ** 2
|
|
65
|
+
if variance > best_variance:
|
|
66
|
+
best_variance = variance
|
|
67
|
+
best = threshold
|
|
68
|
+
return best
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def binarize(gray: np.ndarray) -> np.ndarray:
|
|
72
|
+
values = np.asarray(gray, dtype=np.uint8)
|
|
73
|
+
threshold = otsu_threshold(values)
|
|
74
|
+
if float(values.mean()) < 110.0:
|
|
75
|
+
return values >= threshold
|
|
76
|
+
return values <= threshold
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _runs(mask: np.ndarray) -> list[tuple[int, int]]:
|
|
80
|
+
runs: list[tuple[int, int]] = []
|
|
81
|
+
start: int | None = None
|
|
82
|
+
for index, active in enumerate(mask.tolist()):
|
|
83
|
+
if active and start is None:
|
|
84
|
+
start = index
|
|
85
|
+
elif not active and start is not None:
|
|
86
|
+
runs.append((start, index))
|
|
87
|
+
start = None
|
|
88
|
+
if start is not None:
|
|
89
|
+
runs.append((start, len(mask)))
|
|
90
|
+
return runs
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _valley_split(start: int, end: int, row_fraction: np.ndarray, typical: int, depth: int = 0) -> list[tuple[int, int]]:
|
|
94
|
+
height = end - start
|
|
95
|
+
if depth > 4 or height < max(20, int(typical * 2.2)):
|
|
96
|
+
return [(start, end)]
|
|
97
|
+
middle_lo = start + height // 4
|
|
98
|
+
middle_hi = end - height // 4
|
|
99
|
+
if middle_hi <= middle_lo:
|
|
100
|
+
return [(start, end)]
|
|
101
|
+
segment = row_fraction[middle_lo:middle_hi]
|
|
102
|
+
valley = int(np.argmin(segment)) + middle_lo
|
|
103
|
+
peak = float(row_fraction[start:end].max())
|
|
104
|
+
if float(row_fraction[valley]) > max(peak * 0.35, 0.01):
|
|
105
|
+
return [(start, end)]
|
|
106
|
+
return _valley_split(start, valley, row_fraction, typical, depth + 1) + _valley_split(
|
|
107
|
+
valley + 1, end, row_fraction, typical, depth + 1
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def line_boxes(gray: np.ndarray) -> list[Box]:
|
|
112
|
+
if gray.ndim != 2:
|
|
113
|
+
raise ValueError("line detection expects a grayscale image")
|
|
114
|
+
ink = binarize(gray)
|
|
115
|
+
height, width = ink.shape
|
|
116
|
+
if width == 0 or height == 0 or int(ink.sum()) < 8:
|
|
117
|
+
return []
|
|
118
|
+
row_fraction = ink.mean(axis=1)
|
|
119
|
+
bands = _runs(row_fraction > 0.004)
|
|
120
|
+
if not bands:
|
|
121
|
+
return []
|
|
122
|
+
heights = [end - start for start, end in bands]
|
|
123
|
+
typical = sorted(heights)[len(heights) // 2]
|
|
124
|
+
if len(bands) == 1:
|
|
125
|
+
typical = max(8, heights[0] // 3)
|
|
126
|
+
split_bands: list[tuple[int, int]] = []
|
|
127
|
+
for start, end in bands:
|
|
128
|
+
split_bands.extend(_valley_split(start, end, row_fraction, typical))
|
|
129
|
+
column_gap = max(48, int(0.08 * width))
|
|
130
|
+
boxes: list[Box] = []
|
|
131
|
+
for y0, y1 in split_bands:
|
|
132
|
+
if y1 - y0 < 6:
|
|
133
|
+
continue
|
|
134
|
+
columns = _runs(ink[y0:y1].mean(axis=0) > 0.01)
|
|
135
|
+
if not columns:
|
|
136
|
+
continue
|
|
137
|
+
merged: list[list[int]] = [[columns[0][0], columns[0][1]]]
|
|
138
|
+
for start, end in columns[1:]:
|
|
139
|
+
if start - merged[-1][1] < column_gap:
|
|
140
|
+
merged[-1][1] = end
|
|
141
|
+
else:
|
|
142
|
+
merged.append([start, end])
|
|
143
|
+
for start, end in merged:
|
|
144
|
+
if end - start < 6:
|
|
145
|
+
continue
|
|
146
|
+
boxes.append(Box(int(start), int(y0), int(end), int(y1)))
|
|
147
|
+
return boxes
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def vertical_split(boxes: list[Box], min_gap: int) -> tuple[list[Box], list[Box]] | None:
|
|
151
|
+
if len(boxes) < 2:
|
|
152
|
+
return None
|
|
153
|
+
events: list[tuple[int, int]] = []
|
|
154
|
+
for box in boxes:
|
|
155
|
+
events.append((box.x0, 1))
|
|
156
|
+
events.append((box.x1, -1))
|
|
157
|
+
events.sort(key=lambda item: (item[0], item[1]))
|
|
158
|
+
coverage = 0
|
|
159
|
+
in_gap = False
|
|
160
|
+
gap_start = 0
|
|
161
|
+
gaps: list[tuple[int, int, int]] = []
|
|
162
|
+
for position, delta in events:
|
|
163
|
+
if delta < 0:
|
|
164
|
+
coverage += delta
|
|
165
|
+
if coverage == 0:
|
|
166
|
+
in_gap = True
|
|
167
|
+
gap_start = position
|
|
168
|
+
else:
|
|
169
|
+
if in_gap and coverage == 0 and position - gap_start >= min_gap:
|
|
170
|
+
gaps.append((gap_start, position, position - gap_start))
|
|
171
|
+
in_gap = False
|
|
172
|
+
coverage += delta
|
|
173
|
+
best: tuple[tuple[int, int], list[Box], list[Box]] | None = None
|
|
174
|
+
for start, end, gap in gaps:
|
|
175
|
+
left = [box for box in boxes if box.x1 <= start]
|
|
176
|
+
right = [box for box in boxes if box.x0 >= end]
|
|
177
|
+
if not left or not right or len(left) + len(right) != len(boxes):
|
|
178
|
+
continue
|
|
179
|
+
score = (min(len(left), len(right)), gap)
|
|
180
|
+
if best is None or score > best[0]:
|
|
181
|
+
best = (score, left, right)
|
|
182
|
+
if best is None:
|
|
183
|
+
return None
|
|
184
|
+
return best[1], best[2]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _horizontal_bands(boxes: list[Box], min_gap: int) -> list[list[Box]]:
|
|
188
|
+
ordered = sorted(boxes, key=lambda box: (box.y0, box.x0))
|
|
189
|
+
bands: list[list[Box]] = [[ordered[0]]]
|
|
190
|
+
bottom = ordered[0].y1
|
|
191
|
+
for box in ordered[1:]:
|
|
192
|
+
if box.y0 - bottom >= min_gap:
|
|
193
|
+
bands.append([box])
|
|
194
|
+
bottom = box.y1
|
|
195
|
+
else:
|
|
196
|
+
bands[-1].append(box)
|
|
197
|
+
bottom = max(bottom, box.y1)
|
|
198
|
+
return bands
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def reading_order(boxes: list[Box], min_gap_x: int = 16, min_gap_y: int = 4) -> list[Box]:
|
|
202
|
+
"""Read down each column when a gutter exists. Full-width lines stay in place."""
|
|
203
|
+
return _reading_order(list(boxes), min_gap_x, min_gap_y)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _reading_order(boxes: list[Box], min_gap_x: int, min_gap_y: int) -> list[Box]:
|
|
207
|
+
if len(boxes) <= 1:
|
|
208
|
+
return list(boxes)
|
|
209
|
+
x0 = min(box.x0 for box in boxes)
|
|
210
|
+
x1 = max(box.x1 for box in boxes)
|
|
211
|
+
group_width = max(1, x1 - x0)
|
|
212
|
+
spanning = [box for box in boxes if box.width >= 0.7 * group_width]
|
|
213
|
+
body = [box for box in boxes if box not in spanning]
|
|
214
|
+
split = vertical_split(body, min_gap_x) if len(body) >= 2 else None
|
|
215
|
+
if split is not None:
|
|
216
|
+
left, right = split
|
|
217
|
+
if spanning:
|
|
218
|
+
return _interleave_spanning(spanning, left, right, min_gap_x, min_gap_y)
|
|
219
|
+
return _reading_order(left, min_gap_x, min_gap_y) + _reading_order(right, min_gap_x, min_gap_y)
|
|
220
|
+
bands = _horizontal_bands(boxes, min_gap_y)
|
|
221
|
+
if len(bands) > 1:
|
|
222
|
+
ordered: list[Box] = []
|
|
223
|
+
for band in bands:
|
|
224
|
+
ordered.extend(_reading_order(band, min_gap_x, min_gap_y))
|
|
225
|
+
return ordered
|
|
226
|
+
return sorted(boxes, key=lambda box: (box.y0, box.x0))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _interleave_spanning(
|
|
230
|
+
spanning: list[Box],
|
|
231
|
+
left: list[Box],
|
|
232
|
+
right: list[Box],
|
|
233
|
+
min_gap_x: int,
|
|
234
|
+
min_gap_y: int,
|
|
235
|
+
) -> list[Box]:
|
|
236
|
+
left_ordered = _reading_order(left, min_gap_x, min_gap_y)
|
|
237
|
+
right_ordered = _reading_order(right, min_gap_x, min_gap_y)
|
|
238
|
+
spans = sorted(spanning, key=lambda box: box.y0)
|
|
239
|
+
output: list[Box] = []
|
|
240
|
+
for index, span in enumerate(spans):
|
|
241
|
+
y_lo = spans[index - 1].y1 if index else -10**9
|
|
242
|
+
output.extend(box for box in left_ordered if y_lo <= box.y0 < span.y0)
|
|
243
|
+
output.extend(box for box in right_ordered if y_lo <= box.y0 < span.y0)
|
|
244
|
+
output.append(span)
|
|
245
|
+
y_lo = spans[-1].y1
|
|
246
|
+
output.extend(box for box in left_ordered if box.y0 >= y_lo)
|
|
247
|
+
output.extend(box for box in right_ordered if box.y0 >= y_lo)
|
|
248
|
+
return output
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def projection_score(gray: np.ndarray) -> float:
|
|
252
|
+
ink = binarize(gray)
|
|
253
|
+
if ink.size == 0:
|
|
254
|
+
return 0.0
|
|
255
|
+
return float(ink.mean(axis=1).var())
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def estimate_skew(gray: np.ndarray, max_angle: float = 5.0, step: float = 0.5) -> float:
|
|
259
|
+
"""Return the counter-clockwise degrees that straighten horizontal text."""
|
|
260
|
+
sample = np.asarray(gray, dtype=np.uint8)
|
|
261
|
+
if sample.shape[1] > 700:
|
|
262
|
+
scale = 700 / sample.shape[1]
|
|
263
|
+
resized = Image.fromarray(sample).resize(
|
|
264
|
+
(700, max(1, int(round(sample.shape[0] * scale)))),
|
|
265
|
+
Image.Resampling.BILINEAR,
|
|
266
|
+
)
|
|
267
|
+
sample = np.asarray(resized, dtype=np.uint8)
|
|
268
|
+
|
|
269
|
+
ink = Image.fromarray(binarize(sample).astype(np.uint8) * 255)
|
|
270
|
+
|
|
271
|
+
def score(angle: float) -> float:
|
|
272
|
+
rotated = ink.rotate(angle, resample=Image.Resampling.NEAREST, expand=False, fillcolor=0)
|
|
273
|
+
return float(np.asarray(rotated, dtype=np.float32).mean(axis=1).var())
|
|
274
|
+
|
|
275
|
+
best_angle = 0.0
|
|
276
|
+
best_score = score(0.0)
|
|
277
|
+
angle = -float(max_angle)
|
|
278
|
+
while angle <= max_angle + 1e-6:
|
|
279
|
+
if abs(angle) > 1e-6:
|
|
280
|
+
value = score(angle)
|
|
281
|
+
if value > best_score * 1.03:
|
|
282
|
+
best_score = value
|
|
283
|
+
best_angle = float(angle)
|
|
284
|
+
angle += step
|
|
285
|
+
return best_angle
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def pil_rotate_matrix(width: int, height: int, angle_deg: float) -> tuple[list[float], tuple[int, int]]:
|
|
289
|
+
"""Match Pillow's expand=True affine map from rotated pixels back to the original."""
|
|
290
|
+
angle = -math.radians(angle_deg % 360.0)
|
|
291
|
+
center = (width / 2.0, height / 2.0)
|
|
292
|
+
matrix = [
|
|
293
|
+
round(math.cos(angle), 15),
|
|
294
|
+
round(math.sin(angle), 15),
|
|
295
|
+
0.0,
|
|
296
|
+
round(-math.sin(angle), 15),
|
|
297
|
+
round(math.cos(angle), 15),
|
|
298
|
+
0.0,
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
def transform(x: float, y: float) -> tuple[float, float]:
|
|
302
|
+
a, b, c, d, e, f = matrix
|
|
303
|
+
return a * x + b * y + c, d * x + e * y + f
|
|
304
|
+
|
|
305
|
+
matrix[2], matrix[5] = transform(-center[0], -center[1])
|
|
306
|
+
matrix[2] += center[0]
|
|
307
|
+
matrix[5] += center[1]
|
|
308
|
+
xs: list[float] = []
|
|
309
|
+
ys: list[float] = []
|
|
310
|
+
for x, y in ((0, 0), (width, 0), (width, height), (0, height)):
|
|
311
|
+
mapped_x, mapped_y = transform(x, y)
|
|
312
|
+
xs.append(mapped_x)
|
|
313
|
+
ys.append(mapped_y)
|
|
314
|
+
new_width = math.ceil(max(xs)) - math.floor(min(xs))
|
|
315
|
+
new_height = math.ceil(max(ys)) - math.floor(min(ys))
|
|
316
|
+
matrix[2], matrix[5] = transform(-(new_width - width) / 2.0, -(new_height - height) / 2.0)
|
|
317
|
+
return matrix, (int(new_width), int(new_height))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def map_box_to_original(box: Box, matrix: list[float], original_size: tuple[int, int]) -> Box:
|
|
321
|
+
a, b, c, d, e, f = matrix
|
|
322
|
+
corners = (
|
|
323
|
+
(box.x0, box.y0),
|
|
324
|
+
(box.x1, box.y0),
|
|
325
|
+
(box.x0, box.y1),
|
|
326
|
+
(box.x1, box.y1),
|
|
327
|
+
)
|
|
328
|
+
mapped = [(a * x + b * y + c, d * x + e * y + f) for x, y in corners]
|
|
329
|
+
width, height = original_size
|
|
330
|
+
x0 = max(0, int(math.floor(min(point[0] for point in mapped))))
|
|
331
|
+
y0 = max(0, int(math.floor(min(point[1] for point in mapped))))
|
|
332
|
+
x1 = min(width, int(math.ceil(max(point[0] for point in mapped))))
|
|
333
|
+
y1 = min(height, int(math.ceil(max(point[1] for point in mapped))))
|
|
334
|
+
if x1 <= x0:
|
|
335
|
+
x1 = min(width, x0 + 1)
|
|
336
|
+
if y1 <= y0:
|
|
337
|
+
y1 = min(height, y0 + 1)
|
|
338
|
+
return Box(x0, y0, x1, y1)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@dataclass
|
|
342
|
+
class PageLayout:
|
|
343
|
+
boxes: list[Box]
|
|
344
|
+
crops: list[Image.Image]
|
|
345
|
+
angle: float
|
|
346
|
+
layout: str
|
|
347
|
+
original_size: tuple[int, int]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def segment_page(image: Image.Image, correct_orientation: bool = True) -> PageLayout:
|
|
351
|
+
gray = to_grayscale(image)
|
|
352
|
+
angle = estimate_skew(np.asarray(gray)) if correct_orientation else 0.0
|
|
353
|
+
if abs(angle) < 0.4:
|
|
354
|
+
angle = 0.0
|
|
355
|
+
if angle == 0.0:
|
|
356
|
+
working = gray
|
|
357
|
+
matrix = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]
|
|
358
|
+
else:
|
|
359
|
+
working = gray.rotate(
|
|
360
|
+
angle,
|
|
361
|
+
resample=Image.Resampling.BILINEAR,
|
|
362
|
+
expand=True,
|
|
363
|
+
fillcolor=border_color(gray),
|
|
364
|
+
)
|
|
365
|
+
matrix, _expected = pil_rotate_matrix(gray.width, gray.height, angle)
|
|
366
|
+
boxes = [box.pad(2, working.width, working.height) for box in line_boxes(np.asarray(working))]
|
|
367
|
+
ordered = reading_order(boxes)
|
|
368
|
+
crops = [working.crop((box.x0, box.y0, box.x1, box.y1)) for box in ordered]
|
|
369
|
+
if angle != 0.0:
|
|
370
|
+
reported = [map_box_to_original(box, matrix, gray.size) for box in ordered]
|
|
371
|
+
else:
|
|
372
|
+
reported = ordered
|
|
373
|
+
layout = "multi-column" if vertical_split(ordered, 16) is not None else "single-column"
|
|
374
|
+
return PageLayout(reported, crops, angle, layout, gray.size)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def draw_boxes(image: Image.Image, boxes: list[Box]) -> Image.Image:
|
|
378
|
+
canvas = image.convert("RGB")
|
|
379
|
+
draw = ImageDraw.Draw(canvas)
|
|
380
|
+
for box in boxes:
|
|
381
|
+
draw.rectangle((box.x0, box.y0, box.x1 - 1, box.y1 - 1), outline=(220, 30, 30))
|
|
382
|
+
return canvas
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Character-level error analysis for recognized lines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
PUNCTUATION = set("!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def align(reference: str, hypothesis: str) -> list[tuple[str, str | None, str | None]]:
|
|
12
|
+
"""Return edit operations in reading order: eq, sub, del, ins."""
|
|
13
|
+
ref_len, hyp_len = len(reference), len(hypothesis)
|
|
14
|
+
scores = [[0] * (hyp_len + 1) for _ in range(ref_len + 1)]
|
|
15
|
+
for i in range(1, ref_len + 1):
|
|
16
|
+
scores[i][0] = i
|
|
17
|
+
for j in range(1, hyp_len + 1):
|
|
18
|
+
scores[0][j] = j
|
|
19
|
+
for i in range(1, ref_len + 1):
|
|
20
|
+
for j in range(1, hyp_len + 1):
|
|
21
|
+
cost = 0 if reference[i - 1] == hypothesis[j - 1] else 1
|
|
22
|
+
scores[i][j] = min(
|
|
23
|
+
scores[i - 1][j] + 1,
|
|
24
|
+
scores[i][j - 1] + 1,
|
|
25
|
+
scores[i - 1][j - 1] + cost,
|
|
26
|
+
)
|
|
27
|
+
operations: list[tuple[str, str | None, str | None]] = []
|
|
28
|
+
i, j = ref_len, hyp_len
|
|
29
|
+
while i > 0 or j > 0:
|
|
30
|
+
if (
|
|
31
|
+
i > 0
|
|
32
|
+
and j > 0
|
|
33
|
+
and reference[i - 1] == hypothesis[j - 1]
|
|
34
|
+
and scores[i][j] == scores[i - 1][j - 1]
|
|
35
|
+
):
|
|
36
|
+
operations.append(("eq", reference[i - 1], hypothesis[j - 1]))
|
|
37
|
+
i -= 1
|
|
38
|
+
j -= 1
|
|
39
|
+
elif i > 0 and j > 0 and scores[i][j] == scores[i - 1][j - 1] + 1:
|
|
40
|
+
operations.append(("sub", reference[i - 1], hypothesis[j - 1]))
|
|
41
|
+
i -= 1
|
|
42
|
+
j -= 1
|
|
43
|
+
elif i > 0 and scores[i][j] == scores[i - 1][j] + 1:
|
|
44
|
+
operations.append(("del", reference[i - 1], None))
|
|
45
|
+
i -= 1
|
|
46
|
+
else:
|
|
47
|
+
operations.append(("ins", None, hypothesis[j - 1]))
|
|
48
|
+
j -= 1
|
|
49
|
+
operations.reverse()
|
|
50
|
+
return operations
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ErrorReport:
|
|
55
|
+
samples: int = 0
|
|
56
|
+
substitutions: int = 0
|
|
57
|
+
insertions: int = 0
|
|
58
|
+
deletions: int = 0
|
|
59
|
+
punctuation_errors: int = 0
|
|
60
|
+
case_only_substitutions: int = 0
|
|
61
|
+
repeated_character_failures: int = 0
|
|
62
|
+
confusion: Counter = field(default_factory=Counter)
|
|
63
|
+
worst: list[dict[str, float | str]] = field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
def add(self, reference: str, hypothesis: str, cer_value: float) -> None:
|
|
66
|
+
self.samples += 1
|
|
67
|
+
operations = align(reference, hypothesis)
|
|
68
|
+
for kind, ref_char, hyp_char in operations:
|
|
69
|
+
if kind == "sub":
|
|
70
|
+
self.substitutions += 1
|
|
71
|
+
self.confusion[(ref_char, hyp_char)] += 1
|
|
72
|
+
if ref_char and hyp_char and ref_char.lower() == hyp_char.lower() and ref_char != hyp_char:
|
|
73
|
+
self.case_only_substitutions += 1
|
|
74
|
+
if ref_char in PUNCTUATION or (hyp_char in PUNCTUATION if hyp_char else False):
|
|
75
|
+
self.punctuation_errors += 1
|
|
76
|
+
elif kind == "ins":
|
|
77
|
+
self.insertions += 1
|
|
78
|
+
if hyp_char in PUNCTUATION:
|
|
79
|
+
self.punctuation_errors += 1
|
|
80
|
+
elif kind == "del":
|
|
81
|
+
self.deletions += 1
|
|
82
|
+
if ref_char in PUNCTUATION:
|
|
83
|
+
self.punctuation_errors += 1
|
|
84
|
+
self.repeated_character_failures += _repeated_failures(reference, operations)
|
|
85
|
+
self.worst.append({"reference": reference, "hypothesis": hypothesis, "cer": cer_value})
|
|
86
|
+
|
|
87
|
+
def to_markdown(self) -> str:
|
|
88
|
+
worst = sorted(self.worst, key=lambda item: float(item["cer"]), reverse=True)[:8]
|
|
89
|
+
lines = [
|
|
90
|
+
"# wormOCR error analysis",
|
|
91
|
+
"",
|
|
92
|
+
f"Samples: {self.samples}",
|
|
93
|
+
"",
|
|
94
|
+
"## Edit operations",
|
|
95
|
+
"",
|
|
96
|
+
f"- substitutions: {self.substitutions}",
|
|
97
|
+
f"- insertions: {self.insertions}",
|
|
98
|
+
f"- deletions: {self.deletions}",
|
|
99
|
+
f"- punctuation errors: {self.punctuation_errors}",
|
|
100
|
+
f"- case-only substitutions: {self.case_only_substitutions}",
|
|
101
|
+
f"- repeated-character run failures: {self.repeated_character_failures}",
|
|
102
|
+
"",
|
|
103
|
+
"## Top substitutions",
|
|
104
|
+
"",
|
|
105
|
+
"| reference | hypothesis | count |",
|
|
106
|
+
"| --- | --- | ---: |",
|
|
107
|
+
]
|
|
108
|
+
for (ref_char, hyp_char), count in self.confusion.most_common(15):
|
|
109
|
+
lines.append(f"| `{_show(ref_char)}` | `{_show(hyp_char)}` | {count} |")
|
|
110
|
+
if not self.confusion:
|
|
111
|
+
lines.append("| | | 0 |")
|
|
112
|
+
lines.extend(["", "## Highest-CER samples", ""])
|
|
113
|
+
if not worst:
|
|
114
|
+
lines.append("No samples.")
|
|
115
|
+
for item in worst:
|
|
116
|
+
lines.append(f"- CER {float(item['cer']):.3f}")
|
|
117
|
+
lines.append(f" - ref: `{item['reference']}`")
|
|
118
|
+
lines.append(f" - hyp: `{item['hypothesis']}`")
|
|
119
|
+
lines.append("")
|
|
120
|
+
return "\n".join(lines)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _show(char: str | None) -> str:
|
|
124
|
+
if char is None:
|
|
125
|
+
return ""
|
|
126
|
+
if char == " ":
|
|
127
|
+
return "␣"
|
|
128
|
+
return char
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _repeated_failures(reference: str, operations: list[tuple[str, str | None, str | None]]) -> int:
|
|
132
|
+
ref_ops = [operation for operation in operations if operation[0] != "ins"]
|
|
133
|
+
failures = 0
|
|
134
|
+
index = 0
|
|
135
|
+
while index < len(reference):
|
|
136
|
+
end = index + 1
|
|
137
|
+
while end < len(reference) and reference[end] == reference[index]:
|
|
138
|
+
end += 1
|
|
139
|
+
run = end - index
|
|
140
|
+
if run >= 2:
|
|
141
|
+
matched = sum(1 for op in ref_ops[index:end] if op[0] == "eq")
|
|
142
|
+
if matched < run:
|
|
143
|
+
failures += 1
|
|
144
|
+
index = end
|
|
145
|
+
return failures
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Character and word error rates.
|
|
2
|
+
|
|
3
|
+
CER counts every character, including spaces. WER splits on single spaces, so a
|
|
4
|
+
doubled space is its own token and is not collapsed away. Exact match requires
|
|
5
|
+
the full strings to be equal.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def edit_distance(reference: list | str, hypothesis: list | str) -> int:
|
|
14
|
+
if len(reference) < len(hypothesis):
|
|
15
|
+
reference, hypothesis = hypothesis, reference
|
|
16
|
+
previous = list(range(len(hypothesis) + 1))
|
|
17
|
+
for index, left in enumerate(reference, start=1):
|
|
18
|
+
current = [index]
|
|
19
|
+
for inner, right in enumerate(hypothesis, start=1):
|
|
20
|
+
insert = current[inner - 1] + 1
|
|
21
|
+
delete = previous[inner] + 1
|
|
22
|
+
replace = previous[inner - 1] + (left != right)
|
|
23
|
+
current.append(min(insert, delete, replace))
|
|
24
|
+
previous = current
|
|
25
|
+
return previous[-1]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cer(reference: str, hypothesis: str) -> float:
|
|
29
|
+
if not reference and not hypothesis:
|
|
30
|
+
return 0.0
|
|
31
|
+
return edit_distance(reference, hypothesis) / max(len(reference), 1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def words(text: str) -> list[str]:
|
|
35
|
+
return text.split(" ")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def wer(reference: str, hypothesis: str) -> float:
|
|
39
|
+
reference_words = words(reference)
|
|
40
|
+
hypothesis_words = words(hypothesis)
|
|
41
|
+
if not reference_words and not hypothesis_words:
|
|
42
|
+
return 0.0
|
|
43
|
+
return edit_distance(reference_words, hypothesis_words) / max(len(reference_words), 1)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class MetricTotals:
|
|
48
|
+
characters: int = 0
|
|
49
|
+
character_edits: int = 0
|
|
50
|
+
words: int = 0
|
|
51
|
+
word_edits: int = 0
|
|
52
|
+
exact: int = 0
|
|
53
|
+
samples: int = 0
|
|
54
|
+
loss_sum: float = 0.0
|
|
55
|
+
loss_batches: int = 0
|
|
56
|
+
|
|
57
|
+
def update(self, reference: str, hypothesis: str) -> None:
|
|
58
|
+
self.samples += 1
|
|
59
|
+
self.characters += len(reference)
|
|
60
|
+
self.character_edits += edit_distance(reference, hypothesis)
|
|
61
|
+
reference_words = words(reference)
|
|
62
|
+
self.words += len(reference_words)
|
|
63
|
+
self.word_edits += edit_distance(reference_words, words(hypothesis))
|
|
64
|
+
self.exact += int(reference == hypothesis)
|
|
65
|
+
|
|
66
|
+
def update_loss(self, loss: float) -> None:
|
|
67
|
+
self.loss_sum += float(loss)
|
|
68
|
+
self.loss_batches += 1
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def corpus_cer(self) -> float:
|
|
72
|
+
if self.characters == 0:
|
|
73
|
+
return 0.0
|
|
74
|
+
return self.character_edits / self.characters
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def corpus_wer(self) -> float:
|
|
78
|
+
if self.words == 0:
|
|
79
|
+
return 0.0
|
|
80
|
+
return self.word_edits / self.words
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def exact_match(self) -> float:
|
|
84
|
+
if self.samples == 0:
|
|
85
|
+
return 0.0
|
|
86
|
+
return self.exact / self.samples
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def mean_loss(self) -> float | None:
|
|
90
|
+
if self.loss_batches == 0:
|
|
91
|
+
return None
|
|
92
|
+
return self.loss_sum / self.loss_batches
|
|
93
|
+
|
|
94
|
+
def to_dict(self) -> dict[str, float | int | None]:
|
|
95
|
+
return {
|
|
96
|
+
"samples": self.samples,
|
|
97
|
+
"cer": self.corpus_cer,
|
|
98
|
+
"wer": self.corpus_wer,
|
|
99
|
+
"exact_match": self.exact_match,
|
|
100
|
+
"character_edits": self.character_edits,
|
|
101
|
+
"characters": self.characters,
|
|
102
|
+
"word_edits": self.word_edits,
|
|
103
|
+
"words": self.words,
|
|
104
|
+
"loss": self.mean_loss,
|
|
105
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Controlled corruptions for robustness measurements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
from wormocr.data.fonts import discover_fonts
|
|
10
|
+
from wormocr.data.synthetic import _tensor_to_pil, make_sample, sample_text
|
|
11
|
+
from wormocr.evaluation.metrics import MetricTotals
|
|
12
|
+
from wormocr.inference.engine import LineEngine
|
|
13
|
+
|
|
14
|
+
CORRUPTIONS = ("clean", "blur", "noise", "jpeg", "lowres", "rotate", "shadow", "small")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def measure_robustness(engine: LineEngine, samples: int, seed: int) -> dict[str, dict]:
|
|
18
|
+
config = engine.config
|
|
19
|
+
fonts = list(discover_fonts(config["data"].get("font_dir") or ""))
|
|
20
|
+
texts = [
|
|
21
|
+
sample_text(random.Random(seed + index * 17), int(config["data"]["max_chars"]))
|
|
22
|
+
for index in range(samples)
|
|
23
|
+
]
|
|
24
|
+
report: dict[str, dict] = {}
|
|
25
|
+
for kind in CORRUPTIONS:
|
|
26
|
+
images: list[Image.Image] = []
|
|
27
|
+
for index, text in enumerate(texts):
|
|
28
|
+
sample = make_sample(
|
|
29
|
+
index,
|
|
30
|
+
seed + 99,
|
|
31
|
+
config,
|
|
32
|
+
fonts,
|
|
33
|
+
augment=False,
|
|
34
|
+
text=text,
|
|
35
|
+
corruption=kind,
|
|
36
|
+
)
|
|
37
|
+
images.append(_tensor_to_pil(sample["image"]))
|
|
38
|
+
hypotheses = engine.recognize(images)
|
|
39
|
+
totals = MetricTotals()
|
|
40
|
+
for text, hypothesis in zip(texts, hypotheses):
|
|
41
|
+
totals.update(text, str(hypothesis["text"]))
|
|
42
|
+
report[kind] = totals.to_dict()
|
|
43
|
+
return report
|