Spaces:
Running on Zero
Running on Zero
Feat: Universal 2D Spatial Residual Inpainting (zero boundaries, perfect gradients & transparency)
Browse files
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 |
-
|
|
|
|
| 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 |
-
#
|
| 902 |
-
|
| 903 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
#
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 929 |
result_crop = (
|
| 930 |
-
|
| 931 |
-
+
|
| 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 |
|