File size: 9,318 Bytes
fdcd731
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dbd0f80
fdcd731
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dbd0f80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fdcd731
dbd0f80
fdcd731
 
 
 
 
 
dbd0f80
 
fdcd731
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dbd0f80
fdcd731
 
 
 
dbd0f80
 
 
fdcd731
dbd0f80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fdcd731
dbd0f80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fdcd731
 
 
dbd0f80
 
 
fdcd731
 
6f8dc60
fdcd731
 
 
 
6f8dc60
dbd0f80
 
 
 
fdcd731
dbd0f80
 
fdcd731
 
 
 
 
 
 
 
dbd0f80
 
 
 
 
 
 
 
 
fdcd731
 
dbd0f80
 
6f8dc60
dbd0f80
6f8dc60
fdcd731
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
"""Manga TEXT-BLOCK detector — the grouping authority for vertical text.

Model: ``Kiuyha/Manga-Bubble-YOLO`` (YOLO26, 2025, Apache-2.0) — trained on
Manga109 + MangaDex pages with Magiv2-assisted annotations to detect text
bubbles / text regions in manga.  The YOLO26 head is END-TO-END: the ONNX
graph already performs score filtering + de-duplication and emits up to 300
final detections of shape ``(1, 300, 6)`` = ``[x1, y1, x2, y2, conf, cls]``
in input-pixel space.  No NMS post-processing is needed.

Why: Lens gives clean paragraph groups for horizontal text but shatters
vertical CJK into per-column fragments with no set boundaries.  Pure
geometry cannot always reconstruct the sets (stair layouts, offset columns).
This detector was *trained* to see text blocks the way a reader does, so its
boxes decide which columns belong together.

Safety: completely optional.  If onnxruntime or the model file is missing,
``detect_text_blocks`` returns ``[]`` and grouping falls back to geometry —
behaviour is byte-identical to the system without this module.
"""

from __future__ import annotations

import os
import queue as _queue_mod
import threading
import time
from typing import Any

import numpy as np
from PIL import Image

from backend.config import settings
from backend.log import dbg, event

Box = tuple[float, float, float, float]

_INPUT_SIZE = 1280
_CONF_THRESH = 0.30

# ---------------------------------------------------------------------------
# Session pool — multiple independent ONNX sessions so workers can run
# inference in parallel instead of serialising on a single lock.
#
# With pool_size=4 and 12 concurrent workers, the maximum blocks_lock_ms
# (time a job spends waiting for a free session) is bounded at
# (pool_size−1) x ~1.3 s ≈ 3.9 s, instead of (12−1) x 1.3 s ≈ 14 s.
#
# Memory cost per session: ~25 MB (yolo26s) / ~7 MB (yolo26n) — negligible.
# ---------------------------------------------------------------------------
_pool: _queue_mod.Queue[Any] = _queue_mod.Queue()
_pool_count = 0          # sessions successfully loaded
_pool_ready = False      # init has been attempted (success or failure)
_session_failed = False  # permanent: onnxruntime missing / corrupt model
_next_download_retry = 0.0
_DOWNLOAD_RETRY_SEC = 300.0
_init_lock = threading.Lock()


def model_path() -> str:
    return (settings.textblock_model_path or "").strip()


def _download_model() -> bool:
    """Stream-download the ONNX weights (best-effort, never fatal)."""
    path = model_path()
    if not path:
        return False
    if os.path.exists(path) and os.path.getsize(path) > 1_000_000:
        return True
    url = (settings.textblock_model_url or "").strip()
    if not url:
        return False
    try:
        import httpx

        os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
        tmp = path + ".part"
        with httpx.Client(timeout=300, follow_redirects=True) as client:
            with client.stream("GET", url) as r:
                r.raise_for_status()
                with open(tmp, "wb") as f:
                    for chunk in r.iter_bytes(1 << 20):
                        f.write(chunk)
        os.replace(tmp, path)
        event("textblocks.model.downloaded", {"path": path, "size": os.path.getsize(path)})
        return True
    except Exception as e:  # noqa: BLE001
        event("textblocks.model.download_failed", {"error": str(e)[:200]}, ok=False)
        return False


def _init_pool() -> None:
    """Load pool_size ONNX sessions into _pool. Called once, under _init_lock."""
    global _pool_count, _pool_ready, _session_failed, _next_download_retry

    path = model_path()
    if not path:
        _session_failed = True
        _pool_ready = True
        return

    if not (os.path.exists(path) and os.path.getsize(path) > 1_000_000):
        now = time.time()
        if now < _next_download_retry:
            _pool_ready = True
            return
        if not _download_model():
            _next_download_retry = now + _DOWNLOAD_RETRY_SEC
            _pool_ready = True
            return

    try:
        import onnxruntime as ort

        n = max(1, settings.textblock_pool_size)
        for _ in range(n):
            sess = ort.InferenceSession(path, providers=["CPUExecutionProvider"])
            _pool.put(sess)
        _pool_count = n
        event("textblocks.model.loaded", {"path": path, "sessions": n})
    except Exception as e:  # noqa: BLE001
        _session_failed = True
        event("textblocks.model.load_failed", {"error": str(e)[:200]}, ok=False)
    finally:
        _pool_ready = True


