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.
Files changed (48) hide show
  1. wormocr/__init__.py +18 -0
  2. wormocr/__main__.py +4 -0
  3. wormocr/api.py +229 -0
  4. wormocr/benchmark/__init__.py +5 -0
  5. wormocr/benchmark/report.py +403 -0
  6. wormocr/checkpoints.py +55 -0
  7. wormocr/cli.py +308 -0
  8. wormocr/config.py +220 -0
  9. wormocr/data/__init__.py +5 -0
  10. wormocr/data/dataset.py +110 -0
  11. wormocr/data/fonts.py +173 -0
  12. wormocr/data/handwriting.py +304 -0
  13. wormocr/data/lexicon.py +105 -0
  14. wormocr/data/lexicon_medical.py +48 -0
  15. wormocr/data/mixed.py +211 -0
  16. wormocr/data/synthetic.py +445 -0
  17. wormocr/detection/__init__.py +5 -0
  18. wormocr/detection/lines.py +382 -0
  19. wormocr/evaluation/__init__.py +5 -0
  20. wormocr/evaluation/errors.py +145 -0
  21. wormocr/evaluation/metrics.py +105 -0
  22. wormocr/evaluation/robustness.py +43 -0
  23. wormocr/exceptions.py +25 -0
  24. wormocr/export/__init__.py +5 -0
  25. wormocr/export/onnx_export.py +75 -0
  26. wormocr/hub.py +157 -0
  27. wormocr/inference/__init__.py +5 -0
  28. wormocr/inference/decode.py +113 -0
  29. wormocr/inference/engine.py +122 -0
  30. wormocr/models/__init__.py +5 -0
  31. wormocr/models/crnn.py +236 -0
  32. wormocr/preprocessing.py +135 -0
  33. wormocr/py.typed +0 -0
  34. wormocr/runtime.py +56 -0
  35. wormocr/training/__init__.py +5 -0
  36. wormocr/training/loop.py +645 -0
  37. wormocr/vocabulary.py +69 -0
  38. wormocr/web/__init__.py +5 -0
  39. wormocr/web/server.py +505 -0
  40. wormocr/web/static/app.js +514 -0
  41. wormocr/web/static/index.html +141 -0
  42. wormocr/web/static/style.css +379 -0
  43. wormocr-1.0.0.dist-info/METADATA +377 -0
  44. wormocr-1.0.0.dist-info/RECORD +48 -0
  45. wormocr-1.0.0.dist-info/WHEEL +5 -0
  46. wormocr-1.0.0.dist-info/entry_points.txt +2 -0
  47. wormocr-1.0.0.dist-info/licenses/LICENSE +21 -0
  48. 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,5 @@
1
+ """Evaluation metrics and error reports."""
2
+
3
+ from wormocr.evaluation.metrics import MetricTotals, cer, wer
4
+
5
+ __all__ = ["MetricTotals", "cer", "wer"]
@@ -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