Spaces:
Running
Running
Fix pink halo: finish the matte (despeckle/choke/feather) before un-mixing color; expose matte shrink
Browse files- app.py +7 -1
- spritekit.py +68 -52
app.py
CHANGED
|
@@ -74,6 +74,7 @@ def forge(
|
|
| 74 |
softness,
|
| 75 |
despill,
|
| 76 |
despeckle,
|
|
|
|
| 77 |
playback_fps,
|
| 78 |
sprite_height,
|
| 79 |
request: gr.Request,
|
|
@@ -148,6 +149,7 @@ def forge(
|
|
| 148 |
softness=float(softness),
|
| 149 |
despill=float(despill),
|
| 150 |
despeckle=int(despeckle),
|
|
|
|
| 151 |
)
|
| 152 |
key_hex = "#%02X%02X%02X" % tuple((key_rgb * 255).astype(int))
|
| 153 |
|
|
@@ -278,6 +280,10 @@ with gr.Blocks(title="Sprite Forge") as demo:
|
|
| 278 |
softness = gr.Slider(0.01, 0.25, value=0.09, step=0.01, label="Edge softness")
|
| 279 |
despill = gr.Slider(0, 1, value=1.0, step=0.05, label="Despill")
|
| 280 |
despeckle = gr.Slider(0, 4, value=2, step=1, label="Despeckle")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
|
| 282 |
demo_frame = gr.HTML(IDLE_PANEL, label="Playable")
|
| 283 |
status = gr.Markdown()
|
|
@@ -296,7 +302,7 @@ with gr.Blocks(title="Sprite Forge") as demo:
|
|
| 296 |
forge,
|
| 297 |
[
|
| 298 |
subject, uploaded, style, image_backend, canvas, selected_moves, duration, steps, seed,
|
| 299 |
-
tolerance, softness, despill, despeckle, playback_fps, sprite_height,
|
| 300 |
],
|
| 301 |
[demo_frame, character_preview, clip_gallery, sheet_image, sprite_previews, downloads, status],
|
| 302 |
)
|
|
|
|
| 74 |
softness,
|
| 75 |
despill,
|
| 76 |
despeckle,
|
| 77 |
+
shrink,
|
| 78 |
playback_fps,
|
| 79 |
sprite_height,
|
| 80 |
request: gr.Request,
|
|
|
|
| 149 |
softness=float(softness),
|
| 150 |
despill=float(despill),
|
| 151 |
despeckle=int(despeckle),
|
| 152 |
+
shrink=float(shrink),
|
| 153 |
)
|
| 154 |
key_hex = "#%02X%02X%02X" % tuple((key_rgb * 255).astype(int))
|
| 155 |
|
|
|
|
| 280 |
softness = gr.Slider(0.01, 0.25, value=0.09, step=0.01, label="Edge softness")
|
| 281 |
despill = gr.Slider(0, 1, value=1.0, step=0.05, label="Despill")
|
| 282 |
despeckle = gr.Slider(0, 4, value=2, step=1, label="Despeckle")
|
| 283 |
+
shrink = gr.Slider(
|
| 284 |
+
0, 0.5, value=0.2, step=0.05, label="Matte shrink",
|
| 285 |
+
info="Chokes the matte inward. The clip is 4:2:0, so chroma bleeds ~1px past the real edge.",
|
| 286 |
+
)
|
| 287 |
|
| 288 |
demo_frame = gr.HTML(IDLE_PANEL, label="Playable")
|
| 289 |
status = gr.Markdown()
|
|
|
|
| 302 |
forge,
|
| 303 |
[
|
| 304 |
subject, uploaded, style, image_backend, canvas, selected_moves, duration, steps, seed,
|
| 305 |
+
tolerance, softness, despill, despeckle, shrink, playback_fps, sprite_height,
|
| 306 |
],
|
| 307 |
[demo_frame, character_preview, clip_gallery, sheet_image, sprite_previews, downloads, status],
|
| 308 |
)
|
spritekit.py
CHANGED
|
@@ -87,70 +87,87 @@ def _smoothstep(edge0: float, edge1: float, x: np.ndarray) -> np.ndarray:
|
|
| 87 |
return t * t * (3.0 - 2.0 * t)
|
| 88 |
|
| 89 |
|
| 90 |
-
def
|
| 91 |
frame: np.ndarray,
|
| 92 |
key_rgb: np.ndarray,
|
| 93 |
tolerance: float = 0.10,
|
| 94 |
softness: float = 0.09,
|
| 95 |
-
despill: float = 1.0,
|
| 96 |
) -> np.ndarray:
|
| 97 |
-
"""
|
| 98 |
|
| 99 |
``tolerance`` is the chroma radius that is fully transparent and ``softness`` the width of the ramp out to fully
|
| 100 |
opaque, both as distances in the (Cb, Cr) plane whose useful range is about 0..0.7.
|
| 101 |
"""
|
| 102 |
ycc = to_ycc(frame)
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
key_chroma = key[1:]
|
| 106 |
-
key_norm = float(np.linalg.norm(key_chroma))
|
| 107 |
-
if key_norm < 1e-4:
|
| 108 |
# A neutral backdrop has no chroma to key against; nothing here can separate it from a white glove.
|
| 109 |
-
return np.
|
| 110 |
-
key_dir = key_chroma / key_norm
|
| 111 |
-
|
| 112 |
delta = ycc[..., 1:] - key_chroma
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
# Below a few percent coverage the division is mostly amplifying noise, and those pixels are invisible
|
| 131 |
-
# anyway — fade the correction out rather than letting it produce confetti.
|
| 132 |
-
trust = np.clip(alpha / 0.15, 0.0, 1.0)[..., None] * despill
|
| 133 |
-
out = frame * (1.0 - trust) + unmixed * trust
|
| 134 |
-
|
| 135 |
-
return np.concatenate([out, alpha[..., None]], axis=-1).astype(np.float32)
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
def clean_matte(rgba: np.ndarray, despeckle: int = 2, feather: float = 0.6) -> np.ndarray:
|
| 139 |
-
"""Drop isolated specks out of the matte and soften the remaining edge.
|
| 140 |
-
|
| 141 |
-
A median filter is what removes single-pixel noise without eating a whisker; an erode/dilate pair of the same
|
| 142 |
-
radius would take the whisker with it.
|
| 143 |
"""
|
| 144 |
-
if despeckle <= 0 and feather <= 0:
|
| 145 |
-
return rgba
|
| 146 |
-
alpha = Image.fromarray((np.clip(rgba[..., 3], 0, 1) * 255).astype(np.uint8), "L")
|
| 147 |
if despeckle > 0:
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
| 149 |
if feather > 0:
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
|
| 156 |
def key_clip(
|
|
@@ -160,6 +177,7 @@ def key_clip(
|
|
| 160 |
softness: float = 0.09,
|
| 161 |
despill: float = 1.0,
|
| 162 |
despeckle: int = 2,
|
|
|
|
| 163 |
feather: float = 0.6,
|
| 164 |
) -> tuple[list[np.ndarray], np.ndarray]:
|
| 165 |
"""Key every frame of a clip against **one** color measured on the first frame.
|
|
@@ -169,9 +187,7 @@ def key_clip(
|
|
| 169 |
"""
|
| 170 |
if key_rgb is None:
|
| 171 |
key_rgb = detect_key_color(frames[0])
|
| 172 |
-
keyed = [
|
| 173 |
-
clean_matte(key_out(f, key_rgb, tolerance, softness, despill), despeckle, feather) for f in frames
|
| 174 |
-
]
|
| 175 |
return keyed, key_rgb
|
| 176 |
|
| 177 |
|
|
|
|
| 87 |
return t * t * (3.0 - 2.0 * t)
|
| 88 |
|
| 89 |
|
| 90 |
+
def matte(
|
| 91 |
frame: np.ndarray,
|
| 92 |
key_rgb: np.ndarray,
|
| 93 |
tolerance: float = 0.10,
|
| 94 |
softness: float = 0.09,
|
|
|
|
| 95 |
) -> np.ndarray:
|
| 96 |
+
"""The alpha channel alone, from the chroma distance to ``key_rgb``.
|
| 97 |
|
| 98 |
``tolerance`` is the chroma radius that is fully transparent and ``softness`` the width of the ramp out to fully
|
| 99 |
opaque, both as distances in the (Cb, Cr) plane whose useful range is about 0..0.7.
|
| 100 |
"""
|
| 101 |
ycc = to_ycc(frame)
|
| 102 |
+
key_chroma = to_ycc(key_rgb[None, None, :])[0, 0][1:]
|
| 103 |
+
if float(np.linalg.norm(key_chroma)) < 1e-4:
|
|
|
|
|
|
|
|
|
|
| 104 |
# A neutral backdrop has no chroma to key against; nothing here can separate it from a white glove.
|
| 105 |
+
return np.ones(frame.shape[:2], np.float32)
|
|
|
|
|
|
|
| 106 |
delta = ycc[..., 1:] - key_chroma
|
| 107 |
+
return _smoothstep(tolerance, tolerance + max(softness, 1e-4), np.linalg.norm(delta, axis=-1))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def refine_matte(alpha: np.ndarray, despeckle: int = 2, shrink: float = 0.2, feather: float = 0.6) -> np.ndarray:
|
| 111 |
+
"""Despeckle, choke and soften the matte — all of it before any color is recovered.
|
| 112 |
+
|
| 113 |
+
A median filter removes single-pixel noise without eating a whisker, which an erode/dilate pair of the same
|
| 114 |
+
radius would not.
|
| 115 |
+
|
| 116 |
+
``shrink`` is the compositor's choke, and it is what actually kills the halo. The alpha ramp is derived from
|
| 117 |
+
chroma, and the clip arrives 4:2:0 subsampled, so chroma bleeds about a pixel past the real edge and a rim of
|
| 118 |
+
genuinely-backdrop pixels comes out faintly opaque. Rescaling the ramp so everything under ``shrink`` goes to
|
| 119 |
+
zero drops that rim.
|
| 120 |
+
|
| 121 |
+
Ordering matters more than any of the individual filters here: feathering *expands* alpha outward, and if it runs
|
| 122 |
+
after the color step it hands partial opacity to pixels that are still pure backdrop — a pink rim, reintroduced
|
| 123 |
+
by the very filter meant to tidy the edge. The matte is finished here and `unmix` is told about it.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
"""
|
|
|
|
|
|
|
|
|
|
| 125 |
if despeckle > 0:
|
| 126 |
+
image = Image.fromarray((np.clip(alpha, 0, 1) * 255).astype(np.uint8), "L")
|
| 127 |
+
alpha = np.asarray(image.filter(ImageFilter.MedianFilter(size=despeckle * 2 + 1)), np.float32) / 255.0
|
| 128 |
+
if shrink > 0:
|
| 129 |
+
alpha = np.clip((alpha - shrink) / max(1.0 - shrink, 1e-3), 0.0, 1.0)
|
| 130 |
if feather > 0:
|
| 131 |
+
image = Image.fromarray((np.clip(alpha, 0, 1) * 255).astype(np.uint8), "L")
|
| 132 |
+
alpha = np.asarray(image.filter(ImageFilter.GaussianBlur(radius=feather)), np.float32) / 255.0
|
| 133 |
+
return alpha.astype(np.float32)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def unmix(frame: np.ndarray, alpha: np.ndarray, key_rgb: np.ndarray, strength: float = 1.0) -> np.ndarray:
|
| 137 |
+
"""Recover the character's own color from an edge pixel, given the finished matte.
|
| 138 |
+
|
| 139 |
+
An edge pixel is a real alpha composite of the character over the backdrop, ``observed = a*F + (1-a)*key``, so
|
| 140 |
+
the character's color comes back out algebraically: ``F = (observed - (1-a)*key) / a``. That removes the fringe
|
| 141 |
+
rather than trading it for a grey rim, and it is an identity at ``a == 1``, so opaque interior pixels are
|
| 142 |
+
provably untouched.
|
| 143 |
+
|
| 144 |
+
That last property is why this replaced a chroma-projection despill. Projection subtracts the backdrop's chroma
|
| 145 |
+
direction from *every* pixel, and a magenta key points almost exactly along +Cr — an axis every warm color
|
| 146 |
+
shares — so an early version of this turned an orange cat green.
|
| 147 |
+
"""
|
| 148 |
+
if strength <= 0:
|
| 149 |
+
return frame
|
| 150 |
+
safe = np.maximum(alpha, 1e-3)[..., None]
|
| 151 |
+
unmixed = np.clip((frame - (1.0 - alpha)[..., None] * key_rgb) / safe, 0.0, 1.0)
|
| 152 |
+
# Below a few percent coverage the division mostly amplifies noise, and those pixels are invisible anyway —
|
| 153 |
+
# fade the correction out rather than letting it produce confetti.
|
| 154 |
+
trust = np.clip(alpha / 0.15, 0.0, 1.0)[..., None] * strength
|
| 155 |
+
return (frame * (1.0 - trust) + unmixed * trust).astype(np.float32)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def key_frame(
|
| 159 |
+
frame: np.ndarray,
|
| 160 |
+
key_rgb: np.ndarray,
|
| 161 |
+
tolerance: float = 0.10,
|
| 162 |
+
softness: float = 0.09,
|
| 163 |
+
despill: float = 1.0,
|
| 164 |
+
despeckle: int = 2,
|
| 165 |
+
shrink: float = 0.2,
|
| 166 |
+
feather: float = 0.6,
|
| 167 |
+
) -> np.ndarray:
|
| 168 |
+
"""matte -> refine -> unmix, in that order. Returns RGBA float 0..1."""
|
| 169 |
+
alpha = refine_matte(matte(frame, key_rgb, tolerance, softness), despeckle, shrink, feather)
|
| 170 |
+
return np.concatenate([unmix(frame, alpha, key_rgb, despill), alpha[..., None]], axis=-1).astype(np.float32)
|
| 171 |
|
| 172 |
|
| 173 |
def key_clip(
|
|
|
|
| 177 |
softness: float = 0.09,
|
| 178 |
despill: float = 1.0,
|
| 179 |
despeckle: int = 2,
|
| 180 |
+
shrink: float = 0.2,
|
| 181 |
feather: float = 0.6,
|
| 182 |
) -> tuple[list[np.ndarray], np.ndarray]:
|
| 183 |
"""Key every frame of a clip against **one** color measured on the first frame.
|
|
|
|
| 187 |
"""
|
| 188 |
if key_rgb is None:
|
| 189 |
key_rgb = detect_key_color(frames[0])
|
| 190 |
+
keyed = [key_frame(f, key_rgb, tolerance, softness, despill, despeckle, shrink, feather) for f in frames]
|
|
|
|
|
|
|
| 191 |
return keyed, key_rgb
|
| 192 |
|
| 193 |
|