vi3ion 0.1.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 (93) hide show
  1. agent_interface/__init__.py +2 -0
  2. agent_interface/mcp_server.py +12 -0
  3. vi3ion/__init__.py +3 -0
  4. vi3ion/analyzer.py +353 -0
  5. vi3ion/attention.py +96 -0
  6. vi3ion/brief.py +266 -0
  7. vi3ion/caption.py +145 -0
  8. vi3ion/cjk_glyphs.py +326 -0
  9. vi3ion/cli.py +1064 -0
  10. vi3ion/code_verify.py +173 -0
  11. vi3ion/colors.py +56 -0
  12. vi3ion/composition.py +371 -0
  13. vi3ion/config.py +229 -0
  14. vi3ion/core/__init__.py +1 -0
  15. vi3ion/core/context/__init__.py +3 -0
  16. vi3ion/core/context/session.py +25 -0
  17. vi3ion/core/coordinates.py +113 -0
  18. vi3ion/core/engine.py +1275 -0
  19. vi3ion/core/geometry/__init__.py +4 -0
  20. vi3ion/core/geometry/contours.py +23 -0
  21. vi3ion/core/geometry/edges.py +45 -0
  22. vi3ion/core/geometry/measurements.py +100 -0
  23. vi3ion/core/geometry/pipeline.py +83 -0
  24. vi3ion/core/geometry/shapes.py +215 -0
  25. vi3ion/core/ingestion/__init__.py +3 -0
  26. vi3ion/core/ingestion/loader.py +202 -0
  27. vi3ion/core/ocr/__init__.py +5 -0
  28. vi3ion/core/ocr/japan.py +105 -0
  29. vi3ion/core/ocr/models/japan_rec_crnn.onnx +0 -0
  30. vi3ion/core/ocr/pipeline.py +153 -0
  31. vi3ion/core/ocr/providers.py +152 -0
  32. vi3ion/core/ocr/reading_order.py +38 -0
  33. vi3ion/core/pixels/__init__.py +14 -0
  34. vi3ion/core/pixels/colors.py +128 -0
  35. vi3ion/core/pixels/element_color.py +209 -0
  36. vi3ion/core/pixels/pipeline.py +113 -0
  37. vi3ion/core/pixels/segmentation.py +71 -0
  38. vi3ion/core/pixels/statistics.py +66 -0
  39. vi3ion/core/semantics/__init__.py +67 -0
  40. vi3ion/core/semantics/clip_tags.py +108 -0
  41. vi3ion/core/semantics/florence.py +223 -0
  42. vi3ion/core/semantics/interface.py +68 -0
  43. vi3ion/core/semantics/merge.py +189 -0
  44. vi3ion/core/semantics/mock.py +83 -0
  45. vi3ion/core/semantics/owlvit.py +132 -0
  46. vi3ion/core/semantics/pipeline.py +178 -0
  47. vi3ion/core/semantics/remote.py +169 -0
  48. vi3ion/core/spatial/__init__.py +4 -0
  49. vi3ion/core/spatial/pipeline.py +125 -0
  50. vi3ion/core/spatial/relationships.py +201 -0
  51. vi3ion/core/spatial/scene_graph.py +66 -0
  52. vi3ion/core/summary.py +414 -0
  53. vi3ion/core/verification/__init__.py +4 -0
  54. vi3ion/core/verification/claims.py +278 -0
  55. vi3ion/core/verification/verifier.py +963 -0
  56. vi3ion/describe_shapes.py +300 -0
  57. vi3ion/errors.py +27 -0
  58. vi3ion/evaluate.py +311 -0
  59. vi3ion/evidence.py +187 -0
  60. vi3ion/footer_fix.py +137 -0
  61. vi3ion/fused_words.py +107 -0
  62. vi3ion/image_memory.py +444 -0
  63. vi3ion/index_sequence.py +201 -0
  64. vi3ion/local_stack.py +494 -0
  65. vi3ion/mcp_server.py +413 -0
  66. vi3ion/path_hints.py +88 -0
  67. vi3ion/relook.py +244 -0
  68. vi3ion/relook_rows.py +225 -0
  69. vi3ion/schemas/__init__.py +43 -0
  70. vi3ion/schemas/common.py +275 -0
  71. vi3ion/schemas/requests.py +94 -0
  72. vi3ion/schemas/responses.py +65 -0
  73. vi3ion/server/__init__.py +1 -0
  74. vi3ion/server/app.py +88 -0
  75. vi3ion/server/deps.py +9 -0
  76. vi3ion/server/routes/__init__.py +5 -0
  77. vi3ion/server/routes/images.py +251 -0
  78. vi3ion/server/routes/system.py +47 -0
  79. vi3ion/server/routes/tools.py +152 -0
  80. vi3ion/skill/SKILL.md +126 -0
  81. vi3ion/storage/__init__.py +4 -0
  82. vi3ion/storage/database.py +521 -0
  83. vi3ion/storage/image_store.py +45 -0
  84. vi3ion/text_merge.py +289 -0
  85. vi3ion/visual_diff.py +130 -0
  86. vi3ion/word_split.py +197 -0
  87. vi3ion-0.1.0.dist-info/METADATA +369 -0
  88. vi3ion-0.1.0.dist-info/RECORD +93 -0
  89. vi3ion-0.1.0.dist-info/WHEEL +5 -0
  90. vi3ion-0.1.0.dist-info/entry_points.txt +3 -0
  91. vi3ion-0.1.0.dist-info/licenses/LICENSE +201 -0
  92. vi3ion-0.1.0.dist-info/licenses/NOTICE +21 -0
  93. vi3ion-0.1.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,2 @@
