Spaces:
Running
Running
Palette keying: explain each pixel as one flat color composited over the backdrop; guarantees no key color survives
Browse files- app.py +28 -1
- spritekit.py +118 -2
app.py
CHANGED
|
@@ -22,6 +22,8 @@ import time
|
|
| 22 |
import traceback
|
| 23 |
|
| 24 |
import gradio as gr
|
|
|
|
|
|
|
| 25 |
|
| 26 |
import backends
|
| 27 |
import moves as move_defs
|
|
@@ -87,6 +89,7 @@ def forge(
|
|
| 87 |
despill,
|
| 88 |
despeckle,
|
| 89 |
shrink,
|
|
|
|
| 90 |
playback_fps,
|
| 91 |
sprite_height,
|
| 92 |
request: gr.Request,
|
|
@@ -211,6 +214,25 @@ def forge(
|
|
| 211 |
yield PENDING, character, gallery, None, [], None, f"{len(clips)} clips generated. Cutting to pose extremes…"
|
| 212 |
|
| 213 |
# ---- 3. key, cut to extremes, pack ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
packed, previews, key_hex = [], [], None
|
| 215 |
for i, clip in enumerate(clips):
|
| 216 |
progress(0.75 + 0.2 * i / len(clips), desc=f"Keying {clip['label']}")
|
|
@@ -222,6 +244,7 @@ def forge(
|
|
| 222 |
despill=float(despill),
|
| 223 |
despeckle=int(despeckle),
|
| 224 |
shrink=float(shrink),
|
|
|
|
| 225 |
)
|
| 226 |
key_hex = "#%02X%02X%02X" % tuple((key_rgb * 255).astype(int))
|
| 227 |
|
|
@@ -343,6 +366,10 @@ with gr.Blocks(title="Sprite Forge") as demo:
|
|
| 343 |
with gr.Row():
|
| 344 |
despill = gr.Slider(0, 1, value=1.0, step=0.05, label="Despill")
|
| 345 |
despeckle = gr.Slider(0, 4, value=2, step=1, label="Despeckle")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
shrink = gr.Slider(
|
| 347 |
0, 0.5, value=0.3, step=0.05, label="Matte shrink",
|
| 348 |
info="Chokes the matte inward against 4:2:0 chroma bleed.",
|
|
@@ -364,7 +391,7 @@ with gr.Blocks(title="Sprite Forge") as demo:
|
|
| 364 |
forge,
|
| 365 |
[
|
| 366 |
subject, uploaded, style, image_backend, canvas, selected_moves, duration, steps, seed,
|
| 367 |
-
tolerance, softness, despill, despeckle, shrink, playback_fps, sprite_height,
|
| 368 |
],
|
| 369 |
[demo_frame, character_preview, clip_gallery, sheet_image, sprite_previews, downloads, status],
|
| 370 |
)
|
|
|
|
| 22 |
import traceback
|
| 23 |
|
| 24 |
import gradio as gr
|
| 25 |
+
import numpy as np
|
| 26 |
+
from PIL import Image
|
| 27 |
|
| 28 |
import backends
|
| 29 |
import moves as move_defs
|
|
|
|
| 89 |
despill,
|
| 90 |
despeckle,
|
| 91 |
shrink,
|
| 92 |
+
palette_size,
|
| 93 |
playback_fps,
|
| 94 |
sprite_height,
|
| 95 |
request: gr.Request,
|
|
|
|
| 214 |
yield PENDING, character, gallery, None, [], None, f"{len(clips)} clips generated. Cutting to pose extremes…"
|
| 215 |
|
| 216 |
# ---- 3. key, cut to extremes, pack ----
|
| 217 |
+
# One palette for the whole character, read before any keying. Shared across every move so the sheet cannot
|
| 218 |
+
# drift in color between rows, and seeded from the character still because that is a PNG — the only view of the
|
| 219 |
+
# art that has not been through H.264 chroma subsampling.
|
| 220 |
+
palette = None
|
| 221 |
+
if int(palette_size) > 0:
|
| 222 |
+
progress(0.72, desc="Reading the character's palette")
|
| 223 |
+
samples = []
|
| 224 |
+
still = np.asarray(Image.open(character).convert("RGB"), np.float32) / 255.0
|
| 225 |
+
samples.append(sk.sample_interior(still, sk.detect_key_color(still), 6000))
|
| 226 |
+
for clip in clips:
|
| 227 |
+
frames, _fps = sk.read_video(clip["video"])
|
| 228 |
+
clip_key = np.median(np.stack([sk.detect_key_color(f) for f in frames]), axis=0)
|
| 229 |
+
for j in {0, len(frames) // 2, len(frames) - 1}:
|
| 230 |
+
samples.append(sk.sample_interior(frames[j], clip_key, 2000, seed=j))
|
| 231 |
+
del frames
|
| 232 |
+
stacked = np.concatenate([s for s in samples if len(s)]) if any(len(s) for s in samples) else None
|
| 233 |
+
if stacked is not None and len(stacked):
|
| 234 |
+
palette = sk.extract_palette(stacked, int(palette_size))
|
| 235 |
+
|
| 236 |
packed, previews, key_hex = [], [], None
|
| 237 |
for i, clip in enumerate(clips):
|
| 238 |
progress(0.75 + 0.2 * i / len(clips), desc=f"Keying {clip['label']}")
|
|
|
|
| 244 |
despill=float(despill),
|
| 245 |
despeckle=int(despeckle),
|
| 246 |
shrink=float(shrink),
|
| 247 |
+
palette=palette,
|
| 248 |
)
|
| 249 |
key_hex = "#%02X%02X%02X" % tuple((key_rgb * 255).astype(int))
|
| 250 |
|
|
|
|
| 366 |
with gr.Row():
|
| 367 |
despill = gr.Slider(0, 1, value=1.0, step=0.05, label="Despill")
|
| 368 |
despeckle = gr.Slider(0, 4, value=2, step=1, label="Despeckle")
|
| 369 |
+
palette_size = gr.Slider(
|
| 370 |
+
0, 32, value=16, step=2, label="Palette colors",
|
| 371 |
+
info="Flat-art keying: 0 falls back to plain chroma keying.",
|
| 372 |
+
)
|
| 373 |
shrink = gr.Slider(
|
| 374 |
0, 0.5, value=0.3, step=0.05, label="Matte shrink",
|
| 375 |
info="Chokes the matte inward against 4:2:0 chroma bleed.",
|
|
|
|
| 391 |
forge,
|
| 392 |
[
|
| 393 |
subject, uploaded, style, image_backend, canvas, selected_moves, duration, steps, seed,
|
| 394 |
+
tolerance, softness, despill, despeckle, shrink, palette_size, playback_fps, sprite_height,
|
| 395 |
],
|
| 396 |
[demo_frame, character_preview, clip_gallery, sheet_image, sprite_previews, downloads, status],
|
| 397 |
)
|
spritekit.py
CHANGED
|
@@ -181,6 +181,107 @@ def extend_edges(rgb: np.ndarray, alpha: np.ndarray, radius: int = 3) -> np.ndar
|
|
| 181 |
return (rgb * weight + extended * (1.0 - weight)).astype(np.float32)
|
| 182 |
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
def suppress_residual(
|
| 185 |
rgb: np.ndarray,
|
| 186 |
alpha: np.ndarray,
|
|
@@ -252,6 +353,7 @@ def key_frame(
|
|
| 252 |
despeckle: int = 2,
|
| 253 |
shrink: float = 0.2,
|
| 254 |
feather: float = 0.6,
|
|
|
|
| 255 |
) -> np.ndarray:
|
| 256 |
"""Returns RGBA float 0..1.
|
| 257 |
|
|
@@ -266,6 +368,17 @@ def key_frame(
|
|
| 266 |
|
| 267 |
So: recover color against the truth, then hand out the tidied matte.
|
| 268 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
raw = matte(frame, key_rgb, tolerance, softness)
|
| 270 |
refined = refine_matte(raw, despeckle, shrink, feather)
|
| 271 |
rgb = unmix(frame, raw, key_rgb, despill)
|
|
@@ -286,7 +399,8 @@ def key_clip(
|
|
| 286 |
despeckle: int = 2,
|
| 287 |
shrink: float = 0.2,
|
| 288 |
feather: float = 0.6,
|
| 289 |
-
|
|
|
|
| 290 |
"""Key every frame against the clip's **median** backdrop, and report which frames that backdrop still describes.
|
| 291 |
|
| 292 |
MiniMax-H3 is asked to hold the backdrop flat and unchanged and mostly does — but not always. A run of this
|
|
@@ -310,7 +424,9 @@ def key_clip(
|
|
| 310 |
# A well-behaved clip drifts by a couple of levels; the magenta->green slide was a distance of ~0.8. Anything
|
| 311 |
# past this is a different backdrop, not noise.
|
| 312 |
reliable = [i for i in range(len(frames)) if float(np.linalg.norm(measured[i] - key_rgb)) < 0.15]
|
| 313 |
-
keyed = [
|
|
|
|
|
|
|
| 314 |
return keyed, key_rgb, (reliable or list(range(len(frames))))
|
| 315 |
|
| 316 |
|
|
|
|
| 181 |
return (rgb * weight + extended * (1.0 - weight)).astype(np.float32)
|
| 182 |
|
| 183 |
|
| 184 |
+
def _kmeans(points: np.ndarray, k: int, iterations: int = 12, seed: int = 0) -> np.ndarray:
|
| 185 |
+
"""Small k-means with k-means++ seeding. Enough for a dozen flat colors out of a few thousand samples."""
|
| 186 |
+
rng = np.random.default_rng(seed)
|
| 187 |
+
centers = [points[rng.integers(len(points))]]
|
| 188 |
+
nearest = ((points - centers[0]) ** 2).sum(1)
|
| 189 |
+
for _ in range(k - 1):
|
| 190 |
+
total = float(nearest.sum())
|
| 191 |
+
if total < 1e-12:
|
| 192 |
+
break
|
| 193 |
+
centers.append(points[rng.choice(len(points), p=nearest / total)])
|
| 194 |
+
nearest = np.minimum(nearest, ((points - centers[-1]) ** 2).sum(1))
|
| 195 |
+
current = np.stack(centers)
|
| 196 |
+
for _ in range(iterations):
|
| 197 |
+
labels = np.argmin(((points[:, None, :] - current[None]) ** 2).sum(2), axis=1)
|
| 198 |
+
for i in range(len(current)):
|
| 199 |
+
member = labels == i
|
| 200 |
+
if member.any():
|
| 201 |
+
current[i] = points[member].mean(0)
|
| 202 |
+
return current.astype(np.float32)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def sample_interior(
|
| 206 |
+
frame: np.ndarray,
|
| 207 |
+
key_rgb: np.ndarray,
|
| 208 |
+
limit: int = 4000,
|
| 209 |
+
seed: int = 0,
|
| 210 |
+
) -> np.ndarray:
|
| 211 |
+
"""Confidently-interior pixels of one frame, for palette extraction.
|
| 212 |
+
|
| 213 |
+
Eroded hard — only pixels whose whole neighbourhood is opaque qualify. The palette must describe the character's
|
| 214 |
+
real flat colors, and a single edge pixel dragged in by 4:2:0 bleed would add a magenta-tinted entry that then
|
| 215 |
+
licenses magenta everywhere.
|
| 216 |
+
"""
|
| 217 |
+
alpha = matte(frame, key_rgb)
|
| 218 |
+
interior = _box_blur(alpha[..., None], 3)[..., 0] > 0.995
|
| 219 |
+
points = frame[interior]
|
| 220 |
+
if len(points) == 0:
|
| 221 |
+
return points.reshape(0, 3)
|
| 222 |
+
if len(points) > limit:
|
| 223 |
+
points = points[np.random.default_rng(seed).choice(len(points), limit, replace=False)]
|
| 224 |
+
return points
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def extract_palette(samples: np.ndarray, size: int = 16) -> np.ndarray:
|
| 228 |
+
"""The character's flat palette, as ``(k, 3)`` float RGB."""
|
| 229 |
+
if len(samples) < size:
|
| 230 |
+
return samples.reshape(-1, 3).astype(np.float32)
|
| 231 |
+
return _kmeans(samples.astype(np.float32), size)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def palette_matte(
|
| 235 |
+
frame: np.ndarray,
|
| 236 |
+
palette: np.ndarray,
|
| 237 |
+
key_rgb: np.ndarray,
|
| 238 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 239 |
+
"""Explain every pixel as *one palette color composited over the backdrop*. Returns ``(alpha, rgb)``.
|
| 240 |
+
|
| 241 |
+
This is the only step here that can *guarantee* the backdrop is gone, and it earns that by exploiting something
|
| 242 |
+
the earlier chroma keying ignored: the source art is flat, so the character is made of a dozen known colors and
|
| 243 |
+
nothing in between. Every in-between color in the decoded clip is therefore an artifact of H.264 — a blend of a
|
| 244 |
+
real color with the backdrop — and is exactly what a composite equation describes.
|
| 245 |
+
|
| 246 |
+
For each palette color ``P`` the coverage that best explains the pixel is a projection onto the segment from the
|
| 247 |
+
backdrop to that color::
|
| 248 |
+
|
| 249 |
+
a = clamp(<observed - key, P - key> / |P - key|^2, 0, 1)
|
| 250 |
+
|
| 251 |
+
and the residual says how well it explains it. Take the palette color with the smallest residual.
|
| 252 |
+
|
| 253 |
+
Two properties follow, and they are the point:
|
| 254 |
+
|
| 255 |
+
* **A pure-backdrop pixel is explained by ``a = 0`` with zero residual, for every palette entry.** It becomes
|
| 256 |
+
fully transparent no matter what its chroma got smeared into — which is precisely the rim that survived
|
| 257 |
+
choking, un-mixing and edge extension, all of which are identities at ``a == 1``.
|
| 258 |
+
* **The output color is always a palette color**, never a blend, so no amount of backdrop can be left in it.
|
| 259 |
+
|
| 260 |
+
It also removes the failure mode of `suppress_residual`: a character that genuinely *is* near the key color has
|
| 261 |
+
that color in its palette, so it matches at ``a = 1`` and is preserved rather than eroded.
|
| 262 |
+
"""
|
| 263 |
+
offset = frame - key_rgb
|
| 264 |
+
directions = palette.astype(np.float32) - key_rgb
|
| 265 |
+
lengths = np.maximum((directions ** 2).sum(1), 1e-8)
|
| 266 |
+
|
| 267 |
+
best_residual = np.full(frame.shape[:2], np.inf, np.float32)
|
| 268 |
+
best_alpha = np.zeros(frame.shape[:2], np.float32)
|
| 269 |
+
best_color = np.zeros_like(frame)
|
| 270 |
+
|
| 271 |
+
# Looped over the palette rather than broadcast: a (H, W, k, 3) tensor is hundreds of MB at 768^2 and this Space
|
| 272 |
+
# runs on 2 vCPU / 16 GB.
|
| 273 |
+
for i in range(len(palette)):
|
| 274 |
+
direction = directions[i]
|
| 275 |
+
alpha = np.clip((offset @ direction) / lengths[i], 0.0, 1.0)
|
| 276 |
+
residual = np.linalg.norm(offset - alpha[..., None] * direction, axis=-1)
|
| 277 |
+
better = residual < best_residual
|
| 278 |
+
best_residual = np.where(better, residual, best_residual)
|
| 279 |
+
best_alpha = np.where(better, alpha, best_alpha)
|
| 280 |
+
best_color = np.where(better[..., None], palette[i], best_color)
|
| 281 |
+
|
| 282 |
+
return best_alpha.astype(np.float32), best_color.astype(np.float32)
|
| 283 |
+
|
| 284 |
+
|
| 285 |
def suppress_residual(
|
| 286 |
rgb: np.ndarray,
|
| 287 |
alpha: np.ndarray,
|
|
|
|
| 353 |
despeckle: int = 2,
|
| 354 |
shrink: float = 0.2,
|
| 355 |
feather: float = 0.6,
|
| 356 |
+
palette: np.ndarray | None = None,
|
| 357 |
) -> np.ndarray:
|
| 358 |
"""Returns RGBA float 0..1.
|
| 359 |
|
|
|
|
| 368 |
|
| 369 |
So: recover color against the truth, then hand out the tidied matte.
|
| 370 |
"""
|
| 371 |
+
if palette is not None and len(palette):
|
| 372 |
+
raw, matched = palette_matte(frame, palette, key_rgb)
|
| 373 |
+
refined = refine_matte(raw, despeckle, 0.0, feather)
|
| 374 |
+
# Snap only where the composite is partial. A fully opaque pixel is interior and keeps its original color,
|
| 375 |
+
# so soft shading and small details survive; the transition band takes the palette color, which is what
|
| 376 |
+
# makes a backdrop-colored edge impossible. No choke is applied — palette matting derives coverage from the
|
| 377 |
+
# full RGB rather than from chroma alone, so it does not need one.
|
| 378 |
+
keep = _smoothstep(0.90, 1.0, refined)[..., None]
|
| 379 |
+
rgb = matched * (1.0 - keep) + frame * keep
|
| 380 |
+
return np.concatenate([rgb, refined[..., None]], axis=-1).astype(np.float32)
|
| 381 |
+
|
| 382 |
raw = matte(frame, key_rgb, tolerance, softness)
|
| 383 |
refined = refine_matte(raw, despeckle, shrink, feather)
|
| 384 |
rgb = unmix(frame, raw, key_rgb, despill)
|
|
|
|
| 399 |
despeckle: int = 2,
|
| 400 |
shrink: float = 0.2,
|
| 401 |
feather: float = 0.6,
|
| 402 |
+
palette: np.ndarray | None = None,
|
| 403 |
+
) -> tuple[list[np.ndarray], np.ndarray, list[int]]:
|
| 404 |
"""Key every frame against the clip's **median** backdrop, and report which frames that backdrop still describes.
|
| 405 |
|
| 406 |
MiniMax-H3 is asked to hold the backdrop flat and unchanged and mostly does — but not always. A run of this
|
|
|
|
| 424 |
# A well-behaved clip drifts by a couple of levels; the magenta->green slide was a distance of ~0.8. Anything
|
| 425 |
# past this is a different backdrop, not noise.
|
| 426 |
reliable = [i for i in range(len(frames)) if float(np.linalg.norm(measured[i] - key_rgb)) < 0.15]
|
| 427 |
+
keyed = [
|
| 428 |
+
key_frame(f, key_rgb, tolerance, softness, despill, despeckle, shrink, feather, palette) for f in frames
|
| 429 |
+
]
|
| 430 |
return keyed, key_rgb, (reliable or list(range(len(frames))))
|
| 431 |
|
| 432 |
|