Spaces:
Paused
Paused
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
|