1
+ """Legacy package kept for backwards compatibility (see mcp_server)."""
2
+ __all__ = ["mcp_server"]
@@ -0,0 +1,12 @@
1
+ """Legacy shim: the MCP server now lives in vi3ion.mcp_server.
2
+
3
+ Launchers built from older entry points (``from agent_interface.mcp_server
4
+ import main``) keep working through this module instead of failing with
5
+ ModuleNotFoundError. Running the module directly also starts the server.
6
+ """
7
+ from vi3ion.mcp_server import main # noqa: F401
8
+
9
+ __all__ = ["main"]
10
+
11
+ if __name__ == "__main__": # pragma: no cover - process entry point
12
+ main()
vi3ion/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Vi3ion: visual intelligence for AI agents."""
2
+
3
+ __version__ = "0.1.0"
vi3ion/analyzer.py ADDED
@@ -0,0 +1,353 @@
1
+ """Text layer: OCR -> rows -> spatial regions -> budgeted, annotated items.
2
+
3
+ This is the "exact text and layout" half of the brief. Drop rules:
4
+ - confidence < 0.30 -> dropped and logged, counted per region
5
+ - confidence < 0.80 -> marked ``(uncertain)``
6
+ - per-region budget -> dropped and logged (counts reported per region)
7
+
8
+ Merged/fragmented OCR text is repaired by :mod:`vi3ion.text_merge` and
9
+ :mod:`vi3ion.fused_words`, which run between OCR and region assignment.
10
+ Second-look passes (contrast, 2x small-text bands, per-row index recovery) live
11
+ in :mod:`vi3ion.relook` and :mod:`vi3ion.relook_rows`; index flags are reasoned
12
+ about in :mod:`vi3ion.index_sequence`. Shape/graph data is intentionally not
13
+ surfaced here; it belongs in debug logs.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import time
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+
23
+ import cv2
24
+
25
+ from .cjk_glyphs import recover_cjk
26
+ from .core.ocr.providers import get_ocr_provider
27
+ from .footer_fix import reconstruct_footer
28
+ from .fused_words import split_lowercase_words
29
+ from .index_sequence import sequence_correct
30
+ from .relook import contrast_items, small_text_items
31
+ from .relook_rows import recover_rows
32
+ from .word_split import split_body_words
33
+ from .text_merge import ( # noqa: F401 (re-exported for callers)
34
+ REGION_ORDER,
35
+ TextItem,
36
+ join_same_row,
37
+ pair_numbers_across_rows,
38
+ split_merged_words,
39
+ )
40
+
41
+ logger = logging.getLogger("vi3ion.analyzer")
42
+
43
+ TOP_FRACTION = 0.12
44
+ BOTTOM_FRACTION = 0.08
45
+ COLUMN_FRACTION = 0.25
46
+ # Display text wider than this is anchored by its left edge: a headline that
47
+ # starts in the left column belongs there even when its centre crosses 25%.
48
+ WIDE_TEXT_FRACTION = 0.25
49
+ CONF_FLOOR = 0.30
50
+ CONF_UNCERTAIN = 0.80
51
+
52
+ # Budget share per region (items), roughly 1500 tokens total when combined with
53
+ # the shape and evidence sections. The bottom bar is never starved.
54
+ REGION_BUDGET = {
55
+ "top_bar": 8,
56
+ "left_column": 12,
57
+ "centre": 8,
58
+ "right_column": 14,
59
+ "bottom_bar": 14,
60
+ }
61
+
62
+
63
+ @dataclass
64
+ class AnalyzerResult:
65
+ items: list[TextItem] = field(default_factory=list)
66
+ sections: dict[str, list[TextItem]] = field(default_factory=dict)
67
+ omitted: dict[str, int] = field(default_factory=dict)
68
+ untranscribed: dict[str, int] = field(default_factory=dict)
69
+ width: int = 0
70
+ height: int = 0
71
+ duration_ms: float = 0.0
72
+
73
+
74
+ def _ensure_debug_log() -> Path:
75
+ debug_dir = Path("data") / "debug"
76
+ debug_dir.mkdir(parents=True, exist_ok=True)
77
+ path = debug_dir / "analyzer.log"
78
+ handler = logging.FileHandler(path, encoding="utf-8")
79
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
80
+ if logger.handlers:
81
+ logger.handlers = [handler]
82
+ else:
83
+ logger.addHandler(handler)
84
+ logger.setLevel(logging.DEBUG)
85
+ return path
86
+
87
+
88
+ def _ocr_lines(bgr: np.ndarray, path: Path):
89
+ """Primary OCR pass. Returns (kept, dropped) with drops kept for counting."""
90
+ provider = get_ocr_provider()
91
+ if not provider.available:
92
+ logger.warning("OCR provider unavailable: %s", path)
93
+ return [], []
94
+ kept, dropped = [], []
95
+ for line in provider.recognize(bgr):
96
+ text = line.text.strip()
97
+ if not text:
98
+ logger.debug("drop: empty text @ %s", path)
99
+ continue
100
+ if line.confidence < CONF_FLOOR:
101
+ logger.debug(
102
+ "drop: confidence %.2f < %.2f %r @ %s",
103
+ line.confidence, CONF_FLOOR, text, path,
104
+ )
105
+ dropped.append((text, float(line.confidence), tuple(line.bbox)))
106
+ continue
107
+ kept.append(line)
108
+ return kept, dropped
109
+
110
+
111
+ def _text_items(lines, force_uncertain: bool = False) -> list[TextItem]:
112
+ return [
113
+ TextItem(
114
+ value=line.text.strip(),
115
+ bbox=tuple(float(v) for v in line.bbox),
116
+ confidence=float(line.confidence),
117
+ uncertain=force_uncertain or line.confidence < CONF_UNCERTAIN,
118
+ )
119
+ for line in lines
120
+ ]
121
+
122
+
123
+ def _group_rows(items: list[TextItem]) -> list[list[TextItem]]:
124
+ """Cluster by vertical overlap; sort rows top->bottom, items left->right."""
125
+ ordered = sorted(items, key=lambda item: (item.bbox[1], item.bbox[0]))
126
+ rows: list[list[TextItem]] = []
127
+ for item in ordered:
128
+ y0, y1 = item.bbox[1], item.bbox[3]
129
+ placed = False
130
+ for row in rows:
131
+ r0 = min(i.bbox[1] for i in row)
132
+ r1 = max(i.bbox[3] for i in row)
133
+ overlap = min(y1, r1) - max(y0, r0)
134
+ if overlap > 0.5 * min(y1 - y0, r1 - r0):
135
+ row.append(item)
136
+ placed = True
137
+ break
138
+ if not placed:
139
+ rows.append([item])
140
+ for row in rows:
141
+ row.sort(key=lambda item: item.bbox[0])
142
+ rows.sort(key=lambda row: min(i.bbox[1] for i in row))
143
+ return rows
144
+
145
+
146
+ def _region_of(bbox, width: int, height: int) -> str:
147
+ """Spatial region for one box. Wide display text is anchored by its left edge."""
148
+ x0, y0, x1, y1 = bbox
149
+ if y1 < TOP_FRACTION * height:
150
+ return "top_bar"
151
+ if y0 > (1 - BOTTOM_FRACTION) * height:
152
+ return "bottom_bar"
153
+ anchor = x0 if (x1 - x0) > WIDE_TEXT_FRACTION * width else (x0 + x1) / 2
154
+ if anchor < COLUMN_FRACTION * width:
155
+ return "left_column"
156
+ if anchor > (1 - COLUMN_FRACTION) * width:
157
+ return "right_column"
158
+ return "centre"
159
+
160
+
161
+ def _assign_regions(items: list[TextItem], width: int, height: int) -> None:
162
+ for item in items:
163
+ item.region = _region_of(item.bbox, width, height)
164
+
165
+
166
+ def _is_body_text(item: TextItem) -> bool:
167
+ """Paragraph prose: short item, contains lowercase, not a bare number."""
168
+ h = item.bbox[3] - item.bbox[1]
169
+ if not 10 <= h <= 40:
170
+ return False
171
+ if any(ch.islower() for ch in item.value):
172
+ return True
173
+ return False
174
+
175
+
176
+ def _vertical_adjacent(above: TextItem, below: TextItem) -> bool:
177
+ """Two same-column lines with strong x-overlap and a small gap."""
178
+ ox = min(above.bbox[2], below.bbox[2]) - max(above.bbox[0], below.bbox[0])
179
+ w = min(above.bbox[2] - above.bbox[0], below.bbox[2] - below.bbox[0])
180
+ if w <= 0 or ox < 0.35 * w:
181
+ return False
182
+ gap = below.bbox[1] - above.bbox[3]
183
+ h = above.bbox[3] - above.bbox[1]
184
+ return 0 <= gap <= 2.2 * h
185
+
186
+
187
+ def _join_paragraphs(sections: dict[str, list[TextItem]]) -> dict[str, list[TextItem]]:
188
+ """Merge vertically adjacent body-text lines in a column into one paragraph.
189
+
190
+ Only small lowercase-containing lines are considered, so headlines and
191
+ uppercase titles never merge. The joined item inherits every line's text
192
+ in reading order and is marked uncertain.
193
+ """
194
+ for region in ("left_column", "centre"):
195
+ items = sorted(sections[region], key=lambda i: (i.bbox[1], i.bbox[0]))
196
+ out: list[TextItem] = []
197
+ i = 0
198
+ while i < len(items):
199
+ group = [items[i]]
200
+ j = i + 1
201
+ while j < len(items) and _is_body_text(group[-1]) and _is_body_text(items[j]):
202
+ if not _vertical_adjacent(group[-1], items[j]):
203
+ break
204
+ group.append(items[j])
205
+ j += 1
206
+ if len(group) > 1:
207
+ value = " ".join(g.value for g in group)
208
+ box = (
209
+ min(g.bbox[0] for g in group),
210
+ min(g.bbox[1] for g in group),
211
+ max(g.bbox[2] for g in group),
212
+ max(g.bbox[3] for g in group),
213
+ )
214
+ out.append(
215
+ TextItem(
216
+ value=value,
217
+ bbox=box,
218
+ confidence=min(g.confidence for g in group),
219
+ region=region,
220
+ uncertain=any(g.uncertain for g in group),
221
+ )
222
+ )
223
+ i = j
224
+ else:
225
+ out.append(items[i])
226
+ i += 1
227
+ sections[region] = out
228
+ return sections
229
+
230
+
231
+ def _select_budget(sections: dict[str, list[TextItem]]) -> dict[str, int]:
232
+ omitted: dict[str, int] = {}
233
+ for region in REGION_ORDER:
234
+ items = sections.get(region, [])
235
+ cap = REGION_BUDGET[region]
236
+ if len(items) > cap:
237
+ for item in items[cap:]:
238
+ logger.debug("drop: budget %s (%s)", item.value, region)
239
+ sections[region] = items[:cap]
240
+ omitted[region] = len(items) - cap
241
+ return omitted
242
+
243
+
244
+ def _measure_item_colors(bgr, sections) -> None:
245
+ """Attach a measured colour to every text item (ink + local background)."""
246
+ from .core.pixels.element_color import measure_element
247
+ from .text_merge import _glyph_ink
248
+
249
+ height, width = bgr.shape[:2]
250
+ for region_items in sections.values():
251
+ for item in region_items:
252
+ try:
253
+ x0, y0, x1, y1 = (int(round(v)) for v in item.bbox)
254
+ except Exception: # noqa: BLE001
255
+ continue
256
+ x0 = max(0, min(width - 1, x0))
257
+ y0 = max(0, min(height - 1, y0))
258
+ x1 = max(x0 + 1, min(width, x1))
259
+ y1 = max(y0 + 1, min(height, y1))
260
+ crop = bgr[y0:y1, x0:x1]
261
+ if crop.size == 0:
262
+ continue
263
+ mask = None
264
+ try:
265
+ mask = _glyph_ink(crop)
266
+ except Exception: # noqa: BLE001
267
+ mask = None
268
+ try:
269
+ item.color = measure_element(
270
+ bgr, (x0, y0, x1, y1), mask if mask is not None else None
271
+ )
272
+ except Exception: # noqa: BLE001 - colour must never break the brief
273
+ item.color = None
274
+
275
+
276
+ def analyze_image(image_path: str | Path) -> AnalyzerResult:
277
+ started = time.perf_counter()
278
+ _ensure_debug_log()
279
+ path = Path(image_path)
280
+ bgr = cv2.imread(str(path), cv2.IMREAD_COLOR)
281
+ if bgr is None:
282
+ raise ValueError(f"could not decode image: {path}")
283
+ height, width = bgr.shape[:2]
284
+
285
+ # Small captures (low-DPI screenshots, scaled thumbnails) lose text to the
286
+ # OCR pass. Upscale before OCR and map the boxes back afterwards.
287
+ ocr_bgr = bgr
288
+ scale = 1.0
289
+ if max(width, height) < 900:
290
+ scale = 2.0 if max(width, height) >= 400 else 3.0
291
+ ocr_bgr = cv2.resize(
292
+ bgr, (int(width * scale), int(height * scale)), interpolation=cv2.INTER_CUBIC
293
+ )
294
+
295
+ raw, dropped = _ocr_lines(ocr_bgr, path)
296
+ if scale != 1.0:
297
+ for line in raw:
298
+ x0, y0, x1, y1 = line.bbox
299
+ line.bbox = (x0 / scale, y0 / scale, x1 / scale, y1 / scale)
300
+ if line.polygon:
301
+ line.polygon = [[x / scale, y / scale] for x, y in line.polygon]
302
+ dropped = [
303
+ (text, conf, (x0 / scale, y0 / scale, x1 / scale, y1 / scale))
304
+ for text, conf, (x0, y0, x1, y1) in dropped
305
+ ]
306
+ items = _text_items(raw)
307
+ items.extend(contrast_items(bgr, items, path))
308
+ items.extend(small_text_items(bgr, items, path))
309
+ items = split_merged_words(bgr, items)
310
+ items = split_lowercase_words(bgr, items)
311
+ items = split_body_words(bgr, items)
312
+ _assign_regions(items, width, height)
313
+ items.extend(recover_rows(bgr, items, width, height))
314
+ # CJK margin recovery is the most expensive extra pass; skip it in
315
+ # low-resource mode (local models rarely need marginal kanji).
316
+ from .config import get_settings
317
+
318
+ if not get_settings().low_resource:
319
+ items.extend(recover_cjk(bgr, items, width, height))
320
+
321
+ sections: dict[str, list[TextItem]] = {region: [] for region in REGION_ORDER}
322
+ for row in _group_rows(items):
323
+ for item in row:
324
+ sections[item.region].append(item)
325
+ sections = join_same_row(sections)
326
+ sections = _join_paragraphs(sections)
327
+ # Index reasoning runs after joins so merged rows (label + margin CJK)
328
+ # still get their expected-number flag.
329
+ for region in REGION_ORDER:
330
+ sequence_correct(sections[region])
331
+ sections = pair_numbers_across_rows(sections)
332
+ sections["bottom_bar"] = reconstruct_footer(bgr, sections["bottom_bar"])
333
+
334
+ # Measure each element's own colour (ink on its local background). The
335
+ # glyph mask separates letters from the surface behind them.
336
+ _measure_item_colors(bgr, sections)
337
+
338
+ untranscribed: dict[str, int] = {}
339
+ for text, confidence, bbox in dropped:
340
+ region = _region_of(bbox, width, height)
341
+ untranscribed[region] = untranscribed.get(region, 0) + 1
342
+ logger.debug("not transcribed (%.2f) %r in %s", confidence, text, region)
343
+
344
+ omitted = _select_budget(sections)
345
+ return AnalyzerResult(
346
+ items=items,
347
+ sections=sections,
348
+ omitted=omitted,
349
+ untranscribed=untranscribed,
350
+ width=width,
351
+ height=height,
352
+ duration_ms=(time.perf_counter() - started) * 1000,
353
+ )
vi3ion/attention.py ADDED
@@ -0,0 +1,96 @@
1
+ """Context-driven attention relook.
2
+
3
+ When the user message supplies terms (the described bug area), only OCR lines
4
+ whose text overlaps those terms are re-read at a higher scale. This focuses
5
+ compute where the user is looking and leaves the rest of the frame untouched.
6
+ Everything produced here is still evidence: re-reads are only accepted when
7
+ the recogniser is more confident, and the updated lines keep their geometry.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import re
14
+
15
+ import cv2
16
+
17
+ from .core.ocr.providers import OCRLine, get_ocr_provider
18
+
19
+ logger = logging.getLogger("vi3ion.analyzer")
20
+
21
+ MAX_BOXES = 8
22
+ REL_R_SCALES = (2, 3)
23
+ REL_PAD = 4
24
+
25
+
26
+ def _term_words(terms: list[str]) -> set[str]:
27
+ words: set[str] = set()
28
+ for term in terms:
29
+ for w in re.findall(r"[0-9a-z\u3040-\u30ff\u3400-\u9fff]+", term.lower()):
30
+ if len(w) >= 2:
31
+ words.add(w)
32
+ return words
33
+
34
+
35
+ def overlap_score(text: str, term_words: set[str]) -> float:
36
+ if not term_words or not text:
37
+ return 0.0
38
+ words = {w for w in re.findall(r"[0-9a-z\u3040-\u30ff\u3400-\u9fff]+", text.lower()) if len(w) >= 2}
39
+ if not words:
40
+ return 0.0
41
+ hit = len(words & term_words)
42
+ # strong weight for exact phrase containment
43
+ any_contain = any(tw in text.lower() for tw in term_words if len(tw) >= 4)
44
+ return (hit / len(term_words)) + (0.6 if any_contain else 0.0)
45
+
46
+
47
+ def attention_relook(
48
+ bgr,
49
+ lines: list[OCRLine],
50
+ terms: list[str],
51
+ provider=None,
52
+ max_boxes: int = MAX_BOXES,
53
+ ) -> tuple[int, float]:
54
+ """Re-read only lines overlapping the context terms; returns (updated, ms)."""
55
+ if not terms or not lines:
56
+ return 0, 0.0
57
+ provider = provider or get_ocr_provider()
58
+ if not provider.available:
59
+ return 0, 0.0
60
+ term_words = _term_words(terms)
61
+ scored = [(overlap_score(line.text, term_words), line) for line in lines]
62
+ scored.sort(key=lambda pair: -pair[0])
63
+ selected = [line for score, line in scored if score > 0.0][:max_boxes]
64
+ if not selected:
65
+ return 0, 0.0
66
+
67
+ import time
68
+
69
+ started = time.perf_counter()
70
+ updated = 0
71
+ for line in selected:
72
+ x0, y0, x1, y1 = (int(round(v)) for v in line.bbox)
73
+ crop = bgr[max(0, y0 - REL_PAD):y1 + REL_PAD, max(0, x0 - REL_PAD):x1 + REL_PAD]
74
+ if crop.size == 0:
75
+ continue
76
+ best: tuple[float, str] | None = None
77
+ for scale in REL_R_SCALES:
78
+ big = cv2.resize(crop, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
79
+ for reread in provider.recognize(big):
80
+ text = reread.text.strip()
81
+ if not text:
82
+ continue
83
+ if best is None or reread.confidence > best[0]:
84
+ best = (float(reread.confidence), text)
85
+ if best is None or best[0] <= line.confidence:
86
+ continue
87
+ # keep the reading only when it overlaps/contains the original text
88
+ old = re.sub(r"[^0-9a-z\u3040-\u30ff\u3400-\u9fff]+", "", line.text.lower())
89
+ new = re.sub(r"[^0-9a-z\u3040-\u30ff\u3400-\u9fff]+", "", best[1].lower())
90
+ if not old or not new or (old not in new and new not in old):
91
+ continue
92
+ line.text = best[1]
93
+ line.confidence = best[0]
94
+ updated += 1
95
+ logger.debug("attention relook %r -> %r @ %.2f", old, new, best[0])
96
+ return updated, (time.perf_counter() - started) * 1000.0