def _ensure_pool() -> None:
    """Trigger pool initialisation on first use (idempotent)."""
    if _pool_ready:
        return
    with _init_lock:
        if not _pool_ready:
            _init_pool()


def ensure_model() -> bool:
    """Download + load the session pool (called from warmup, best-effort)."""
    _ensure_pool()
    return _pool_count > 0


def available() -> bool:
    """True once the pool has at least one loaded session."""
    _ensure_pool()
    return not _session_failed and _pool_count > 0


def detect_text_blocks(img: Image.Image, timings: dict | None = None) -> list[Box]:
    """Detect text-block boxes on a page. Returns [] when the model is off.

    Preprocess mirrors the model card: plain resize to 1280x1280, RGB,
    CHW, /255.  Output boxes are mapped back with the inverse scale.

    ``timings`` (optional) is filled with ``lock_ms`` (time waiting for a
    free session from the pool) and ``infer_ms`` (this job's own inference).
    With pool_size=4, max wait ≈ (pool_size−1)xinfer_ms instead of
    (workers−1)xinfer_ms.
    """
    _ensure_pool()
    if _session_failed or _pool_count == 0:
        return []
    try:
        t0 = time.perf_counter()
        W, H = img.size
        rgb = img.convert("RGB").resize((_INPUT_SIZE, _INPUT_SIZE), Image.BILINEAR)
        arr = np.asarray(rgb, dtype=np.float32) / 255.0
        arr = np.expand_dims(arr.transpose(2, 0, 1), 0)  # 1x3xHxW

        # Grab a session from the pool (blocks until one is free).
        t_wait = time.perf_counter()
        try:
            session = _pool.get(timeout=60.0)
        except _queue_mod.Empty:
            event("textblocks.pool_timeout", {}, ok=False)
            return []
        t_infer = time.perf_counter()
        try:
            input_name = session.get_inputs()[0].name
            out = session.run(None, {input_name: arr})[0]
        finally:
            _pool.put(session)  # always return, even on exception
        if timings is not None:
            timings["lock_ms"] = round((t_infer - t_wait) * 1000, 1)
            timings["infer_ms"] = round((time.perf_counter() - t_infer) * 1000, 1)

        det = np.asarray(out)
        det = det.reshape(-1, det.shape[-1])  # (300, 6)
        sx, sy = W / float(_INPUT_SIZE), H / float(_INPUT_SIZE)
        boxes: list[Box] = []
        for row in det:
            if len(row) < 6 or float(row[4]) < _CONF_THRESH:
                continue
            x1, y1, x2, y2 = (float(v) for v in row[:4])
            x1, x2 = sorted((max(0.0, x1 * sx), min(float(W), x2 * sx)))
            y1, y2 = sorted((max(0.0, y1 * sy), min(float(H), y2 * sy)))
            if x2 - x1 >= 4 and y2 - y1 >= 4:
                boxes.append((x1, y1, x2, y2))
        dbg("textblocks.detect", {
            "boxes": len(boxes),
            "ms": round((time.perf_counter() - t0) * 1000, 1),
        })
        return boxes
    except Exception as e:  # noqa: BLE001 - never break the pipeline
        event("textblocks.detect_failed", {"error": str(e)[:200]}, ok=False)
        return []


def _para_rect(para: dict) -> Box | None:
    bp = para.get("bounds_px")
    if isinstance(bp, (list, tuple)) and len(bp) == 4:
        x1, y1, x2, y2 = (float(v) for v in bp)
        if x2 > x1 and y2 > y1:
            return (x1, y1, x2, y2)
    return None


def annotate_paragraph_blocks(tree: dict | None, blocks: list[Box]) -> int:
    """Stamp each paragraph with the index of its best text block.

    Assignment = highest IoU-like score, requiring the block to cover at
    least half of the paragraph.  Paragraphs with no qualifying block carry
    no annotation and keep the geometric grouping path.
    Returns the number of annotated paragraphs.
    """
    if not isinstance(tree, dict) or not blocks:
        return 0
    n = 0
    for para in tree.get("paragraphs") or []:
        if not isinstance(para, dict):
            continue
        pr = _para_rect(para)
        if pr is None:
            continue
        px1, py1, px2, py2 = pr
        p_area = max(1.0, (px2 - px1) * (py2 - py1))
        best_i, best_score = None, 0.0
        for i, (bx1, by1, bx2, by2) in enumerate(blocks):
            ix = max(0.0, min(px2, bx2) - max(px1, bx1))
            iy = max(0.0, min(py2, by2) - max(py1, by1))
            inter = ix * iy
            if inter / p_area < 0.5:
                continue  # block must cover most of the paragraph
            union = p_area + (bx2 - bx1) * (by2 - by1) - inter
            score = inter / max(1.0, union)
            if score > best_score:
                best_i, best_score = i, score
        if best_i is not None:
            para["_tb_block"] = best_i
            n += 1
    return n