acsaco's picture
download
raw
10.4 kB
"""
Text Detection - Dual approach:
1. Try CTD YOLO ONNX if model available and compatible (high quality)
2. Fall back to OpenCV-based detection (always works)
"""
from __future__ import annotations
import asyncio
import logging
import os
from pathlib import Path
import cv2
import numpy as np
log = logging.getLogger("detection")
_ort_session = None
_use_onnx = False
def _resolve_model_path() -> str | None:
"""Resolve the location of the comictextdetector.pt.onnx model."""
search_paths = [
# Check environment variable
os.path.join(os.environ.get("MODEL_DIR", ""), "detection", "comictextdetector.pt.onnx"),
os.path.join(os.environ.get("MODEL_DIR", ""), "comictextdetector.pt.onnx"),
# Docker volume path
"/app/models/weights/detection/comictextdetector.pt.onnx",
# Local paths relative to project root
os.path.join(os.path.dirname(__file__), "..", "..", "..", "models", "detection", "comictextdetector.pt.onnx"),
os.path.join("models", "detection", "comictextdetector.pt.onnx"),
]
for path in search_paths:
if path and os.path.exists(path):
log.info(f"Resolved detection model path: {path}")
return os.path.abspath(path)
return None
async def load_model(model_path: str = None, device: str = "cpu"):
"""Load CTD ONNX model using onnxruntime. Falls back to OpenCV on failure."""
global _ort_session, _use_onnx
if _ort_session is not None:
return
path = model_path or _resolve_model_path()
if not path:
log.warning("comictextdetector.pt.onnx model file not found. Falling back to OpenCV detection.")
_use_onnx = False
return
def _init_session():
global _ort_session, _use_onnx
try:
import onnxruntime as ort
# Select execution providers
# We force CPUExecutionProvider to save VRAM and avoid memory corruption / shape collisions with Ollama/PyTorch
providers = ['CPUExecutionProvider']
log.info(f"Initializing ONNX session for detection with providers: {providers}")
_ort_session = ort.InferenceSession(path, providers=providers)
_use_onnx = True
log.info(f"Loaded CTD ONNX model successfully on: {_ort_session.get_providers()}")
except Exception as e:
log.error(f"Failed to load ONNX model: {e}. Falling back to OpenCV.")
_ort_session = None
_use_onnx = False
await asyncio.get_event_loop().run_in_executor(None, _init_session)
async def detect(
img_rgb: np.ndarray,
detection_size: int = 1024,
text_threshold: float = 0.3,
box_threshold: float = 0.3,
device: str = "cpu",
) -> tuple[list, np.ndarray, np.ndarray]:
"""Detect text regions. Uses ONNX if loaded, otherwise OpenCV contours."""
if _use_onnx and _ort_session is not None:
try:
return await _detect_onnx(img_rgb, text_threshold, box_threshold)
except Exception as e:
log.error(f"ONNX detection failed: {e}. Falling back to OpenCV.")
return await _detect_opencv(img_rgb, box_threshold)
async def _detect_onnx(
img_rgb: np.ndarray,
text_threshold: float,
box_threshold: float,
) -> tuple[list, np.ndarray, np.ndarray]:
"""ONNX-based text and bubble detection + text segmentation."""
global _ort_session
loop = asyncio.get_event_loop()
def _run():
h, w = img_rgb.shape[:2]
# Preprocessing: resize to 1024x1024
img_resized = cv2.resize(img_rgb, (1024, 1024), interpolation=cv2.INTER_LINEAR)
img_float = img_resized.astype(np.float32) / 255.0
# HWC to CHW
img_chw = np.transpose(img_float, (2, 0, 1))
# Add batch dim
input_data = np.expand_dims(img_chw, axis=0)
# Run ONNX inference
outputs = _ort_session.run(None, {"images": input_data})
blk, seg, det = outputs # blk: [1, 64512, 7], seg: [1, 1, 1024, 1024]
# --- 1. Parse Text Box Detections (blk) ---
boxes = []
confidences = []
# Scale factors back to original image
scale_x = w / 1024.0
scale_y = h / 1024.0
# blk columns: x_center, y_center, box_w, box_h, conf, prob_class_0, prob_class_1
# Class 0: bubble, Class 1: text
detections = blk[0]
for det_row in detections:
obj_conf = det_row[4]
if obj_conf < box_threshold:
continue
# Class score for text (class 1 in CTD ONNX)
text_score = obj_conf * det_row[6]
if text_score < text_threshold:
continue
cx, cy, bw, bh = det_row[0], det_row[1], det_row[2], det_row[3]
# Map back to original image coordinates
x_min = int((cx - bw / 2.0) * scale_x)
y_min = int((cy - bh / 2.0) * scale_y)
x_max = int((cx + bw / 2.0) * scale_x)
y_max = int((cy + bh / 2.0) * scale_y)
# Clip values
x_min = max(0, min(w - 1, x_min))
y_min = max(0, min(h - 1, y_min))
x_max = max(0, min(w - 1, x_max))
y_max = max(0, min(h - 1, y_max))
bw_orig = x_max - x_min
bh_orig = y_max - y_min
if bw_orig > 4 and bh_orig > 4:
boxes.append([x_min, y_min, bw_orig, bh_orig])
confidences.append(float(text_score))
# Apply Non-Maximum Suppression (NMS)
textlines = []
mask_raw = np.zeros((h, w), dtype=np.uint8)
if boxes:
# cv2.dnn.NMSBoxes returns indices of the kept boxes
nms_indices = cv2.dnn.NMSBoxes(boxes, confidences, score_threshold=text_threshold, nms_threshold=0.3)
# NMSBoxes return type can vary by OpenCV version
if len(nms_indices) > 0:
indices = nms_indices.flatten() if hasattr(nms_indices, "flatten") else nms_indices
for idx_i, idx in enumerate(indices):
x, y, bw_box, bh_box = boxes[idx]
bbox = [x, y, x + bw_box, y + bh_box]
textlines.append({
"index": idx_i,
"bbox": bbox,
"polygon": [
[bbox[0], bbox[1]],
[bbox[2], bbox[1]],
[bbox[2], bbox[3]],
[bbox[0], bbox[3]]
],
"confidence": confidences[idx]
})
# Only use character-level segmentation for inpainting to prevent boxy leaks outside bubble borders
# cv2.rectangle(mask_raw, (x, y), (x + bw_box, y + bh_box), 255, -1)
# --- 2. Parse Text Segmentation Map (seg) ---
seg_prob = seg[0, 0, :, :] # 1024x1024
# Resize to original size
seg_prob_resized = cv2.resize(seg_prob, (w, h), interpolation=cv2.INTER_LINEAR)
# Threshold to create binary mask
seg_mask = (seg_prob_resized > 0.25).astype(np.uint8) * 255
# Merge bounding box raw mask and segmentation mask to make it extra robust
mask_raw = cv2.bitwise_or(mask_raw, seg_mask)
# Dilate mask for optimal inpainting boundary coverage
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask = cv2.dilate(mask_raw, kernel, iterations=4)
return textlines, mask_raw, mask
return await loop.run_in_executor(None, _run)
async def _detect_opencv(img_rgb: np.ndarray, threshold: float) -> tuple[list, np.ndarray, np.ndarray]:
"""OpenCV-based text detection using contour analysis (CPU fallback)."""
loop = asyncio.get_event_loop()
def _run():
h, w = img_rgb.shape[:2]
gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)
# Adaptive threshold to find text-like regions
binary = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 15, 10)
# Morphological operations to connect text components
kernel_h = cv2.getStructuringElement(cv2.MORPH_RECT, (25, 1))
kernel_v = cv2.getStructuringElement(cv2.MORPH_RECT, (1, 25))
dilated_h = cv2.dilate(binary, kernel_h, iterations=2)
dilated_v = cv2.dilate(binary, kernel_v, iterations=2)
combined = cv2.bitwise_or(dilated_h, dilated_v)
# Close gaps
kernel_close = cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7))
combined = cv2.morphologyEx(combined, cv2.MORPH_CLOSE, kernel_close, iterations=3)
# Find contours
contours, _ = cv2.findContours(combined, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
textlines = []
mask_raw = np.zeros((h, w), dtype=np.uint8)
for i, contour in enumerate(contours):
x, y, bw, bh = cv2.boundingRect(contour)
area = bw * bh
# Filter: minimum size
if bw < 15 or bh < 15 or area < 200:
continue
# Filter: not too large (max 40% of image)
if area > (w * h * 0.4):
continue
# Filter: aspect ratio
aspect = max(bw, bh) / max(min(bw, bh), 1)
if aspect > 10:
continue
bbox = [x, y, x + bw, y + bh]
contour_area = cv2.contourArea(contour)
confidence = min(contour_area / area, 1.0)
textlines.append({
"index": i,
"bbox": bbox,
"polygon": [[x,y],[x+bw,y],[x+bw,y+bh],[x,y+bh]],
"confidence": confidence,
})
cv2.drawContours(mask_raw, [contour], -1, 255, -1)
# Dilate mask for inpainting coverage
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
mask = cv2.dilate(mask_raw, kernel, iterations=5)
return textlines, mask_raw, mask
return await loop.run_in_executor(None, _run)
def unload():
global _ort_session, _use_onnx
_ort_session = None
_use_onnx = False
log.info("CTD ONNX model unloaded.")

Xet Storage Details

Size:
10.4 kB
·
Xet hash:
7d54cc54bf0428e2f50660091f53777b90e1c6ed8a3b8e57282ed5075218a756

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.