mmo9 commited on
Commit
849d77c
·
verified ·
1 Parent(s): c66dc60

Feat: Universal 2D Spatial Residual Inpainting (zero boundaries, perfect gradients & transparency)

Browse files
Files changed (1) hide show
  1. app.py +68 -20
app.py CHANGED
@@ -693,8 +693,11 @@ def _build_text_mask(image_bgr: np.ndarray, dilate_iter: int = 3, remove_sfx: bo
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
 
700
  def _run_yolo_pass(conf_val: float) -> np.ndarray:
@@ -882,7 +885,8 @@ def _lama_inpaint_tile(img_rgb: np.ndarray, mask: np.ndarray, size: int = 512) -
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)
@@ -898,9 +902,20 @@ def _lama_inpaint_tile(img_rgb: np.ndarray, mask: np.ndarray, size: int = 512) -
898
  if ch == 0 or cw == 0:
899
  continue
900
 
901
- # Resize to fixed 512x512 expected by LaMa ONNX model
902
- crop_img_512 = cv2.resize(crop_img, (size, size), interpolation=cv2.INTER_CUBIC)
903
- crop_mask_512 = cv2.resize(crop_mask, (size, size), interpolation=cv2.INTER_NEAREST)
 
 
 
 
 
 
 
 
 
 
 
904
 
905
  crop_img_t = crop_img_512.transpose(2, 0, 1)[np.newaxis].astype(np.float32) / 255.0
906
  crop_mask_t = (crop_mask_512[np.newaxis, np.newaxis] > 127).astype(np.float32)
@@ -911,25 +926,58 @@ def _lama_inpaint_tile(img_rgb: np.ndarray, mask: np.ndarray, size: int = 512) -
911
  })[0]
912
 
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
- # SMART FEATHER: hard mask for ALL inner text pixels (no ghost text),
917
- # + thin 2px soft feather ONLY at the outer boundary (no sharp visible edges).
918
- # Step 1: inner mask = pixels that were definitely text (hard replacement)
919
  inner_mask = (crop_mask > 127).astype(np.uint8)
