Spaces:
Running
Running
| """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) | |
| # Divide CPU threads evenly across sessions so concurrent inference | |
| # does not over-subscribe the machine. On a 2-vCPU HF Space with | |
| # n=1 this leaves 2 threads for the single session (the proven-fast | |
| # path). With n>1 each session gets floor(cpu_count/n) >= 1 thread. | |
| cpu_count = os.cpu_count() or 2 | |
| threads_per_session = max(1, cpu_count // n) | |
| opts = ort.SessionOptions() | |
| opts.intra_op_num_threads = threads_per_session | |
| opts.inter_op_num_threads = 1 # sequential graph operators; parallel handled above | |
| for _ in range(n): | |
| sess = ort.InferenceSession( | |
| path, sess_options=opts, providers=["CPUExecutionProvider"] | |
| ) | |
| _pool.put(sess) | |
| _pool_count = n | |
| event( | |
| "textblocks.model.loaded", | |
| {"path": path, "sessions": n, "threads_each": threads_per_session}, | |
| ) | |
| 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 | |