wechat-screenshot-vision-algorithm 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 (36) hide show
  1. wechat_screenshot_vision_algorithm/__init__.py +40 -0
  2. wechat_screenshot_vision_algorithm/_config.py +61 -0
  3. wechat_screenshot_vision_algorithm/algorithms/__init__.py +0 -0
  4. wechat_screenshot_vision_algorithm/algorithms/avatar_column.py +211 -0
  5. wechat_screenshot_vision_algorithm/algorithms/badge_detection.py +275 -0
  6. wechat_screenshot_vision_algorithm/algorithms/card_bbox.py +814 -0
  7. wechat_screenshot_vision_algorithm/algorithms/phash_utils.py +267 -0
  8. wechat_screenshot_vision_algorithm/algorithms/speaker_band.py +292 -0
  9. wechat_screenshot_vision_algorithm/algorithms/template_matching.py +2152 -0
  10. wechat_screenshot_vision_algorithm/algorithms/title_ocr.py +145 -0
  11. wechat_screenshot_vision_algorithm/merge/__init__.py +0 -0
  12. wechat_screenshot_vision_algorithm/merge/multipage.py +157 -0
  13. wechat_screenshot_vision_algorithm/ocr/__init__.py +0 -0
  14. wechat_screenshot_vision_algorithm/ocr/avatar_guard.py +436 -0
  15. wechat_screenshot_vision_algorithm/ocr/badge_ocr.py +234 -0
  16. wechat_screenshot_vision_algorithm/ocr/nickname_binding.py +1888 -0
  17. wechat_screenshot_vision_algorithm/ocr/text_ocr_adapter.py +627 -0
  18. wechat_screenshot_vision_algorithm/png_utils.py +87 -0
  19. wechat_screenshot_vision_algorithm/profiles/__init__.py +0 -0
  20. wechat_screenshot_vision_algorithm/profiles/android_wechat.py +53 -0
  21. wechat_screenshot_vision_algorithm/profiles/harmony_wechat.py +10 -0
  22. wechat_screenshot_vision_algorithm/profiles/ios_wechat.py +53 -0
  23. wechat_screenshot_vision_algorithm/templates/wechat/android/8.0.69/chat_back_chevron.png +0 -0
  24. wechat_screenshot_vision_algorithm/templates/wechat/android/8.0.69/chat_input_emoji_smile.png +0 -0
  25. wechat_screenshot_vision_algorithm/templates/wechat/android/8.0.69/chat_input_plus.png +0 -0
  26. wechat_screenshot_vision_algorithm/templates/wechat/android/8.0.69/chat_input_voice.png +0 -0
  27. wechat_screenshot_vision_algorithm/templates/wechat/android/8.0.69/chat_title_more_dots.png +0 -0
  28. wechat_screenshot_vision_algorithm/templates/wechat/android/8.0.69/favorite_label.png +0 -0
  29. wechat_screenshot_vision_algorithm/templates/wechat/android/8.0.69/new_messages_hint_suffix.png +0 -0
  30. wechat_screenshot_vision_algorithm/templates/wechat/android/8.0.69/unread_divider_hint.png +0 -0
  31. wechat_screenshot_vision_algorithm/templates/wechat/android/8.0.69/unread_divider_hint_v2_textonly.png +0 -0
  32. wechat_screenshot_vision_algorithm/templates/wechat/android/8.0.69/wechat_note_header.png +0 -0
  33. wechat_screenshot_vision_algorithm-0.1.0.dist-info/METADATA +423 -0
  34. wechat_screenshot_vision_algorithm-0.1.0.dist-info/RECORD +36 -0
  35. wechat_screenshot_vision_algorithm-0.1.0.dist-info/WHEEL +5 -0
  36. wechat_screenshot_vision_algorithm-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,627 @@