920
- # Step 2: compute 2px outer boundary ring of the mask
921
- boundary_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
922
- dilated_boundary = cv2.dilate(inner_mask, boundary_kernel, iterations=1)
923
- boundary_ring = (dilated_boundary - inner_mask).astype(np.float32) # only outer edge
924
- # Step 3: soft feather weight for the boundary ring only (0→1 over 2px)
925
- feather = cv2.GaussianBlur(boundary_ring, (5, 5), 1.0)
926
- feather_3ch = np.stack([feather, feather, feather], axis=-1)
927
- # Step 4: inner = hard LaMa, boundary = soft blend, outside = original
928
- inner_3ch = np.stack([inner_mask.astype(np.float32)] * 3, axis=-1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
929
  result_crop = (
930
- inner_3ch * out_img_orig.astype(np.float32)
931
- + feather_3ch * out_img_orig.astype(np.float32)
932
- + (1.0 - inner_3ch - feather_3ch) * crop_img.astype(np.float32)
933
  )
934
  img_out[y0:y1, x0:x1] = np.clip(result_crop, 0, 255).astype(np.uint8)
935
 
 
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
+ allowed_classes = bubble_classes if not remove_sfx else (bubble_classes | sfx_classes)
697
 
698
  def _check_conf(c_int: int, conf: float, base_conf: float) -> bool:
699
+ if c_int not in allowed_classes:
700
+ return False
701
  return conf >= base_conf
702
 
703
  def _run_yolo_pass(conf_val: float) -> np.ndarray:
 
885
 
886
  for cnt in contours:
887
  rx, ry, rw, rh = cv2.boundingRect(cnt)
888
+ # Smart wide context: give LaMa surrounding gradient context while keeping replacement constrained
889
+ pad = min(48, max(24, max(rw, rh) // 6))
890
  x0 = max(0, rx - pad)
891
  y0 = max(0, ry - pad)
892
  x1 = min(w, rx + rw + pad)
 
902
  if ch == 0 or cw == 0:
903
  continue
904
 
905
+ # Aspect-Ratio Preserved Letterboxing:
906
+ # Instead of squishing rectangles to 512x512, pad to square S x S to preserve isotropic gradients
907
+ S = max(ch, cw)
908
+ pad_top = (S - ch) // 2
909
+ pad_bottom = S - ch - pad_top
910
+ pad_left = (S - cw) // 2
911
+ pad_right = S - cw - pad_left
912
+
913
+ crop_img_sq = cv2.copyMakeBorder(crop_img, pad_top, pad_bottom, pad_left, pad_right, cv2.BORDER_REFLECT_101)
914
+ crop_mask_sq = cv2.copyMakeBorder(crop_mask, pad_top, pad_bottom, pad_left, pad_right, cv2.BORDER_CONSTANT, value=0)
915
+
916
+ # Resize square to 512x512 expected by LaMa ONNX model
917
+ crop_img_512 = cv2.resize(crop_img_sq, (size, size), interpolation=cv2.INTER_CUBIC)
918
+ crop_mask_512 = cv2.resize(crop_mask_sq, (size, size), interpolation=cv2.INTER_NEAREST)
919
 
920
  crop_img_t = crop_img_512.transpose(2, 0, 1)[np.newaxis].astype(np.float32) / 255.0
921
  crop_mask_t = (crop_mask_512[np.newaxis, np.newaxis] > 127).astype(np.float32)
 
926
  })[0]
927
 
928
  out_img_512 = np.clip(out[0].transpose(1, 2, 0) * 255.0, 0.0, 255.0).astype(np.uint8)
929
+ out_img_sq = cv2.resize(out_img_512, (S, S), interpolation=cv2.INTER_CUBIC)
930
+
931
+ # Unpad back to original (ch, cw)
932
+ out_img_orig = out_img_sq[pad_top : pad_top + ch, pad_left : pad_left + cw]
933
 
 
 
 
934
  inner_mask = (crop_mask > 127).astype(np.uint8)
935
+ if inner_mask.max() == 0:
936
+ continue
937
+
938
+ # 2D SPATIAL RESIDUAL HARMONIZATION (Universal Gradient & Transparency Support):
939
+ # Computes 2D color residual at surrounding boundary and interpolates it smoothly across the hole
940
+ context_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
941
+ dilated_ctx = cv2.dilate(inner_mask, context_k, iterations=2)
942
+ context_ring = (dilated_ctx > 0) & (inner_mask == 0)
943
+
944
+ # Edge-Aware Filtering: Exclude black stroke borders/outlines from the context ring
945
+ crop_gray = cv2.cvtColor(crop_img, cv2.COLOR_RGB2GRAY)
946
+ edge_map = cv2.Canny(crop_gray, 40, 120)
947
+ dilated_edge = cv2.dilate(edge_map, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)))
948
+ clean_context_ring = context_ring & (dilated_edge == 0)
949
+ if clean_context_ring.sum() > 20:
950
+ active_context = clean_context_ring
951
+ else:
952
+ active_context = context_ring
953
+
954
+ if np.any(active_context):
955
+ # Compute 2D residual field
956
+ raw_residual = np.zeros_like(crop_img, dtype=np.float32)
957
+ raw_residual[active_context] = crop_img[active_context].astype(np.float32) - out_img_orig[active_context].astype(np.float32)
958
+
959
+ # Propagate 2D smooth error into the mask area using Telea inpainting on the residual field per channel
960
+ # Shift by +128 to keep float values in valid uint8 range for cv2.inpaint
961
+ res_uint8 = np.clip(raw_residual + 128.0, 0, 255).astype(np.uint8)
962
+ smooth_res_uint8 = cv2.inpaint(res_uint8, inner_mask, inpaintRadius=15, flags=cv2.INPAINT_TELEA)
963
+ smooth_residual = smooth_res_uint8.astype(np.float32) - 128.0
964
+
965
+ out_img_calibrated = np.clip(out_img_orig.astype(np.float32) + smooth_residual, 0.0, 255.0)
966
+ else:
967
+ out_img_calibrated = out_img_orig.astype(np.float32)
968
+
969
+ # SEAMLESS BOUNDARY TRANSITION:
970
+ # Distance transform inside mask
971
+ dist_in = cv2.distanceTransform(inner_mask, cv2.DIST_L2, 3)
972
+ # Weight map: 0.0 at outer edge -> 1.0 at >= 2.0px inside
973
+ weight = np.clip(dist_in / 2.0, 0.0, 1.0)
974
+ # Smooth Hermite curve (3t^2 - 2t^3) for seamless C1 gradient continuity
975
+ smooth_weight = weight * weight * (3.0 - 2.0 * weight)
976
+ weight_3ch = np.stack([smooth_weight] * 3, axis=-1)
977
+
978
  result_crop = (
979
+ weight_3ch * out_img_calibrated
980
+ + (1.0 - weight_3ch) * crop_img.astype(np.float32)
 
981
  )
982
  img_out[y0:y1, x0:x1] = np.clip(result_crop, 0, 255).astype(np.uint8)
983