mmo9 commited on
Commit
9b65601
Β·
verified Β·
1 Parent(s): 5f05250

Fix: hard mask (no ghost blending) + pad 8px + old pipeline

Browse files
Files changed (1) hide show
  1. app.py +108 -436
app.py CHANGED
@@ -1,5 +1,6 @@
1
  """
2
- MangaCleaner GPU Microservice Ò€” app.py
 
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, text_detector_model, lama_session, aot_model, bubble_seg_model
266
 
267
  if yolo_model is None:
268
  log.info("Loading YOLOv8 text segmenter …")
269
  try:
270
- yolo_path = _safe_download(repo_id=YOLO_REPO, filename=YOLO_FILE)
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 = _safe_download(repo_id=LAMA_REPO, filename=LAMA_FILE)
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, local_files_only=True)
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 text_detector_model is None:
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(text_detector_model, "task", "detect")
700
 
701
- # NOTE: text_detector_model = AnimeText (single class: text regions only, no SFX class).
702
- # The allowed_classes SFX filter is ONLY for comic-text-segmenter (yolo_model), which has:
703
- # 0: balloon, 1: qipao, 2: fangkuai, 3: changfangtiao, 4: kuangwai (free text/SFX), 5: other
704
- # AnimeText has different class IDs - do NOT filter by class for it.
 
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 = text_detector_model.predict(
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 = text_detector_model.predict(
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 = text_detector_model.predict(
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
- def add_tile(tile: np.ndarray, y_offset: int) -> None:
1022
- tile_height, tile_width = tile.shape[:2]
1023
- results = bubble_seg_model.predict(
1024
- source=tile[:, :, ::-1],
1025
- conf=BUBSEG_CONF,
1026
- iou=YOLO_IOU,
1027
- verbose=False,
1028
- device=dev_target,
1029
- retina_masks=(task_type == "segment"),
1030
- )
1031
- for result in results:
1032
- if result.boxes is None:
1033
- continue
1034
- if task_type == "segment" and result.masks is not None:
1035
- for seg_mask in result.masks.data:
1036
- seg_np = (seg_mask.cpu().numpy() > 0.5).astype(np.uint8) * 255
1037
- seg_np = cv2.resize(seg_np, (tile_width, tile_height), interpolation=cv2.INTER_NEAREST)
1038
- interior[y_offset:y_offset + tile_height, :] = cv2.bitwise_or(
1039
- interior[y_offset:y_offset + tile_height, :],
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 img_out
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 = 6
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
- kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
1117
- dilated_mask = cv2.dilate(crop_mask, kernel, iterations=1)
1118
- blend_mask = cv2.GaussianBlur(dilated_mask, (5, 5), 0).astype(np.float32) / 255.0
1119
- blend_mask = np.expand_dims(blend_mask, axis=-1)
1120
-
1121
- blended = (out_img_orig.astype(np.float32) * blend_mask + crop_img.astype(np.float32) * (1.0 - blend_mask))
1122
- img_out[y0:y1, x0:x1] = np.clip(blended, 0, 255).astype(np.uint8)
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
- dark_s = cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, block_size_small, c_constant)
1250
- dark_l = cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, block_size_large, c_constant)
1251
- _, dark_o = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
1252
- light_s = cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, block_size_small, c_constant)
1253
- light_l = cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, block_size_large, c_constant)
1254
- _, light_o = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
1255
-
1256
- dark_text = cv2.bitwise_or(cv2.bitwise_or(dark_s, dark_l), dark_o)
1257
- light_text = cv2.bitwise_or(cv2.bitwise_or(light_s, light_l), light_o)
 
 
 
 
 
 
1258
  close_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
1259
- dark_text = cv2.morphologyEx(dark_text, cv2.MORPH_CLOSE, close_k)
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
- candidates = [
1265
- cv2.bitwise_and(dark_text, spatial_guide),
1266
- cv2.bitwise_and(light_text, spatial_guide),
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 = False,
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
- masks = _prepare_cleaning_masks(img_bgr, img_rgb, dilate_iter, remove_sfx, c_constant)
1324
- if masks["coarse"].max() > 0:
1325
- img_clean = img_rgb.copy()
1326
- if masks["legacy"].max() > 0:
1327
- img_clean = _hybrid_inpaint(img_clean, masks["legacy"])
1328
- if masks["complex"].max() > 0:
1329
- img_clean = _lama_inpaint_safe_regions(img_clean, masks["complex"])
1330
- if masks["excluded"].max() > 0:
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"),