1
+ """Processor-side text-only OCR adapter (d2-4).
2
+
3
+ Day 2 scope (this module):
4
+
5
+ Thin, engine-agnostic wrapper around PaddleOCR's text detection +
6
+ recognition. Feeds a single PNG (plus its ``Screenshot`` metadata)
7
+ through one pass and returns a flat list of **raw** ``TextBlock``
8
+ entries — ``(text, bbox_xyxy, confidence)`` — in reading order,
9
+ with bounding boxes mapped back to the **original** image
10
+ coordinate system.
11
+
12
+ Explicit non-goals (by design, per ADR v0.3 + §2.5.2 phase split):
13
+
14
+ - **Text normalization** (NFC / full-width ↔ half-width) is
15
+ deferred to the downstream text pipeline (d5
16
+ ``resume_text_merger`` for resume pages, or the nickname OCR
17
+ minimal pipeline in d3). Running it here would duplicate
18
+ ``lite_text_normalizer.normalize_business_text`` and cross
19
+ the scripts/venv ↔ backend/venv boundary for a function
20
+ whose output shape is a simple str. The raw OCR output must
21
+ survive untouched to the phase-4 / phase-5 consumers.
22
+ - **Nickname/speaker attribution**: ADR §2.3 phase 2
23
+ (NicknameBoundaryService) reads from this adapter's output;
24
+ it is NOT the adapter's job.
25
+ - **Resume page stitching / n-gram dedup**: d5
26
+ ``resume_text_merger`` consumes a list of ``OcrPageResult``
27
+ objects across one ``resume_group_id``; this module only
28
+ returns the per-page shape.
29
+ - **PP-Structure / layout analysis**: ADR v0.3 §4.3.0 permanent
30
+ project boundary — this adapter is **text-only** by contract;
31
+ PaddleOCR's ``PaddleOCR.predict()`` (or 2.x ``.ocr()``) is
32
+ the sole entry point used.
33
+ - **Head-avatar pHash / geometry**: ADR v0.3 permanently
34
+ discarded; the adapter does not expose any head-specific
35
+ API.
36
+
37
+ Scale policy (ADR §6.4.4):
38
+
39
+ The collector stamps ``screenshots[*].ocr_scale_hint`` at
40
+ capture time — ``1600`` for ``chat_message`` type,
41
+ ``1280`` for ``resume_detail`` type. The adapter treats this
42
+ as an **upper bound on the long-edge pre-OCR**: if the raw
43
+ image's long-edge exceeds the hint, it's downscaled with
44
+ ``cv2.INTER_AREA``; otherwise the image is passed through
45
+ unresized. Detection bounding boxes coming back from PaddleOCR
46
+ are in the *resized* coordinate space and are rescaled to
47
+ original coords before leaving the adapter so that downstream
48
+ consumers (e.g. ``side_hint_ratio`` cross-check in phase 2)
49
+ can reason in the same frame as the metadata.
50
+
51
+ Engine indirection:
52
+
53
+ ``OcrEngine`` is a Protocol with a single
54
+ ``detect_and_recognize(bgr) -> list[RawOcrItem]`` method. The
55
+ real implementation ``PaddleOcrEngine`` lazy-imports PaddleOCR
56
+ so Windows collector venvs and CI containers without PaddlePaddle
57
+ installed can still import this module (they'd just fail to
58
+ construct a real engine). Unit tests inject a ``FakeOcrEngine``
59
+ (``dummy_engine`` helper in the tests) that returns synthetic
60
+ items; that keeps d2-4 test runs offline and lets d2-5's mock
61
+ coverage target the adapter independently of Paddle's model
62
+ download / CPU startup cost.
63
+
64
+ Output contract:
65
+
66
+ ``OcrPageResult`` carries enough information for the scanner /
67
+ CLI to record session-level OCR stats (wall time, block count,
68
+ char count) AND for phase 2 nickname detection to operate on the
69
+ bboxes. It is a plain dataclass (``asdict``-safe) so JSON reports
70
+ stay trivial.
71
+
72
+ References:
73
+ OCR ADR §2.5.2 phase 1 Preprocess / phase 2 NicknameBoundary
74
+ OCR ADR §6.4.4 ACTION_TYPE_MAP (ocr_scale_hint canonical values)
75
+ OCR ADR §4.3.0 project vision-layer boundary (v0.3)
76
+ backend/app/business/wx_match_business/paddle_ocr_subprocess.py
77
+ (API 冒烟 / 消费者预加载用的子进程批 OCR;与本 adapter 的 Paddle 3.x
78
+ ``predict`` 用法对齐,但 venv 边界独立)
79
+ """
80
+
81
+ from __future__ import annotations
82
+
83
+ import logging
84
+ import time
85
+ from dataclasses import dataclass, field
86
+ from pathlib import Path
87
+ from typing import TYPE_CHECKING, Callable, Optional, Protocol, runtime_checkable
88
+
89
+ from collector_phone_android_contract import Screenshot
90
+
91
+ if TYPE_CHECKING: # avoid a hard numpy import at module-load time
92
+ import numpy as np
93
+
94
+ LOG = logging.getLogger("wx_processor.text_ocr_adapter")
95
+
96
+ #: Minimum confidence kept after PaddleOCR inference(PRD §6 块级 + 与 §11 侧车 0.7 口径一致)。
97
+ #: 与 ``WxMatchSettings.wx_match_ocr_confidence_threshold``、``paddle_ocr_subprocess`` 对齐。
98
+ DEFAULT_CONFIDENCE_THRESHOLD = 0.7
99
+
100
+ #: Reading-order tie-break tolerance: two bboxes whose y1 differs by
101
+ #: less than this many px are treated as "same row" and then sorted
102
+ #: by x1. Intent: match how a human reads chat bubbles where two
103
+ #: adjacent messages sometimes have slightly offset top pixels.
104
+ READING_ORDER_ROW_TOLERANCE_PX = 6
105
+
106
+ #: 相邻 OCR 块若垂直间距超过该值,视为新段落(PRD §6 段落号;与行号独立)。
107
+ PARAGRAPH_BREAK_MIN_GAP_PX = 32
108
+
109
+
110
+ # ============================================================================
111
+ # Raw engine I/O
112
+ # ============================================================================
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class RawOcrItem:
117
+ """One raw detection returned by an engine before filtering/mapping.
118
+
119
+ Attributes:
120
+ text: recognized text, unmodified.
121
+ bbox_quad: four (x, y) corners in the **resized** image's
122
+ coordinate space; PaddleOCR gives a clockwise quad
123
+ starting from top-left, but the adapter does NOT rely
124
+ on the ordering — it axis-aligns the quad via
125
+ ``quad_to_xyxy``.
126
+ confidence: scalar in ``[0, 1]``.
127
+ """
128
+
129
+ text: str
130
+ bbox_quad: tuple[tuple[float, float], ...]
131
+ confidence: float
132
+
133
+
134
+ @runtime_checkable
135
+ class OcrEngine(Protocol):
136
+ """Minimal engine contract: take BGR image, return raw items."""
137
+
138
+ name: str
139
+
140
+ def detect_and_recognize(self, image_bgr: "np.ndarray") -> list[RawOcrItem]:
141
+ """Run text detection + recognition.
142
+
143
+ Implementations MUST NOT resize the image themselves — the
144
+ adapter has already applied ``ocr_scale_hint`` when calling
145
+ this method, and internal resize would break the bbox
146
+ back-projection contract.
147
+ """
148
+ ...
149
+
150
+
151
+ # ============================================================================
152
+ # Adapter output shape
153
+ # ============================================================================
154
+
155
+
156
+ @dataclass
157
+ class TextBlock:
158
+ """Filtered, axis-aligned block in **original** image coords.
159
+
160
+ ``bbox_xyxy`` is ``(x1, y1, x2, y2)`` with ``x1 <= x2`` and
161
+ ``y1 <= y2``. Coordinates are integers (rounded) because
162
+ downstream consumers always treat bboxes as pixel indices.
163
+
164
+ ``line_read_index``:本页阅读序下标(PRD §6 行/块序;1-based)。
165
+ ``paragraph_read_index``:段落序(按垂直大间断分段;1-based)。
166
+ """
167
+
168
+ text: str
169
+ bbox_xyxy: tuple[int, int, int, int]
170
+ confidence: float
171
+ line_read_index: int = 0
172
+ paragraph_read_index: int = 0
173
+
174
+
175
+ @dataclass
176
+ class OcrPageResult:
177
+ """Per-screenshot OCR output the scanner/CLI can stash directly.
178
+
179
+ ``text_blocks`` is already sorted in reading order; ``raw_full_text``
180
+ is the simple newline-join in that same order. ``raw_full_text``
181
+ is *raw* — callers must normalize themselves (see module docstring
182
+ "Explicit non-goals").
183
+ """
184
+
185
+ screenshot_id: str
186
+ scale_hint: int
187
+ original_size: tuple[int, int]
188
+ processed_size: tuple[int, int]
189
+ resized_applied: bool
190
+ text_blocks: list[TextBlock]
191
+ raw_full_text: str
192
+ engine_name: str
193
+ wall_ms: float
194
+ filtered_out_count: int = 0
195
+ confidence_threshold: float = DEFAULT_CONFIDENCE_THRESHOLD
196
+ #: If non-empty, the adapter hit a soft failure (e.g. image decoded
197
+ #: to ``None``) and returned an empty ``text_blocks``. The scanner
198
+ #: uses this to distinguish "really no text" from "could not read
199
+ #: the file" without making it a hard error.
200
+ soft_error: Optional[str] = None
201
+
202
+
203
+ # ============================================================================
204
+ # Errors
205
+ # ============================================================================
206
+
207
+
208
+ class TextOcrAdapterError(Exception):
209
+ """Raised when the adapter cannot produce a usable ``OcrPageResult``.
210
+
211
+ Reserved for programmer / environment errors (e.g. engine not
212
+ installed when expected, PNG file unreadable). Run-time OCR
213
+ "zero hits" or "low-confidence filtered all" scenarios return a
214
+ valid ``OcrPageResult`` with empty ``text_blocks`` instead, so
215
+ the scanner's terminal-status logic stays simple.
216
+ """
217
+
218
+ def __init__(self, message: str, *, error_code: str) -> None:
219
+ super().__init__(message)
220
+ self.error_code = error_code
221
+
222
+
223
+ # ============================================================================
224
+ # Geometry helpers (pure, easy to unit-test)
225
+ # ============================================================================
226
+
227
+
228
+ def resize_long_edge_to(
229
+ image_bgr: "np.ndarray",
230
+ long_edge: int,
231
+ ) -> tuple["np.ndarray", float]:
232
+ """Downscale to ``long_edge``; no-op if already smaller.
233
+
234
+ Returns ``(resized_image, scale_ratio)`` where ``scale_ratio`` is
235
+ ``new_long_edge / old_long_edge`` (``1.0`` if no resize). The
236
+ caller uses ``scale_ratio`` to map bboxes back to original
237
+ coordinates.
238
+ """
239
+ import cv2
240
+
241
+ h, w = image_bgr.shape[:2]
242
+ original_long = max(h, w)
243
+ if original_long <= long_edge:
244
+ return image_bgr, 1.0
245
+ ratio = long_edge / original_long
246
+ new_w = max(1, int(round(w * ratio)))
247
+ new_h = max(1, int(round(h * ratio)))
248
+ return (
249
+ cv2.resize(image_bgr, (new_w, new_h), interpolation=cv2.INTER_AREA),
250
+ ratio,
251
+ )
252
+
253
+
254
+ def quad_to_xyxy(
255
+ quad: tuple[tuple[float, float], ...],
256
+ ) -> tuple[float, float, float, float]:
257
+ """Axis-align a 4-corner polygon to a ``(x1, y1, x2, y2)`` bbox.
258
+
259
+ Uses the quad's min/max envelope. This is deliberate: the
260
+ downstream ``NicknameBoundaryService`` reasons in axis-aligned
261
+ boxes (font-size ratio / y-gap / left-right alignment), so
262
+ preserving the rotated quad would pay a cost with no consumer.
263
+ """
264
+ xs = [p[0] for p in quad]
265
+ ys = [p[1] for p in quad]
266
+ return (min(xs), min(ys), max(xs), max(ys))
267
+
268
+
269
+ def scale_bbox(
270
+ bbox_xyxy: tuple[float, float, float, float],
271
+ inv_ratio: float,
272
+ ) -> tuple[int, int, int, int]:
273
+ """Scale ``xyxy`` by ``inv_ratio`` and round to int pixels.
274
+
275
+ ``inv_ratio`` is ``1 / scale_ratio`` returned by
276
+ ``resize_long_edge_to`` — i.e. "resized → original" direction.
277
+ """
278
+ x1, y1, x2, y2 = bbox_xyxy
279
+ return (
280
+ max(0, int(round(x1 * inv_ratio))),
281
+ max(0, int(round(y1 * inv_ratio))),
282
+ max(0, int(round(x2 * inv_ratio))),
283
+ max(0, int(round(y2 * inv_ratio))),
284
+ )
285
+
286
+
287
+ def assign_paragraph_read_index(blocks: list[TextBlock]) -> list[TextBlock]:
288
+ """为已排序块赋 ``paragraph_read_index``(垂直间隙过大则新开段落)。"""
289
+ if not blocks:
290
+ return blocks
291
+ out: list[TextBlock] = []
292
+ para = 1
293
+ prev_bottom: Optional[int] = None
294
+ for b in blocks:
295
+ y1 = b.bbox_xyxy[1]
296
+ if prev_bottom is not None and (y1 - prev_bottom) > PARAGRAPH_BREAK_MIN_GAP_PX:
297
+ para += 1
298
+ out.append(
299
+ TextBlock(
300
+ text=b.text,
301
+ bbox_xyxy=b.bbox_xyxy,
302
+ confidence=b.confidence,
303
+ line_read_index=b.line_read_index,
304
+ paragraph_read_index=para,
305
+ )
306
+ )
307
+ prev_bottom = b.bbox_xyxy[3]
308
+ return out
309
+
310
+
311
+ def sort_reading_order(blocks: list[TextBlock]) -> list[TextBlock]:
312
+ """Top-to-bottom, left-to-right with a small row tolerance.
313
+
314
+ We bucket ``y1`` into row bands of
315
+ ``READING_ORDER_ROW_TOLERANCE_PX`` so that two bboxes sharing a
316
+ row but whose detected tops differ by a few pixels still sort
317
+ left-to-right within the row. Pure-Python, O(n log n), stable.
318
+ """
319
+
320
+ def key(b: TextBlock) -> tuple[int, int]:
321
+ y1 = b.bbox_xyxy[1]
322
+ row = y1 // READING_ORDER_ROW_TOLERANCE_PX
323
+ return (row, b.bbox_xyxy[0])
324
+
325
+ return sorted(blocks, key=key)
326
+
327
+
328
+ # ============================================================================
329
+ # Real PaddleOCR engine (lazy-loaded)
330
+ # ============================================================================
331
+
332
+
333
+ class PaddleOcrEngine:
334
+ """Real PaddleOCR 3.x engine, text-only.
335
+
336
+ Construction is expensive (model download on first run, ~30 s
337
+ startup even on cached models), so callers are expected to
338
+ instantiate **once** and reuse across screenshots within a
339
+ session.
340
+
341
+ ``use_server_model=False`` (default) matches the ADR v0.3
342
+ deployment: PP-OCRv5 *mobile* on CPU. The ``server`` variant is
343
+ reserved for GPU rollout later.
344
+
345
+ ``rec_batch`` defaults to 6 mirroring
346
+ ``app.core.config.Settings.paddle_ocr_rec_batch`` — changing it
347
+ only affects throughput, not the adapter's output shape.
348
+ """
349
+
350
+ name = "paddleocr_v3_text_only"
351
+
352
+ def __init__(
353
+ self,
354
+ *,
355
+ use_server_model: bool = False,
356
+ rec_batch: int = 6,
357
+ max_long_edge_pre_ocr: int = 2048,
358
+ ) -> None:
359
+ self._use_server_model = use_server_model
360
+ self._rec_batch = rec_batch
361
+ # Give PaddleOCR's internal det_limit a ceiling generous
362
+ # enough to never re-resize images the adapter has already
363
+ # sized to ``ocr_scale_hint``. The adapter is the single
364
+ # source of truth for scale policy.
365
+ self._max_long_edge_pre_ocr = max_long_edge_pre_ocr
366
+ self._ocr: object = None
367
+
368
+ def _ensure_engine(self) -> object:
369
+ if self._ocr is not None:
370
+ return self._ocr
371
+ from paddleocr import PaddleOCR # noqa: PLC0415 — lazy
372
+ kwargs: dict[str, object] = {
373
+ "use_textline_orientation": True,
374
+ # Doc preprocessing (orientation classify + UVDoc unwarping)
375
+ # defaults to ON in PaddleOCR 3.x. UVDoc "rectifies" flat phone
376
+ # screenshots, warping bbox coordinates non-linearly along y
377
+ # (measured -25px top → +90px bottom on 720x1612), which breaks
378
+ # card/nickname spatial binding downstream. Screenshots are
379
+ # always flat and upright — disable both.
380
+ "use_doc_orientation_classify": False,
381
+ "use_doc_unwarping": False,
382
+ "lang": "ch",
383
+ "device": "cpu",
384
+ "text_recognition_batch_size": self._rec_batch,
385
+ "text_det_limit_type": "max",
386
+ "text_det_limit_side_len": self._max_long_edge_pre_ocr,
387
+ }
388
+ if not self._use_server_model:
389
+ kwargs["text_detection_model_name"] = "PP-OCRv5_mobile_det"
390
+ kwargs["text_recognition_model_name"] = "PP-OCRv5_mobile_rec"
391
+ self._ocr = PaddleOCR(**kwargs)
392
+ return self._ocr
393
+
394
+ def detect_and_recognize(self, image_bgr: "np.ndarray") -> list[RawOcrItem]:
395
+ ocr = self._ensure_engine()
396
+ results = list(ocr.predict([image_bgr])) # type: ignore[attr-defined]
397
+ if not results:
398
+ return []
399
+ r = results[0]
400
+ texts: list[str] = list(r.get("rec_texts", []) or [])
401
+ scores: list[float] = list(r.get("rec_scores", []) or [])
402
+ polys_raw = r.get("rec_polys") or r.get("dt_polys") or []
403
+ items: list[RawOcrItem] = []
404
+ for i, text in enumerate(texts):
405
+ score = float(scores[i]) if i < len(scores) else 0.0
406
+ poly = polys_raw[i] if i < len(polys_raw) else None
407
+ if poly is None:
408
+ continue
409
+ quad = _normalize_poly(poly)
410
+ if quad is None:
411
+ continue
412
+ items.append(RawOcrItem(text=text, bbox_quad=quad, confidence=score))
413
+ return items
414
+
415
+
416
+ def _normalize_poly(poly: object) -> Optional[tuple[tuple[float, float], ...]]:
417
+ """Coerce numpy / list-of-lists poly into a plain tuple of (x, y)."""
418
+ try:
419
+ iterable = list(poly) # type: ignore[arg-type]
420
+ except TypeError:
421
+ return None
422
+ corners: list[tuple[float, float]] = []
423
+ for pt in iterable:
424
+ try:
425
+ x, y = float(pt[0]), float(pt[1])
426
+ except (TypeError, IndexError, ValueError):
427
+ return None
428
+ corners.append((x, y))
429
+ if len(corners) < 3:
430
+ return None
431
+ return tuple(corners)
432
+
433
+
434
+ # ============================================================================
435
+ # Adapter
436
+ # ============================================================================
437
+
438
+
439
+ class TextOcrAdapter:
440
+ """Process one screenshot through an injected OCR engine.
441
+
442
+ Single-session usage pattern (scanner/CLI):
443
+
444
+ engine = PaddleOcrEngine()
445
+ adapter = TextOcrAdapter(engine=engine)
446
+ for shot in metadata.screenshots:
447
+ png = resolver.resolve(session_dir, shot)
448
+ result = adapter.process_page(png, shot)
449
+ # hand result to phase-2 / phase-5 consumers
450
+ """
451
+
452
+ def __init__(
453
+ self,
454
+ engine: OcrEngine,
455
+ *,
456
+ confidence_threshold: float = DEFAULT_CONFIDENCE_THRESHOLD,
457
+ image_loader: Optional[Callable[[Path], "np.ndarray"]] = None,
458
+ ) -> None:
459
+ self.engine = engine
460
+ self.confidence_threshold = confidence_threshold
461
+ self._load_image = image_loader or _default_image_loader
462
+
463
+ def process_page(
464
+ self,
465
+ png_path: Path,
466
+ screenshot: Screenshot,
467
+ ) -> OcrPageResult:
468
+ """Run one screenshot through the OCR engine.
469
+
470
+ Raises:
471
+ TextOcrAdapterError: when the PNG file cannot be decoded
472
+ at all (propagated error_code=``ocr_image_decode_error``
473
+ so the scanner can mark the session row ``error``
474
+ with that code — this is a **non-retryable** failure
475
+ because redoing OCR with the same broken bytes won't
476
+ help).
477
+
478
+ Zero-hit / all-low-confidence cases are NOT raised — they
479
+ return a valid ``OcrPageResult`` with empty ``text_blocks``
480
+ and ``filtered_out_count`` set so Admin can distinguish.
481
+ """
482
+ t0 = time.perf_counter()
483
+ try:
484
+ image_bgr = self._load_image(png_path)
485
+ except FileNotFoundError:
486
+ raise
487
+ except Exception as e: # decode failure surfaces as adapter error
488
+ raise TextOcrAdapterError(
489
+ f"failed to decode image at {png_path!s}: {e!r}",
490
+ error_code="ocr_image_decode_error",
491
+ ) from e
492
+ if image_bgr is None:
493
+ raise TextOcrAdapterError(
494
+ f"cv2.imdecode returned None for {png_path!s}",
495
+ error_code="ocr_image_decode_error",
496
+ )
497
+
498
+ original_h, original_w = image_bgr.shape[:2]
499
+ resized_bgr, scale_ratio = resize_long_edge_to(
500
+ image_bgr, screenshot.ocr_scale_hint
501
+ )
502
+ processed_h, processed_w = resized_bgr.shape[:2]
503
+ resized_applied = scale_ratio != 1.0
504
+
505
+ try:
506
+ raw_items = self.engine.detect_and_recognize(resized_bgr)
507
+ except Exception as e:
508
+ LOG.warning(
509
+ "processor.text_ocr_engine_error",
510
+ extra={
511
+ "screenshot_id": screenshot.screenshot_id,
512
+ "engine": getattr(self.engine, "name", type(self.engine).__name__),
513
+ "error": str(e),
514
+ },
515
+ )
516
+ wall_ms = (time.perf_counter() - t0) * 1000.0
517
+ return OcrPageResult(
518
+ screenshot_id=screenshot.screenshot_id,
519
+ scale_hint=screenshot.ocr_scale_hint,
520
+ original_size=(original_w, original_h),
521
+ processed_size=(processed_w, processed_h),
522
+ resized_applied=resized_applied,
523
+ text_blocks=[],
524
+ raw_full_text="",
525
+ engine_name=getattr(self.engine, "name", type(self.engine).__name__),
526
+ wall_ms=wall_ms,
527
+ filtered_out_count=0,
528
+ confidence_threshold=self.confidence_threshold,
529
+ soft_error=f"engine_error:{type(e).__name__}",
530
+ )
531
+
532
+ inv_ratio = 1.0 / scale_ratio if scale_ratio != 0 else 1.0
533
+ blocks: list[TextBlock] = []
534
+ filtered_out = 0
535
+ for item in raw_items:
536
+ if item.confidence < self.confidence_threshold:
537
+ filtered_out += 1
538
+ continue
539
+ axis_aligned = quad_to_xyxy(item.bbox_quad)
540
+ bbox_original = scale_bbox(axis_aligned, inv_ratio)
541
+ # Clamp to original image bounds — shields downstream
542
+ # consumers from off-by-one rounding overshoot.
543
+ x1, y1, x2, y2 = bbox_original
544
+ x1 = min(max(0, x1), original_w)
545
+ y1 = min(max(0, y1), original_h)
546
+ x2 = min(max(0, x2), original_w)
547
+ y2 = min(max(0, y2), original_h)
548
+ if x2 <= x1 or y2 <= y1:
549
+ filtered_out += 1
550
+ continue
551
+ blocks.append(
552
+ TextBlock(
553
+ text=item.text,
554
+ bbox_xyxy=(x1, y1, x2, y2),
555
+ confidence=item.confidence,
556
+ line_read_index=0,
557
+ )
558
+ )
559
+
560
+ blocks = sort_reading_order(blocks)
561
+ blocks = [
562
+ TextBlock(
563
+ text=b.text,
564
+ bbox_xyxy=b.bbox_xyxy,
565
+ confidence=b.confidence,
566
+ line_read_index=idx,
567
+ paragraph_read_index=0,
568
+ )
569
+ for idx, b in enumerate(blocks, start=1)
570
+ ]
571
+ blocks = assign_paragraph_read_index(blocks)
572
+ raw_full_text = "\n".join(b.text for b in blocks)
573
+ wall_ms = (time.perf_counter() - t0) * 1000.0
574
+
575
+ return OcrPageResult(
576
+ screenshot_id=screenshot.screenshot_id,
577
+ scale_hint=screenshot.ocr_scale_hint,
578
+ original_size=(original_w, original_h),
579
+ processed_size=(processed_w, processed_h),
580
+ resized_applied=resized_applied,
581
+ text_blocks=blocks,
582
+ raw_full_text=raw_full_text,
583
+ engine_name=getattr(self.engine, "name", type(self.engine).__name__),
584
+ wall_ms=wall_ms,
585
+ filtered_out_count=filtered_out,
586
+ confidence_threshold=self.confidence_threshold,
587
+ )
588
+
589
+
590
+ # ============================================================================
591
+ # Default image loader
592
+ # ============================================================================
593
+
594
+
595
+ def _default_image_loader(png_path: Path) -> "np.ndarray":
596
+ """Load a PNG file as BGR ``np.ndarray`` via cv2.imdecode.
597
+
598
+ Uses ``imdecode`` (not ``imread``) so non-ASCII paths on Windows
599
+ still work — ``imread`` chokes on Unicode paths.
600
+ """
601
+ import cv2
602
+ import numpy as np
603
+
604
+ if not png_path.exists():
605
+ raise FileNotFoundError(f"PNG not found: {png_path!s}")
606
+ data = png_path.read_bytes()
607
+ arr = np.frombuffer(data, dtype=np.uint8)
608
+ img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
609
+ return img
610
+
611
+
612
+ __all__ = [
613
+ "DEFAULT_CONFIDENCE_THRESHOLD",
614
+ "READING_ORDER_ROW_TOLERANCE_PX",
615
+ "OcrEngine",
616
+ "OcrPageResult",
617
+ "PaddleOcrEngine",
618
+ "RawOcrItem",
619
+ "TextBlock",
620
+ "TextOcrAdapter",
621
+ "TextOcrAdapterError",
622
+ "quad_to_xyxy",
623
+ "resize_long_edge_to",
624
+ "scale_bbox",
625
+ "sort_reading_order",
626
+ "assign_paragraph_read_index",
627
+ ]