Spaces:
Running on Zero
Running on Zero
Fix: hard mask (no ghost blending) + pad 8px + old pipeline
Browse files
app.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""
|
| 2 |
-
MangaCleaner GPU Microservice
|
|
|
|
| 3 |
Space: mmo9/Inpainting_bot
|
| 4 |
GPU: RTX Pro 6000 Blackwell (48 GB VRAM) Γ’β¬β Persistent Pro GPU / ZeroGPU
|
| 5 |
|
|
@@ -215,8 +216,6 @@ else:
|
|
| 215 |
# Switched to segmentation model β returns pixel masks instead of bounding boxes
|
| 216 |
YOLO_REPO = "ogkalu/comic-text-segmenter-yolov8m"
|
| 217 |
YOLO_FILE = "comic-text-segmenter.pt"
|
| 218 |
-
TEXT_DETECTOR_REPO = "deepghs/AnimeText_yolo"
|
| 219 |
-
TEXT_DETECTOR_FILE = "yolo12n_animetext/model.pt"
|
| 220 |
# AOT-GAN: manga-image-translator AOT inpainting (SafeTensors, 22MB)
|
| 221 |
# Replaces LaMa β trained on manga/comic, far better at screentones & gradients
|
| 222 |
AOT_REPO = "mayocream/aot-inpainting"
|
|
@@ -237,37 +236,25 @@ BLUR_RADIUS = int(os.getenv("BLUR_RADIUS", "19"))
|
|
| 237 |
MAX_ZIP_MB = int(os.getenv("MAX_ZIP_MB", "500"))
|
| 238 |
# Minimum connected component area (pxΒ²) to keep β removes screentone/noise dots
|
| 239 |
MIN_COMP_AREA = int(os.getenv("MIN_COMP_AREA", "20"))
|
| 240 |
-
SAFE_BUBBLE_BORDER = int(os.getenv("SAFE_BUBBLE_BORDER", "5"))
|
| 241 |
-
UNIFORM_LIGHT_STD = float(os.getenv("UNIFORM_LIGHT_STD", "12"))
|
| 242 |
-
UNIFORM_LIGHT_GRADIENT = float(os.getenv("UNIFORM_LIGHT_GRADIENT", "8"))
|
| 243 |
-
COMPLEX_FEATHER_RADIUS = float(os.getenv("COMPLEX_FEATHER_RADIUS", "2.5"))
|
| 244 |
|
| 245 |
# Global model handles
|
| 246 |
yolo_model: Optional[YOLO] = None
|
| 247 |
-
text_detector_model: Optional[YOLO] = None
|
| 248 |
lama_session: Optional[ort.InferenceSession] = None # kept for compat, not used for inference
|
| 249 |
aot_model = None # AOT-GAN removed β not used
|
| 250 |
bubble_seg_model: Optional[YOLO] = None
|
|
|
|
| 251 |
|
| 252 |
|
| 253 |
-
#
|
| 254 |
# Model Loading Helper (Lazy Loading on First Use)
|
| 255 |
-
#
|
| 256 |
-
def _safe_download(repo_id: str, filename: str) -> str:
|
| 257 |
-
try:
|
| 258 |
-
return hf_hub_download(repo_id=repo_id, filename=filename, local_files_only=False)
|
| 259 |
-
except Exception as e:
|
| 260 |
-
log.warning("Online hf_hub_download for %s/%s failed (%s), trying local cache...", repo_id, filename, e)
|
| 261 |
-
return hf_hub_download(repo_id=repo_id, filename=filename, local_files_only=True)
|
| 262 |
-
|
| 263 |
-
|
| 264 |
def ensure_models_loaded():
|
| 265 |
-
global yolo_model,
|
| 266 |
|
| 267 |
if yolo_model is None:
|
| 268 |
log.info("Loading YOLOv8 text segmenter β¦")
|
| 269 |
try:
|
| 270 |
-
yolo_path =
|
| 271 |
yolo_model = YOLO(yolo_path)
|
| 272 |
if torch.cuda.is_available():
|
| 273 |
try:
|
|
@@ -278,27 +265,10 @@ def ensure_models_loaded():
|
|
| 278 |
except Exception as exc:
|
| 279 |
log.error("YOLO load failed: %s", exc)
|
| 280 |
|
| 281 |
-
if text_detector_model is None:
|
| 282 |
-
log.info("Loading AnimeText text detector β¦")
|
| 283 |
-
try:
|
| 284 |
-
text_detector_path = _safe_download(
|
| 285 |
-
repo_id=TEXT_DETECTOR_REPO,
|
| 286 |
-
filename=TEXT_DETECTOR_FILE,
|
| 287 |
-
)
|
| 288 |
-
text_detector_model = YOLO(text_detector_path)
|
| 289 |
-
if torch.cuda.is_available():
|
| 290 |
-
try:
|
| 291 |
-
text_detector_model.to(0)
|
| 292 |
-
except Exception:
|
| 293 |
-
pass
|
| 294 |
-
log.info("AnimeText text detector loaded successfully.")
|
| 295 |
-
except Exception as exc:
|
| 296 |
-
log.error("AnimeText detector load failed: %s", exc)
|
| 297 |
-
|
| 298 |
if lama_session is None:
|
| 299 |
log.info("Loading LaMa ONNX model β¦")
|
| 300 |
try:
|
| 301 |
-
lama_path =
|
| 302 |
providers = ["CPUExecutionProvider"]
|
| 303 |
if torch.cuda.is_available() or "CUDAExecutionProvider" in ort.get_available_providers():
|
| 304 |
providers.insert(0, ("CUDAExecutionProvider", {"device_id": 0}))
|
|
@@ -315,7 +285,7 @@ def ensure_models_loaded():
|
|
| 315 |
if bubble_seg_model is None:
|
| 316 |
log.info("Loading bubble segmentation model (manga109-segmentation-bubble) ...")
|
| 317 |
try:
|
| 318 |
-
bubble_seg_path = hf_hub_download(repo_id=BUBBLE_SEG_REPO, filename=BUBBLE_SEG_FILE
|
| 319 |
bubble_seg_model = YOLO(bubble_seg_path)
|
| 320 |
if torch.cuda.is_available():
|
| 321 |
try:
|
|
@@ -326,6 +296,27 @@ def ensure_models_loaded():
|
|
| 326 |
except Exception as exc:
|
| 327 |
log.error("Bubble seg model load failed (non-fatal): %s", exc)
|
| 328 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
def _cuda_available() -> bool:
|
| 330 |
try:
|
| 331 |
import torch
|
|
@@ -689,19 +680,20 @@ def _build_text_mask(image_bgr: np.ndarray, dilate_iter: int = 3, remove_sfx: bo
|
|
| 689 |
h, w = image_bgr.shape[:2]
|
| 690 |
mask = np.zeros((h, w), dtype=np.uint8)
|
| 691 |
|
| 692 |
-
if
|
| 693 |
return mask
|
| 694 |
|
| 695 |
# Base confidence threshold for speech bubbles (0.15 for high recall)
|
| 696 |
conf_thresh_predict = 0.15
|
| 697 |
|
| 698 |
# Determine task type (detect or segment)
|
| 699 |
-
task_type = getattr(
|
| 700 |
|
| 701 |
-
#
|
| 702 |
-
#
|
| 703 |
-
|
| 704 |
-
|
|
|
|
| 705 |
def _check_conf(c_int: int, conf: float, base_conf: float) -> bool:
|
| 706 |
return conf >= base_conf
|
| 707 |
|
|
@@ -709,7 +701,7 @@ def _build_text_mask(image_bgr: np.ndarray, dilate_iter: int = 3, remove_sfx: bo
|
|
| 709 |
pass_mask = np.zeros((h, w), dtype=np.uint8)
|
| 710 |
dev_target = 0 if torch.cuda.is_available() else "cpu"
|
| 711 |
if h / w < 2.0:
|
| 712 |
-
results =
|
| 713 |
source=image_bgr[:, :, ::-1],
|
| 714 |
conf=conf_val,
|
| 715 |
iou=YOLO_IOU,
|
|
@@ -742,7 +734,7 @@ def _build_text_mask(image_bgr: np.ndarray, dilate_iter: int = 3, remove_sfx: bo
|
|
| 742 |
stride = tile_h - overlap
|
| 743 |
|
| 744 |
if w <= 300 or stride <= 0:
|
| 745 |
-
results =
|
| 746 |
source=image_bgr[:, :, ::-1],
|
| 747 |
conf=conf_val,
|
| 748 |
iou=YOLO_IOU,
|
|
@@ -777,7 +769,7 @@ def _build_text_mask(image_bgr: np.ndarray, dilate_iter: int = 3, remove_sfx: bo
|
|
| 777 |
if tile_h_actual == 0 or tile_w == 0:
|
| 778 |
break
|
| 779 |
|
| 780 |
-
results =
|
| 781 |
source=tile_img[:, :, ::-1],
|
| 782 |
conf=conf_val,
|
| 783 |
iou=YOLO_IOU,
|
|
@@ -823,175 +815,6 @@ def _build_text_mask(image_bgr: np.ndarray, dilate_iter: int = 3, remove_sfx: bo
|
|
| 823 |
mask = cv2.dilate(mask, kernel, iterations=max(2, dilate_iter))
|
| 824 |
return mask
|
| 825 |
|
| 826 |
-
|
| 827 |
-
def _safe_bubble_interior(interior: np.ndarray) -> np.ndarray:
|
| 828 |
-
"""Shrink a bubble mask so outlines, tails, and adjacent artwork stay untouched."""
|
| 829 |
-
if interior is None or interior.max() == 0:
|
| 830 |
-
return np.zeros_like(interior) if interior is not None else np.empty((0, 0), dtype=np.uint8)
|
| 831 |
-
border = max(1, SAFE_BUBBLE_BORDER)
|
| 832 |
-
kernel_size = border * 2 + 1
|
| 833 |
-
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))
|
| 834 |
-
return cv2.erode(
|
| 835 |
-
interior,
|
| 836 |
-
kernel,
|
| 837 |
-
iterations=1,
|
| 838 |
-
borderType=cv2.BORDER_CONSTANT,
|
| 839 |
-
borderValue=0,
|
| 840 |
-
)
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
def _component_background_is_uniform_light(img_rgb: np.ndarray, component: np.ndarray, allowed: np.ndarray) -> bool:
|
| 844 |
-
"""Return true only for a genuinely flat, light local background.
|
| 845 |
-
|
| 846 |
-
This intentionally errs on the side of treating a region as complex. A gradient
|
| 847 |
-
bubble must never be sent to the white-bubble path merely because it is bright.
|
| 848 |
-
"""
|
| 849 |
-
outer = cv2.dilate(component, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (17, 17)), iterations=1)
|
| 850 |
-
inner = cv2.dilate(component, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)), iterations=1)
|
| 851 |
-
ring = cv2.subtract(outer, inner)
|
| 852 |
-
if allowed is not None and allowed.max() > 0:
|
| 853 |
-
ring = cv2.bitwise_and(ring, allowed)
|
| 854 |
-
pixels = img_rgb[ring > 0]
|
| 855 |
-
if len(pixels) < 32:
|
| 856 |
-
return False
|
| 857 |
-
|
| 858 |
-
gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)
|
| 859 |
-
gradient = cv2.magnitude(
|
| 860 |
-
cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3),
|
| 861 |
-
cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3),
|
| 862 |
-
)
|
| 863 |
-
gradient_mean = float(gradient[ring > 0].mean())
|
| 864 |
-
lightness = pixels.mean(axis=1)
|
| 865 |
-
return (
|
| 866 |
-
float(lightness.mean()) >= 220.0
|
| 867 |
-
and float(lightness.std()) <= UNIFORM_LIGHT_STD
|
| 868 |
-
and float(pixels.std(axis=0).mean()) <= UNIFORM_LIGHT_STD
|
| 869 |
-
and gradient_mean <= UNIFORM_LIGHT_GRADIENT
|
| 870 |
-
)
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
def _find_system_panel_interior(img_rgb: np.ndarray, component: np.ndarray) -> np.ndarray:
|
| 874 |
-
"""Find a bordered rectangular panel enclosing a text block, if present."""
|
| 875 |
-
height, width = component.shape
|
| 876 |
-
contours, _ = cv2.findContours(component, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 877 |
-
if not contours:
|
| 878 |
-
return np.zeros_like(component)
|
| 879 |
-
text_x, text_y, text_width, text_height = cv2.boundingRect(max(contours, key=cv2.contourArea))
|
| 880 |
-
pad_x = max(40, int(text_width * 0.8))
|
| 881 |
-
pad_y = max(35, int(text_height * 1.5))
|
| 882 |
-
x0, y0 = max(0, text_x - pad_x), max(0, text_y - pad_y)
|
| 883 |
-
x1, y1 = min(width, text_x + text_width + pad_x), min(height, text_y + text_height + pad_y)
|
| 884 |
-
roi = cv2.cvtColor(img_rgb[y0:y1, x0:x1], cv2.COLOR_RGB2GRAY)
|
| 885 |
-
edges = cv2.Canny(roi, 60, 150)
|
| 886 |
-
panel_candidates, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
|
| 887 |
-
selected = None
|
| 888 |
-
selected_area = None
|
| 889 |
-
for contour in panel_candidates:
|
| 890 |
-
x, y, candidate_width, candidate_height = cv2.boundingRect(contour)
|
| 891 |
-
gx, gy = x0 + x, y0 + y
|
| 892 |
-
if candidate_width < text_width * 1.25 or candidate_height < text_height * 1.5:
|
| 893 |
-
continue
|
| 894 |
-
if gx > text_x or gy > text_y or gx + candidate_width < text_x + text_width or gy + candidate_height < text_y + text_height:
|
| 895 |
-
continue
|
| 896 |
-
ratio = candidate_width / max(1, candidate_height)
|
| 897 |
-
if ratio < 0.35 or ratio > 8.0:
|
| 898 |
-
continue
|
| 899 |
-
area = candidate_width * candidate_height
|
| 900 |
-
if selected_area is None or area < selected_area:
|
| 901 |
-
selected = (gx, gy, candidate_width, candidate_height)
|
| 902 |
-
selected_area = area
|
| 903 |
-
|
| 904 |
-
panel = np.zeros_like(component)
|
| 905 |
-
if selected is None:
|
| 906 |
-
return panel
|
| 907 |
-
x, y, candidate_width, candidate_height = selected
|
| 908 |
-
border = min(10, max(3, min(candidate_width, candidate_height) // 16))
|
| 909 |
-
if candidate_width <= border * 2 or candidate_height <= border * 2:
|
| 910 |
-
return panel
|
| 911 |
-
cv2.rectangle(
|
| 912 |
-
panel,
|
| 913 |
-
(x + border, y + border),
|
| 914 |
-
(x + candidate_width - border, y + candidate_height - border),
|
| 915 |
-
255,
|
| 916 |
-
thickness=-1,
|
| 917 |
-
)
|
| 918 |
-
return panel
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
def _route_text_masks(img_rgb: np.ndarray, legacy_mask: np.ndarray, bubble_interior: np.ndarray, allow_external: bool = False) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 922 |
-
"""Route text mask into legacy (LaMa / fast fill) and complex (LaMa ONNX) inpainting.
|
| 923 |
-
Guarantees that detected text in manhwa/webtoons is ALWAYS cleaned.
|
| 924 |
-
"""
|
| 925 |
-
legacy = np.zeros_like(legacy_mask)
|
| 926 |
-
complex_mask = np.zeros_like(legacy_mask)
|
| 927 |
-
excluded = np.zeros_like(legacy_mask)
|
| 928 |
-
|
| 929 |
-
if legacy_mask is None or legacy_mask.max() == 0:
|
| 930 |
-
return legacy, complex_mask, excluded
|
| 931 |
-
|
| 932 |
-
contours, _ = cv2.findContours(legacy_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 933 |
-
|
| 934 |
-
for contour in contours:
|
| 935 |
-
component = np.zeros_like(legacy_mask)
|
| 936 |
-
cv2.drawContours(component, [contour], -1, 255, thickness=-1)
|
| 937 |
-
|
| 938 |
-
uniform_region = _component_background_is_uniform_light(
|
| 939 |
-
img_rgb,
|
| 940 |
-
component,
|
| 941 |
-
bubble_interior,
|
| 942 |
-
)
|
| 943 |
-
|
| 944 |
-
if uniform_region:
|
| 945 |
-
legacy = cv2.bitwise_or(legacy, component)
|
| 946 |
-
else:
|
| 947 |
-
complex_mask = cv2.bitwise_or(complex_mask, component)
|
| 948 |
-
|
| 949 |
-
return legacy, complex_mask, excluded
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
def _prepare_cleaning_masks(img_bgr: np.ndarray, img_rgb: np.ndarray, dilate_iter: int, remove_sfx: bool, c_constant: int) -> dict[str, np.ndarray]:
|
| 953 |
-
coarse_mask = _build_text_mask(img_bgr, dilate_iter=dilate_iter, remove_sfx=remove_sfx)
|
| 954 |
-
empty_mask = np.zeros(img_bgr.shape[:2], dtype=np.uint8)
|
| 955 |
-
if coarse_mask.max() == 0:
|
| 956 |
-
return {
|
| 957 |
-
"coarse": empty_mask,
|
| 958 |
-
"precise": empty_mask,
|
| 959 |
-
"bubble_interior": empty_mask,
|
| 960 |
-
"legacy": empty_mask,
|
| 961 |
-
"complex": empty_mask,
|
| 962 |
-
"excluded": empty_mask,
|
| 963 |
-
}
|
| 964 |
-
|
| 965 |
-
precise_mask = _extract_precise_text_mask(img_rgb, coarse_mask, c_constant=c_constant)
|
| 966 |
-
final_mask = precise_mask
|
| 967 |
-
bubble_interior = _build_bubble_interior_mask(img_bgr)
|
| 968 |
-
legacy_mask, complex_mask, excluded_mask = _route_text_masks(
|
| 969 |
-
img_rgb,
|
| 970 |
-
final_mask,
|
| 971 |
-
bubble_interior,
|
| 972 |
-
allow_external=remove_sfx,
|
| 973 |
-
)
|
| 974 |
-
return {
|
| 975 |
-
"coarse": coarse_mask,
|
| 976 |
-
"precise": final_mask,
|
| 977 |
-
"bubble_interior": bubble_interior,
|
| 978 |
-
"legacy": legacy_mask,
|
| 979 |
-
"complex": complex_mask,
|
| 980 |
-
"excluded": excluded_mask,
|
| 981 |
-
}
|
| 982 |
-
|
| 983 |
-
def _hybrid_inpaint(img_rgb: np.ndarray, mask: np.ndarray) -> np.ndarray:
|
| 984 |
-
"""Inpaint img_rgb using LaMa ONNX Neural Inpainting for ALL text regions.
|
| 985 |
-
Seamlessly reconstructs speech bubble gradients, system windows, dark aura boxes,
|
| 986 |
-
and complex backgrounds without blocky solid color patches or white blobs.
|
| 987 |
-
"""
|
| 988 |
-
if mask is None or mask.max() == 0:
|
| 989 |
-
return img_rgb
|
| 990 |
-
|
| 991 |
-
# Run LaMa ONNX Neural Inpainter on the full text mask
|
| 992 |
-
out = _lama_inpaint_tile(img_rgb, mask)
|
| 993 |
-
return out
|
| 994 |
-
|
| 995 |
def _hybrid_inpaint(img_rgb: np.ndarray, mask: np.ndarray) -> np.ndarray:
|
| 996 |
"""Inpaint img_rgb using LaMa ONNX Neural Inpainting for ALL text regions.
|
| 997 |
Seamlessly reconstructs speech bubble gradients, system windows, dark aura boxes,
|
|
@@ -1017,49 +840,26 @@ def _build_bubble_interior_mask(image_bgr: np.ndarray) -> np.ndarray:
|
|
| 1017 |
try:
|
| 1018 |
task_type = getattr(bubble_seg_model, "task", "segment")
|
| 1019 |
dev_target = 0 if torch.cuda.is_available() else "cpu"
|
| 1020 |
-
|
| 1021 |
-
|
| 1022 |
-
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
|
| 1033 |
-
|
| 1034 |
-
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
seg_np,
|
| 1041 |
-
)
|
| 1042 |
-
else:
|
| 1043 |
-
for box in result.boxes.xyxy:
|
| 1044 |
-
x0, y0, x1, y1 = map(int, box.cpu().numpy())
|
| 1045 |
-
cv2.rectangle(interior, (x0, y_offset + y0), (x1, y_offset + y1), 255, -1)
|
| 1046 |
-
|
| 1047 |
-
if h / w < 2.0:
|
| 1048 |
-
add_tile(image_bgr, 0)
|
| 1049 |
-
else:
|
| 1050 |
-
tile_h = min(h, max(1280, w * 2))
|
| 1051 |
-
overlap = min(350, max(0, tile_h // 3))
|
| 1052 |
-
stride = max(1, tile_h - overlap)
|
| 1053 |
-
y_start = 0
|
| 1054 |
-
while y_start < h:
|
| 1055 |
-
y_end = min(y_start + tile_h, h)
|
| 1056 |
-
if y_end == h and y_start > 0:
|
| 1057 |
-
y_start = max(0, h - tile_h)
|
| 1058 |
-
y_end = h
|
| 1059 |
-
add_tile(image_bgr[y_start:y_end, :], y_start)
|
| 1060 |
-
if y_end == h:
|
| 1061 |
-
break
|
| 1062 |
-
y_start += stride
|
| 1063 |
except Exception as exc:
|
| 1064 |
log.warning("bubble_seg_model inference failed (non-fatal): %s", exc)
|
| 1065 |
return interior
|
|
@@ -1076,13 +876,13 @@ def _lama_inpaint_tile(img_rgb: np.ndarray, mask: np.ndarray, size: int = 512) -
|
|
| 1076 |
contours, _ = cv2.findContours(dilated_for_merge, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 1077 |
|
| 1078 |
if not contours:
|
| 1079 |
-
return
|
| 1080 |
|
| 1081 |
input_names = [inp.name for inp in lama_session.get_inputs()]
|
| 1082 |
|
| 1083 |
for cnt in contours:
|
| 1084 |
rx, ry, rw, rh = cv2.boundingRect(cnt)
|
| 1085 |
-
pad =
|
| 1086 |
x0 = max(0, rx - pad)
|
| 1087 |
y0 = max(0, ry - pad)
|
| 1088 |
x1 = min(w, rx + rw + pad)
|
|
@@ -1113,103 +913,16 @@ def _lama_inpaint_tile(img_rgb: np.ndarray, mask: np.ndarray, size: int = 512) -
|
|
| 1113 |
out_img_512 = np.clip(out[0].transpose(1, 2, 0) * 255.0, 0.0, 255.0).astype(np.uint8)
|
| 1114 |
out_img_orig = cv2.resize(out_img_512, (cw, ch), interpolation=cv2.INTER_CUBIC)
|
| 1115 |
|
| 1116 |
-
|
| 1117 |
-
|
| 1118 |
-
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
|
| 1122 |
-
img_out[y0:y1, x0:x1] =
|
| 1123 |
|
| 1124 |
return img_out
|
| 1125 |
|
| 1126 |
-
|
| 1127 |
-
def _lama_inpaint_safe_regions(img_rgb: np.ndarray, mask: np.ndarray, size: int = LAMA_SIZE) -> np.ndarray:
|
| 1128 |
-
"""Inpaint independent protected regions without aspect-ratio distortion.
|
| 1129 |
-
|
| 1130 |
-
Unlike the legacy inpainting path, this never merges distant components and only
|
| 1131 |
-
writes pixels selected by the protected mask. It is reserved for gradients,
|
| 1132 |
-
patterns, transparent bubbles, and system windows.
|
| 1133 |
-
"""
|
| 1134 |
-
if lama_session is None or mask is None or mask.max() == 0:
|
| 1135 |
-
return img_rgb
|
| 1136 |
-
|
| 1137 |
-
output = img_rgb.copy()
|
| 1138 |
-
input_names = [inp.name for inp in lama_session.get_inputs()]
|
| 1139 |
-
merge_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
| 1140 |
-
merged = cv2.dilate(mask, merge_kernel, iterations=1)
|
| 1141 |
-
contours, _ = cv2.findContours(merged, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 1142 |
-
height, width = img_rgb.shape[:2]
|
| 1143 |
-
|
| 1144 |
-
for contour in contours:
|
| 1145 |
-
x, y, region_width, region_height = cv2.boundingRect(contour)
|
| 1146 |
-
padding = 32
|
| 1147 |
-
x0, y0 = max(0, x - padding), max(0, y - padding)
|
| 1148 |
-
x1, y1 = min(width, x + region_width + padding), min(height, y + region_height + padding)
|
| 1149 |
-
crop = output[y0:y1, x0:x1]
|
| 1150 |
-
crop_mask = mask[y0:y1, x0:x1]
|
| 1151 |
-
if crop.size == 0 or crop_mask.max() == 0:
|
| 1152 |
-
continue
|
| 1153 |
-
|
| 1154 |
-
crop_height, crop_width = crop.shape[:2]
|
| 1155 |
-
side = max(crop_height, crop_width)
|
| 1156 |
-
top = (side - crop_height) // 2
|
| 1157 |
-
bottom = side - crop_height - top
|
| 1158 |
-
left = (side - crop_width) // 2
|
| 1159 |
-
right = side - crop_width - left
|
| 1160 |
-
padded_crop = cv2.copyMakeBorder(crop, top, bottom, left, right, cv2.BORDER_REFLECT_101)
|
| 1161 |
-
padded_mask = cv2.copyMakeBorder(crop_mask, top, bottom, left, right, cv2.BORDER_CONSTANT, value=0)
|
| 1162 |
-
|
| 1163 |
-
model_image = cv2.resize(padded_crop, (size, size), interpolation=cv2.INTER_CUBIC)
|
| 1164 |
-
model_mask = cv2.resize(padded_mask, (size, size), interpolation=cv2.INTER_NEAREST)
|
| 1165 |
-
model_output = lama_session.run(None, {
|
| 1166 |
-
input_names[0]: model_image.transpose(2, 0, 1)[np.newaxis].astype(np.float32) / 255.0,
|
| 1167 |
-
input_names[1]: (model_mask[np.newaxis, np.newaxis] > 127).astype(np.float32),
|
| 1168 |
-
})[0]
|
| 1169 |
-
restored = np.clip(model_output[0].transpose(1, 2, 0) * 255.0, 0.0, 255.0).astype(np.uint8)
|
| 1170 |
-
restored = cv2.resize(restored, (side, side), interpolation=cv2.INTER_CUBIC)
|
| 1171 |
-
restored = restored[top:top + crop_height, left:left + crop_width]
|
| 1172 |
-
|
| 1173 |
-
candidate = _blend_complex_result(crop, restored, crop_mask)
|
| 1174 |
-
if _has_new_boundary_artifact(crop, candidate, crop_mask):
|
| 1175 |
-
log.warning("Rejected complex inpainting result with a new boundary artifact.")
|
| 1176 |
-
continue
|
| 1177 |
-
output[y0:y1, x0:x1] = candidate
|
| 1178 |
-
|
| 1179 |
-
return output
|
| 1180 |
-
|
| 1181 |
-
|
| 1182 |
-
def _blend_complex_result(source: np.ndarray, restored: np.ndarray, mask: np.ndarray) -> np.ndarray:
|
| 1183 |
-
"""Feather only within a protected text mask, never across a bubble boundary."""
|
| 1184 |
-
distance = cv2.distanceTransform(mask, cv2.DIST_L2, 3)
|
| 1185 |
-
radius = max(1.0, COMPLEX_FEATHER_RADIUS)
|
| 1186 |
-
alpha = np.clip((distance + 1.0) / radius, 0.0, 1.0)
|
| 1187 |
-
alpha = np.expand_dims(alpha, axis=-1)
|
| 1188 |
-
blended = source.astype(np.float32) * (1.0 - alpha) + restored.astype(np.float32) * alpha
|
| 1189 |
-
result = source.copy()
|
| 1190 |
-
result[mask > 0] = np.clip(blended, 0, 255).astype(np.uint8)[mask > 0]
|
| 1191 |
-
return result
|
| 1192 |
-
|
| 1193 |
-
|
| 1194 |
-
def _has_new_boundary_artifact(source: np.ndarray, candidate: np.ndarray, mask: np.ndarray) -> bool:
|
| 1195 |
-
"""Reject a generated hard edge when the original boundary was locally smooth."""
|
| 1196 |
-
inner = cv2.subtract(mask, cv2.erode(mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)), iterations=1))
|
| 1197 |
-
if cv2.countNonZero(inner) < 12:
|
| 1198 |
-
return False
|
| 1199 |
-
source_gray = cv2.cvtColor(source, cv2.COLOR_RGB2GRAY)
|
| 1200 |
-
candidate_gray = cv2.cvtColor(candidate, cv2.COLOR_RGB2GRAY)
|
| 1201 |
-
source_edges = cv2.magnitude(
|
| 1202 |
-
cv2.Sobel(source_gray, cv2.CV_32F, 1, 0, ksize=3),
|
| 1203 |
-
cv2.Sobel(source_gray, cv2.CV_32F, 0, 1, ksize=3),
|
| 1204 |
-
)
|
| 1205 |
-
candidate_edges = cv2.magnitude(
|
| 1206 |
-
cv2.Sobel(candidate_gray, cv2.CV_32F, 1, 0, ksize=3),
|
| 1207 |
-
cv2.Sobel(candidate_gray, cv2.CV_32F, 0, 1, ksize=3),
|
| 1208 |
-
)
|
| 1209 |
-
source_mean = float(source_edges[inner > 0].mean())
|
| 1210 |
-
candidate_mean = float(candidate_edges[inner > 0].mean())
|
| 1211 |
-
return source_mean < 12.0 and candidate_mean > max(18.0, source_mean * 2.5)
|
| 1212 |
-
|
| 1213 |
def _extract_precise_text_mask(img_rgb: np.ndarray, mask: np.ndarray, c_constant: int = C_CONSTANT) -> np.ndarray:
|
| 1214 |
"""Refines YOLO text masks into pixel-perfect text stroke masks including drop-shadows & font halos.
|
| 1215 |
Protects character skin, eyes, and bubble outline borders.
|
|
@@ -1242,39 +955,42 @@ def _extract_precise_text_mask(img_rgb: np.ndarray, mask: np.ndarray, c_constant
|
|
| 1242 |
|
| 1243 |
blurred = cv2.GaussianBlur(crop_gray, (3, 3), 0)
|
| 1244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1245 |
# Multi-scale adaptive thresholding + Otsu thresholding for thick/large fonts
|
| 1246 |
max_odd = min_dim - 1 if min_dim % 2 == 0 else min_dim
|
| 1247 |
block_size_small = min(25, max_odd)
|
| 1248 |
block_size_large = min(101, max_odd)
|
| 1249 |
-
|
| 1250 |
-
|
| 1251 |
-
|
| 1252 |
-
|
| 1253 |
-
|
| 1254 |
-
|
| 1255 |
-
|
| 1256 |
-
|
| 1257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1258 |
close_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
| 1259 |
-
|
| 1260 |
-
light_text = cv2.morphologyEx(light_text, cv2.MORPH_CLOSE, close_k)
|
| 1261 |
|
| 1262 |
guide_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (11, 11))
|
| 1263 |
spatial_guide = cv2.dilate(crop_mask, guide_kernel, iterations=3)
|
| 1264 |
-
|
| 1265 |
-
|
| 1266 |
-
|
| 1267 |
-
|
| 1268 |
-
guide_area = max(1, cv2.countNonZero(spatial_guide))
|
| 1269 |
-
candidate_densities = [cv2.countNonZero(candidate) / guide_area for candidate in candidates]
|
| 1270 |
-
valid_candidates = [
|
| 1271 |
-
(density, candidate)
|
| 1272 |
-
for density, candidate in zip(candidate_densities, candidates)
|
| 1273 |
-
if 0.003 <= density <= 0.65
|
| 1274 |
-
]
|
| 1275 |
-
if not valid_candidates:
|
| 1276 |
-
continue
|
| 1277 |
-
stroke_mask = min(valid_candidates, key=lambda item: item[0])[1]
|
| 1278 |
|
| 1279 |
# Connected-component filtering: keep valid text components, remove noise dots < MIN_COMP_AREA
|
| 1280 |
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(stroke_mask, connectivity=8)
|
|
@@ -1305,7 +1021,7 @@ def _cpu_fallback_cleaner(img_bgr: np.ndarray) -> np.ndarray:
|
|
| 1305 |
def clean_single_image_helper(
|
| 1306 |
image_bytes: bytes,
|
| 1307 |
dilate_iter: int = DILATE_ITER,
|
| 1308 |
-
remove_sfx: bool =
|
| 1309 |
c_constant: int = C_CONSTANT,
|
| 1310 |
) -> bytes:
|
| 1311 |
"""Processes and cleans a single manga/manhwa page image.
|
|
@@ -1320,18 +1036,14 @@ def clean_single_image_helper(
|
|
| 1320 |
|
| 1321 |
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
| 1322 |
|
| 1323 |
-
|
| 1324 |
-
|
| 1325 |
-
|
| 1326 |
-
|
| 1327 |
-
|
| 1328 |
-
|
| 1329 |
-
|
| 1330 |
-
|
| 1331 |
-
log.info(
|
| 1332 |
-
"Protected %d text-mask pixels near bubble borders or without a safe interior.",
|
| 1333 |
-
cv2.countNonZero(masks["excluded"]),
|
| 1334 |
-
)
|
| 1335 |
else:
|
| 1336 |
img_clean = img_rgb.copy()
|
| 1337 |
|
|
@@ -1349,22 +1061,6 @@ def clean_single_image_helper(
|
|
| 1349 |
return image_bytes
|
| 1350 |
|
| 1351 |
|
| 1352 |
-
def cleaning_diagnostics(image_bytes: bytes, dilate_iter: int = DILATE_ITER, remove_sfx: bool = False) -> dict[str, bytes]:
|
| 1353 |
-
"""Return PNG masks that explain which pixels the cleaner will modify or protect."""
|
| 1354 |
-
ensure_models_loaded()
|
| 1355 |
-
img_bgr = cv2.imdecode(np.frombuffer(image_bytes, np.uint8), cv2.IMREAD_COLOR)
|
| 1356 |
-
if img_bgr is None:
|
| 1357 |
-
raise ValueError("Cannot decode image")
|
| 1358 |
-
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
| 1359 |
-
masks = _prepare_cleaning_masks(img_bgr, img_rgb, dilate_iter, remove_sfx, C_CONSTANT)
|
| 1360 |
-
result = {}
|
| 1361 |
-
for name, mask in masks.items():
|
| 1362 |
-
ok, encoded = cv2.imencode(".png", mask)
|
| 1363 |
-
if ok:
|
| 1364 |
-
result[f"{name}_mask.png"] = encoded.tobytes()
|
| 1365 |
-
return result
|
| 1366 |
-
|
| 1367 |
-
|
| 1368 |
import concurrent.futures
|
| 1369 |
|
| 1370 |
paddle_ocr_readers: dict = {}
|
|
@@ -1474,10 +1170,12 @@ def clean_single_image(image_bytes: bytes, dilate_iter: int = 3, remove_sfx: boo
|
|
| 1474 |
return clean_single_image_helper(image_bytes, dilate_iter=dilate_iter, remove_sfx=remove_sfx)
|
| 1475 |
|
| 1476 |
|
|
|
|
| 1477 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1478 |
# Gradio UI Setup
|
| 1479 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1480 |
|
|
|
|
| 1481 |
def process_gradio_zip(file_obj, key: str, dilate_iter: int = 3, remove_sfx: bool = False) -> tuple[Optional[str], str]:
|
| 1482 |
if API_KEY and key:
|
| 1483 |
if not secrets.compare_digest(key, API_KEY):
|
|
@@ -1653,32 +1351,6 @@ def patched_create_app(cls, blocks, *args, **kwargs):
|
|
| 1653 |
},
|
| 1654 |
)
|
| 1655 |
|
| 1656 |
-
@app.post("/clean_diagnostics")
|
| 1657 |
-
async def clean_diagnostics(
|
| 1658 |
-
file: UploadFile = File(..., description="Single manga image to inspect"),
|
| 1659 |
-
dilate_iter: int = DILATE_ITER,
|
| 1660 |
-
remove_sfx: bool = False,
|
| 1661 |
-
_key: str = Depends(verify_key_dep),
|
| 1662 |
-
):
|
| 1663 |
-
content = await file.read()
|
| 1664 |
-
if len(content) > MAX_ZIP_MB * 1024 * 1024:
|
| 1665 |
-
raise HTTPException(status_code=413, detail=f"Image too large (max {MAX_ZIP_MB} MB)")
|
| 1666 |
-
try:
|
| 1667 |
-
masks = cleaning_diagnostics(content, dilate_iter=dilate_iter, remove_sfx=remove_sfx)
|
| 1668 |
-
except ValueError as exc:
|
| 1669 |
-
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 1670 |
-
|
| 1671 |
-
out_buf = io.BytesIO()
|
| 1672 |
-
with zipfile.ZipFile(out_buf, "w", compression=zipfile.ZIP_DEFLATED) as out_zip:
|
| 1673 |
-
for name, data in masks.items():
|
| 1674 |
-
out_zip.writestr(name, data)
|
| 1675 |
-
out_buf.seek(0)
|
| 1676 |
-
return Response(
|
| 1677 |
-
content=out_buf.read(),
|
| 1678 |
-
media_type="application/zip",
|
| 1679 |
-
headers={"Content-Disposition": "attachment; filename=cleaning_diagnostics.zip"},
|
| 1680 |
-
)
|
| 1681 |
-
|
| 1682 |
@app.post("/process_ocr_zip")
|
| 1683 |
async def process_ocr_zip(
|
| 1684 |
file: UploadFile = File(..., description="ZIP file containing manga images"),
|
|
|
|
| 1 |
"""
|
| 2 |
+
MangaCleaner GPU Microservice β app.py
|
| 3 |
+
# v2-fixed: Hard mask blending (no ghost text), safe HF download, reduced pad
|
| 4 |
Space: mmo9/Inpainting_bot
|
| 5 |
GPU: RTX Pro 6000 Blackwell (48 GB VRAM) Γ’β¬β Persistent Pro GPU / ZeroGPU
|
| 6 |
|
|
|
|
| 216 |
# Switched to segmentation model β returns pixel masks instead of bounding boxes
|
| 217 |
YOLO_REPO = "ogkalu/comic-text-segmenter-yolov8m"
|
| 218 |
YOLO_FILE = "comic-text-segmenter.pt"
|
|
|
|
|
|
|
| 219 |
# AOT-GAN: manga-image-translator AOT inpainting (SafeTensors, 22MB)
|
| 220 |
# Replaces LaMa β trained on manga/comic, far better at screentones & gradients
|
| 221 |
AOT_REPO = "mayocream/aot-inpainting"
|
|
|
|
| 236 |
MAX_ZIP_MB = int(os.getenv("MAX_ZIP_MB", "500"))
|
| 237 |
# Minimum connected component area (pxΒ²) to keep β removes screentone/noise dots
|
| 238 |
MIN_COMP_AREA = int(os.getenv("MIN_COMP_AREA", "20"))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
|
| 240 |
# Global model handles
|
| 241 |
yolo_model: Optional[YOLO] = None
|
|
|
|
| 242 |
lama_session: Optional[ort.InferenceSession] = None # kept for compat, not used for inference
|
| 243 |
aot_model = None # AOT-GAN removed β not used
|
| 244 |
bubble_seg_model: Optional[YOLO] = None
|
| 245 |
+
sd_pipe = None # StableDiffusionInpaintPipeline for background reconstruction
|
| 246 |
|
| 247 |
|
| 248 |
+
# Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬
|
| 249 |
# Model Loading Helper (Lazy Loading on First Use)
|
| 250 |
+
# Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
def ensure_models_loaded():
|
| 252 |
+
global yolo_model, lama_session, aot_model, bubble_seg_model, sd_pipe
|
| 253 |
|
| 254 |
if yolo_model is None:
|
| 255 |
log.info("Loading YOLOv8 text segmenter β¦")
|
| 256 |
try:
|
| 257 |
+
yolo_path = hf_hub_download(repo_id=YOLO_REPO, filename=YOLO_FILE)
|
| 258 |
yolo_model = YOLO(yolo_path)
|
| 259 |
if torch.cuda.is_available():
|
| 260 |
try:
|
|
|
|
| 265 |
except Exception as exc:
|
| 266 |
log.error("YOLO load failed: %s", exc)
|
| 267 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
if lama_session is None:
|
| 269 |
log.info("Loading LaMa ONNX model β¦")
|
| 270 |
try:
|
| 271 |
+
lama_path = hf_hub_download(repo_id=LAMA_REPO, filename=LAMA_FILE)
|
| 272 |
providers = ["CPUExecutionProvider"]
|
| 273 |
if torch.cuda.is_available() or "CUDAExecutionProvider" in ort.get_available_providers():
|
| 274 |
providers.insert(0, ("CUDAExecutionProvider", {"device_id": 0}))
|
|
|
|
| 285 |
if bubble_seg_model is None:
|
| 286 |
log.info("Loading bubble segmentation model (manga109-segmentation-bubble) ...")
|
| 287 |
try:
|
| 288 |
+
bubble_seg_path = hf_hub_download(repo_id=BUBBLE_SEG_REPO, filename=BUBBLE_SEG_FILE)
|
| 289 |
bubble_seg_model = YOLO(bubble_seg_path)
|
| 290 |
if torch.cuda.is_available():
|
| 291 |
try:
|
|
|
|
| 296 |
except Exception as exc:
|
| 297 |
log.error("Bubble seg model load failed (non-fatal): %s", exc)
|
| 298 |
|
| 299 |
+
if sd_pipe is None:
|
| 300 |
+
log.info("Loading Stable Diffusion 2 Inpainting pipeline ...")
|
| 301 |
+
try:
|
| 302 |
+
from diffusers import StableDiffusionInpaintPipeline
|
| 303 |
+
sd_pipe = StableDiffusionInpaintPipeline.from_pretrained(
|
| 304 |
+
"stabilityai/stable-diffusion-2-inpainting",
|
| 305 |
+
torch_dtype=torch.float16,
|
| 306 |
+
safety_checker=None,
|
| 307 |
+
requires_safety_checker=False,
|
| 308 |
+
)
|
| 309 |
+
sd_pipe = sd_pipe.to("cuda")
|
| 310 |
+
sd_pipe.set_progress_bar_config(disable=True)
|
| 311 |
+
# Speed optimisations
|
| 312 |
+
sd_pipe.enable_attention_slicing()
|
| 313 |
+
log.info("SD2 Inpainting pipeline loaded on CUDA")
|
| 314 |
+
except Exception as exc:
|
| 315 |
+
log.error("SD2 Inpainting load failed (will fall back to Telea): %s", exc)
|
| 316 |
+
sd_pipe = None
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
|
| 320 |
def _cuda_available() -> bool:
|
| 321 |
try:
|
| 322 |
import torch
|
|
|
|
| 680 |
h, w = image_bgr.shape[:2]
|
| 681 |
mask = np.zeros((h, w), dtype=np.uint8)
|
| 682 |
|
| 683 |
+
if yolo_model is None:
|
| 684 |
return mask
|
| 685 |
|
| 686 |
# Base confidence threshold for speech bubbles (0.15 for high recall)
|
| 687 |
conf_thresh_predict = 0.15
|
| 688 |
|
| 689 |
# Determine task type (detect or segment)
|
| 690 |
+
task_type = getattr(yolo_model, "task", "detect")
|
| 691 |
|
| 692 |
+
# ogkalu/comic-text-segmenter-yolov8m classes:
|
| 693 |
+
# 0: balloon, 1: qipao, 2: fangkuai, 3: changfangtiao, 4: kuangwai (free text/SFX/narration), 5: other
|
| 694 |
+
bubble_classes = {0, 1, 2, 3}
|
| 695 |
+
sfx_classes = {4, 5}
|
| 696 |
+
|
| 697 |
def _check_conf(c_int: int, conf: float, base_conf: float) -> bool:
|
| 698 |
return conf >= base_conf
|
| 699 |
|
|
|
|
| 701 |
pass_mask = np.zeros((h, w), dtype=np.uint8)
|
| 702 |
dev_target = 0 if torch.cuda.is_available() else "cpu"
|
| 703 |
if h / w < 2.0:
|
| 704 |
+
results = yolo_model.predict(
|
| 705 |
source=image_bgr[:, :, ::-1],
|
| 706 |
conf=conf_val,
|
| 707 |
iou=YOLO_IOU,
|
|
|
|
| 734 |
stride = tile_h - overlap
|
| 735 |
|
| 736 |
if w <= 300 or stride <= 0:
|
| 737 |
+
results = yolo_model.predict(
|
| 738 |
source=image_bgr[:, :, ::-1],
|
| 739 |
conf=conf_val,
|
| 740 |
iou=YOLO_IOU,
|
|
|
|
| 769 |
if tile_h_actual == 0 or tile_w == 0:
|
| 770 |
break
|
| 771 |
|
| 772 |
+
results = yolo_model.predict(
|
| 773 |
source=tile_img[:, :, ::-1],
|
| 774 |
conf=conf_val,
|
| 775 |
iou=YOLO_IOU,
|
|
|
|
| 815 |
mask = cv2.dilate(mask, kernel, iterations=max(2, dilate_iter))
|
| 816 |
return mask
|
| 817 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 818 |
def _hybrid_inpaint(img_rgb: np.ndarray, mask: np.ndarray) -> np.ndarray:
|
| 819 |
"""Inpaint img_rgb using LaMa ONNX Neural Inpainting for ALL text regions.
|
| 820 |
Seamlessly reconstructs speech bubble gradients, system windows, dark aura boxes,
|
|
|
|
| 840 |
try:
|
| 841 |
task_type = getattr(bubble_seg_model, "task", "segment")
|
| 842 |
dev_target = 0 if torch.cuda.is_available() else "cpu"
|
| 843 |
+
results = bubble_seg_model.predict(
|
| 844 |
+
source=image_bgr[:, :, ::-1],
|
| 845 |
+
conf=BUBSEG_CONF,
|
| 846 |
+
iou=YOLO_IOU,
|
| 847 |
+
verbose=False,
|
| 848 |
+
device=dev_target,
|
| 849 |
+
retina_masks=(task_type == "segment"),
|
| 850 |
+
)
|
| 851 |
+
for r in results:
|
| 852 |
+
if r.boxes is None:
|
| 853 |
+
continue
|
| 854 |
+
if task_type == "segment" and r.masks is not None:
|
| 855 |
+
for seg_mask in r.masks.data:
|
| 856 |
+
seg_np = (seg_mask.cpu().numpy() > 0.5).astype(np.uint8) * 255
|
| 857 |
+
seg_np = cv2.resize(seg_np, (w, h), interpolation=cv2.INTER_NEAREST)
|
| 858 |
+
interior = cv2.bitwise_or(interior, seg_np)
|
| 859 |
+
else:
|
| 860 |
+
for box in r.boxes.xyxy:
|
| 861 |
+
x0, y0, x1, y1 = map(int, box.cpu().numpy())
|
| 862 |
+
cv2.rectangle(interior, (x0, y0), (x1, y1), 255, -1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 863 |
except Exception as exc:
|
| 864 |
log.warning("bubble_seg_model inference failed (non-fatal): %s", exc)
|
| 865 |
return interior
|
|
|
|
| 876 |
contours, _ = cv2.findContours(dilated_for_merge, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 877 |
|
| 878 |
if not contours:
|
| 879 |
+
return img_rgb
|
| 880 |
|
| 881 |
input_names = [inp.name for inp in lama_session.get_inputs()]
|
| 882 |
|
| 883 |
for cnt in contours:
|
| 884 |
rx, ry, rw, rh = cv2.boundingRect(cnt)
|
| 885 |
+
pad = 8 # Reduced from 24 to protect square bubble borders
|
| 886 |
x0 = max(0, rx - pad)
|
| 887 |
y0 = max(0, ry - pad)
|
| 888 |
x1 = min(w, rx + rw + pad)
|
|
|
|
| 913 |
out_img_512 = np.clip(out[0].transpose(1, 2, 0) * 255.0, 0.0, 255.0).astype(np.uint8)
|
| 914 |
out_img_orig = cv2.resize(out_img_512, (cw, ch), interpolation=cv2.INTER_CUBIC)
|
| 915 |
|
| 916 |
+
# FIX: Hard mask replacement - no alpha blending to prevent text ghosts.
|
| 917 |
+
# Only pixels where crop_mask==255 are replaced with LaMa output.
|
| 918 |
+
# Pixels outside the mask keep the original artwork 100% untouched.
|
| 919 |
+
hard_mask = (crop_mask > 127).astype(np.uint8)
|
| 920 |
+
hard_mask_3ch = np.stack([hard_mask, hard_mask, hard_mask], axis=-1)
|
| 921 |
+
result_crop = np.where(hard_mask_3ch, out_img_orig, crop_img)
|
| 922 |
+
img_out[y0:y1, x0:x1] = result_crop
|
| 923 |
|
| 924 |
return img_out
|
| 925 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 926 |
def _extract_precise_text_mask(img_rgb: np.ndarray, mask: np.ndarray, c_constant: int = C_CONSTANT) -> np.ndarray:
|
| 927 |
"""Refines YOLO text masks into pixel-perfect text stroke masks including drop-shadows & font halos.
|
| 928 |
Protects character skin, eyes, and bubble outline borders.
|
|
|
|
| 955 |
|
| 956 |
blurred = cv2.GaussianBlur(crop_gray, (3, 3), 0)
|
| 957 |
|
| 958 |
+
border_pixels = np.concatenate([
|
| 959 |
+
blurred[0, :],
|
| 960 |
+
blurred[-1, :],
|
| 961 |
+
blurred[:, 0],
|
| 962 |
+
blurred[:, -1]
|
| 963 |
+
])
|
| 964 |
+
bg_brightness = np.median(border_pixels) if border_pixels.size > 0 else 255
|
| 965 |
+
|
| 966 |
# Multi-scale adaptive thresholding + Otsu thresholding for thick/large fonts
|
| 967 |
max_odd = min_dim - 1 if min_dim % 2 == 0 else min_dim
|
| 968 |
block_size_small = min(25, max_odd)
|
| 969 |
block_size_large = min(101, max_odd)
|
| 970 |
+
|
| 971 |
+
if bg_brightness > 127:
|
| 972 |
+
thresh_s = cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, block_size_small, c_constant)
|
| 973 |
+
thresh_l = cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, block_size_large, c_constant)
|
| 974 |
+
_, thresh_o = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
|
| 975 |
+
else:
|
| 976 |
+
thresh_s = cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, block_size_small, c_constant)
|
| 977 |
+
thresh_l = cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, block_size_large, c_constant)
|
| 978 |
+
_, thresh_o = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
| 979 |
+
|
| 980 |
+
# Union of threshold passes
|
| 981 |
+
thresh = cv2.bitwise_or(thresh_s, thresh_l)
|
| 982 |
+
thresh = cv2.bitwise_or(thresh, thresh_o)
|
| 983 |
+
|
| 984 |
+
# Morphological closing to seal hollow centers of thick fonts
|
| 985 |
close_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
| 986 |
+
thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, close_k)
|
|
|
|
| 987 |
|
| 988 |
guide_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (11, 11))
|
| 989 |
spatial_guide = cv2.dilate(crop_mask, guide_kernel, iterations=3)
|
| 990 |
+
stroke_mask = cv2.bitwise_and(thresh, spatial_guide)
|
| 991 |
+
|
| 992 |
+
# Union with raw YOLO segment mask to guarantee font drop-shadows & light font halos are NEVER omitted
|
| 993 |
+
stroke_mask = cv2.bitwise_or(stroke_mask, crop_mask)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 994 |
|
| 995 |
# Connected-component filtering: keep valid text components, remove noise dots < MIN_COMP_AREA
|
| 996 |
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(stroke_mask, connectivity=8)
|
|
|
|
| 1021 |
def clean_single_image_helper(
|
| 1022 |
image_bytes: bytes,
|
| 1023 |
dilate_iter: int = DILATE_ITER,
|
| 1024 |
+
remove_sfx: bool = True,
|
| 1025 |
c_constant: int = C_CONSTANT,
|
| 1026 |
) -> bytes:
|
| 1027 |
"""Processes and cleans a single manga/manhwa page image.
|
|
|
|
| 1036 |
|
| 1037 |
img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
|
| 1038 |
|
| 1039 |
+
# Pass 1: Build primary text mask
|
| 1040 |
+
coarse_mask = _build_text_mask(img_bgr, dilate_iter=dilate_iter, remove_sfx=remove_sfx)
|
| 1041 |
+
|
| 1042 |
+
if coarse_mask.max() > 0:
|
| 1043 |
+
dilated_coarse = cv2.dilate(coarse_mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)), iterations=1)
|
| 1044 |
+
precise_mask = _extract_precise_text_mask(img_rgb, coarse_mask, c_constant=c_constant)
|
| 1045 |
+
final_mask = cv2.bitwise_or(precise_mask, dilated_coarse)
|
| 1046 |
+
img_clean = _hybrid_inpaint(img_rgb, final_mask)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1047 |
else:
|
| 1048 |
img_clean = img_rgb.copy()
|
| 1049 |
|
|
|
|
| 1061 |
return image_bytes
|
| 1062 |
|
| 1063 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1064 |
import concurrent.futures
|
| 1065 |
|
| 1066 |
paddle_ocr_readers: dict = {}
|
|
|
|
| 1170 |
return clean_single_image_helper(image_bytes, dilate_iter=dilate_iter, remove_sfx=remove_sfx)
|
| 1171 |
|
| 1172 |
|
| 1173 |
+
# Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬Γ’ββ¬
|
| 1174 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1175 |
# Gradio UI Setup
|
| 1176 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1177 |
|
| 1178 |
+
@spaces.GPU(duration=120)
|
| 1179 |
def process_gradio_zip(file_obj, key: str, dilate_iter: int = 3, remove_sfx: bool = False) -> tuple[Optional[str], str]:
|
| 1180 |
if API_KEY and key:
|
| 1181 |
if not secrets.compare_digest(key, API_KEY):
|
|
|
|
| 1351 |
},
|
| 1352 |
)
|
| 1353 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1354 |
@app.post("/process_ocr_zip")
|
| 1355 |
async def process_ocr_zip(
|
| 1356 |
file: UploadFile = File(..., description="ZIP file containing manga images"),
|