multimodalart HF Staff commited on
Commit
d7eb14f
·
verified ·
1 Parent(s): 5a7253b

Palette keying: explain each pixel as one flat color composited over the backdrop; guarantees no key color survives

Browse files
Files changed (2) hide show
  1. app.py +28 -1
  2. 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
- ) -> tuple[list[np.ndarray], np.ndarray]:
 
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 = [key_frame(f, key_rgb, tolerance, softness, despill, despeckle, shrink, feather) for f in frames]
 
 
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