TextPhantom-v4.0.0 / backend /render /textblocks.py
plan291037's picture
เพิ่มประสิทธิภาพการแปล
c0a68c8
Raw
History Blame Contribute Delete
10 kB
"""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