"""Turn a MiniMax-H3 clip into game-ready sprites. Everything here is CPU work on numpy arrays — the Space itself owns no GPU. The pipeline is the second half of Victor Mustar's workflow: key out the flat backdrop, cut the clip down to its pose extremes, pack the extremes into one sheet. Keying happens in the **chroma plane of YCbCr**, not in RGB. Two properties matter and both fall out of dropping luma: * The contact shadow a text-to-image model insists on drawing under the character is the backdrop hue at a lower luminance. In the (Cb, Cr) plane it sits on top of the backdrop and keys out with it, for free. * Black outlines and white gloves — the two things a naive RGB distance destroys — are *neutral*: they sit at the origin of the chroma plane, maximally far from a saturated backdrop. A rubber-hose character is mostly those two colors, so this is not a detail. The backdrop color is measured off the frame border rather than assumed to be ``#FF00FF``. No text-to-image model actually returns pure magenta when asked — the samples this was built against came back anywhere from ``#F0417F`` rose to ``#E845D8`` orchid — and keying against the requested color instead of the delivered one is the single easiest way to get a matte full of holes. """ from __future__ import annotations import base64 import json import math import os from dataclasses import dataclass, field import numpy as np from PIL import Image, ImageFilter # --------------------------------------------------------------------------- # color # --------------------------------------------------------------------------- # ITU-R BT.601, the matrix JPEG/MPEG chroma subsampling is defined against — which is what the clip was encoded with. _R2Y = np.array( [ [0.299, 0.587, 0.114], [-0.168736, -0.331264, 0.5], [0.5, -0.418688, -0.081312], ], dtype=np.float32, ) _Y2R = np.linalg.inv(_R2Y).astype(np.float32) def to_ycc(rgb: np.ndarray) -> np.ndarray: """``(..., 3)`` float RGB in 0..1 -> YCbCr with luma in 0..1 and chroma in -0.5..0.5.""" return rgb @ _R2Y.T def to_rgb(ycc: np.ndarray) -> np.ndarray: return np.clip(ycc @ _Y2R.T, 0.0, 1.0) def detect_key_color(frame: np.ndarray, border: float = 0.06) -> np.ndarray: """Measure the backdrop off the frame border, as float RGB in 0..1. The median over a ring — not the mean, and not a corner sample. A character whose foot or tail crosses into the ring shifts a mean; it has to cover more than half the ring's pixels to shift the median, which no centered sprite does. """ h, w = frame.shape[:2] bh, bw = max(1, int(h * border)), max(1, int(w * border)) ring = np.concatenate( [ frame[:bh].reshape(-1, 3), frame[-bh:].reshape(-1, 3), frame[:, :bw].reshape(-1, 3), frame[:, -bw:].reshape(-1, 3), ] ) return np.median(ring, axis=0) # --------------------------------------------------------------------------- # keying # --------------------------------------------------------------------------- def _smoothstep(edge0: float, edge1: float, x: np.ndarray) -> np.ndarray: if edge1 <= edge0: return (x > edge0).astype(np.float32) t = np.clip((x - edge0) / (edge1 - edge0), 0.0, 1.0) return t * t * (3.0 - 2.0 * t) def matte( frame: np.ndarray, key_rgb: np.ndarray, tolerance: float = 0.10, softness: float = 0.09, ) -> np.ndarray: """The alpha channel alone, from the chroma distance to ``key_rgb``. ``tolerance`` is the chroma radius that is fully transparent and ``softness`` the width of the ramp out to fully opaque, both as distances in the (Cb, Cr) plane whose useful range is about 0..0.7. """ ycc = to_ycc(frame) key_chroma = to_ycc(key_rgb[None, None, :])[0, 0][1:] if float(np.linalg.norm(key_chroma)) < 1e-4: # A neutral backdrop has no chroma to key against; nothing here can separate it from a white glove. return np.ones(frame.shape[:2], np.float32) delta = ycc[..., 1:] - key_chroma return _smoothstep(tolerance, tolerance + max(softness, 1e-4), np.linalg.norm(delta, axis=-1)) def refine_matte(alpha: np.ndarray, despeckle: int = 2, shrink: float = 0.2, feather: float = 0.6) -> np.ndarray: """Despeckle, choke and soften the matte — all of it before any color is recovered. A median filter removes single-pixel noise without eating a whisker, which an erode/dilate pair of the same radius would not. ``shrink`` is the compositor's choke, and it is what actually kills the halo. The alpha ramp is derived from chroma, and the clip arrives 4:2:0 subsampled, so chroma bleeds about a pixel past the real edge and a rim of genuinely-backdrop pixels comes out faintly opaque. Rescaling the ramp so everything under ``shrink`` goes to zero drops that rim. Ordering matters more than any of the individual filters here: feathering *expands* alpha outward, and if it runs after the color step it hands partial opacity to pixels that are still pure backdrop — a pink rim, reintroduced by the very filter meant to tidy the edge. The matte is finished here and `unmix` is told about it. """ if despeckle > 0: image = Image.fromarray((np.clip(alpha, 0, 1) * 255).astype(np.uint8), "L") alpha = np.asarray(image.filter(ImageFilter.MedianFilter(size=despeckle * 2 + 1)), np.float32) / 255.0 if shrink > 0: alpha = np.clip((alpha - shrink) / max(1.0 - shrink, 1e-3), 0.0, 1.0) if feather > 0: image = Image.fromarray((np.clip(alpha, 0, 1) * 255).astype(np.uint8), "L") alpha = np.asarray(image.filter(ImageFilter.GaussianBlur(radius=feather)), np.float32) / 255.0 return alpha.astype(np.float32) def _box_blur(x: np.ndarray, radius: int) -> np.ndarray: """Separable box blur over the first two axes, as a difference of prefix sums. No SciPy needed.""" if radius <= 0: return x k = 2 * radius + 1 out = x.astype(np.float32) for axis in (0, 1): pad = [(0, 0)] * out.ndim pad[axis] = (radius, radius) cumulative = np.cumsum(np.pad(out, pad, mode="edge"), axis=axis) zeros = list(cumulative.shape) zeros[axis] = 1 cumulative = np.concatenate([np.zeros(zeros, np.float32), cumulative], axis=axis) size = cumulative.shape[axis] upper = np.take(cumulative, range(k, size), axis=axis) lower = np.take(cumulative, range(0, size - k), axis=axis) out = (upper - lower) / k return out def extend_edges(rgb: np.ndarray, alpha: np.ndarray, radius: int = 3) -> np.ndarray: """Push opaque color outward into the matte's transition band. This is what finally removes the rim, and it is worth being precise about why the earlier fixes could not. Un-mixing recovers the true color *given a true alpha*, but the clip arrives 4:2:0, so chroma is shared across 2x2 blocks and a backdrop pixel next to a dark outline gets its chroma dragged toward neutral. The matte reads that as partial coverage. The pixel is really 100% backdrop, so there is no foreground in it to recover — every un-mixing gets is a wild extrapolation that clips, leaving the pixel faintly opaque and still magenta. The compositor's answer is not to recover that pixel's color but to *replace* it with its neighbours'. An alpha-weighted blur samples only foreground — pixels contribute in proportion to how opaque they are — so dividing the blurred premultiplied color by the blurred alpha gives the foreground color spread outward across the edge. Blend it in by ``1 - alpha`` and the interior is untouched while the band takes the character's own color at the character's own edge. Backdrop color then appears nowhere in the output at any alpha. """ if radius <= 0: return rgb weight = alpha[..., None] blurred_color = _box_blur(rgb * weight, radius) blurred_weight = _box_blur(weight, radius) extended = blurred_color / np.maximum(blurred_weight, 1e-4) # Where nothing opaque is in reach, the division is meaningless — keep what is already there rather than # smearing a value out of noise. reachable = (blurred_weight > 1e-3).astype(np.float32) extended = np.clip(extended, 0.0, 1.0) * reachable + rgb * (1.0 - reachable) return (rgb * weight + extended * (1.0 - weight)).astype(np.float32) def _kmeans(points: np.ndarray, k: int, iterations: int = 12, seed: int = 0) -> np.ndarray: """Small k-means with k-means++ seeding. Enough for a dozen flat colors out of a few thousand samples.""" rng = np.random.default_rng(seed) centers = [points[rng.integers(len(points))]] nearest = ((points - centers[0]) ** 2).sum(1) for _ in range(k - 1): total = float(nearest.sum()) if total < 1e-12: break centers.append(points[rng.choice(len(points), p=nearest / total)]) nearest = np.minimum(nearest, ((points - centers[-1]) ** 2).sum(1)) current = np.stack(centers) for _ in range(iterations): labels = np.argmin(((points[:, None, :] - current[None]) ** 2).sum(2), axis=1) for i in range(len(current)): member = labels == i if member.any(): current[i] = points[member].mean(0) return current.astype(np.float32) def sample_interior( frame: np.ndarray, key_rgb: np.ndarray, limit: int = 4000, seed: int = 0, ) -> np.ndarray: """Confidently-interior pixels of one frame, for palette extraction. Eroded hard — only pixels whose whole neighbourhood is opaque qualify. The palette must describe the character's real flat colors, and a single edge pixel dragged in by 4:2:0 bleed would add a magenta-tinted entry that then licenses magenta everywhere. """ alpha = matte(frame, key_rgb) interior = _box_blur(alpha[..., None], 3)[..., 0] > 0.995 points = frame[interior] if len(points) == 0: return points.reshape(0, 3) if len(points) > limit: points = points[np.random.default_rng(seed).choice(len(points), limit, replace=False)] return points def extract_palette( samples: np.ndarray, size: int = 16, key_rgb: np.ndarray | None = None, min_distance: float = 0.30, ) -> np.ndarray: """The character's flat palette, as ``(k, 3)`` float RGB. **No entry may sit near the backdrop**, and that guard is what makes `palette_matte` safe rather than catastrophic. Coverage is solved as ``a = / |P - key|^2``; if some ``P`` is close to the key that denominator goes to zero, so ordinary backdrop noise divides up to ``a = 1`` and the entire background turns *opaque* in the backdrop's own color. Observed live: H3's backdrop is not perfectly uniform, and a darker patch of magenta carries *less* chroma, so the chroma matte reads it as character, it gets sampled into the palette, and the whole frame goes solid. """ points = samples.astype(np.float32).reshape(-1, 3) if key_rgb is not None and len(points): points = points[np.linalg.norm(points - key_rgb, axis=1) >= min_distance] if len(points) == 0: return np.zeros((0, 3), np.float32) palette = points if len(points) < size else _kmeans(points, size) if key_rgb is not None and len(palette): # k-means can still land a centroid near the key by averaging edge pixels; drop those too. palette = palette[np.linalg.norm(palette - key_rgb, axis=1) >= min_distance] return palette.astype(np.float32) def quantize_to_palette( frame: np.ndarray, palette: np.ndarray, key_rgb: np.ndarray | None = None, ) -> np.ndarray: """Snap every pixel of an image to its nearest palette color. The backdrop joins the palette as an exact entry. Applied to the character *still, before it is sent to H3*, this makes the art genuinely flat rather than nominally flat: gradients, film-grain speckle and the text-to-image model's own dithering all collapse onto a dozen colors. Two things follow. The backdrop becomes one exact value instead of a cloud around one, which is the assumption the whole key rests on; and a flatter source gives the video encoder large constant-color regions to work with, so there is less for chroma subsampling to smear at the edges. """ entries = palette if key_rgb is None else np.concatenate([palette, key_rgb[None, :]]) if len(entries) == 0: return frame best_distance = np.full(frame.shape[:2], np.inf, np.float32) best_index = np.zeros(frame.shape[:2], np.int32) for i, color in enumerate(entries): distance = np.linalg.norm(frame - color, axis=-1) closer = distance < best_distance best_distance = np.where(closer, distance, best_distance) best_index = np.where(closer, i, best_index) return entries[best_index].astype(np.float32) def palette_matte( frame: np.ndarray, palette: np.ndarray, key_rgb: np.ndarray, ) -> tuple[np.ndarray, np.ndarray]: """Explain every pixel as *one palette color composited over the backdrop*. Returns ``(alpha, rgb)``. This is the only step here that can *guarantee* the backdrop is gone, and it earns that by exploiting something the earlier chroma keying ignored: the source art is flat, so the character is made of a dozen known colors and nothing in between. Every in-between color in the decoded clip is therefore an artifact of H.264 — a blend of a real color with the backdrop — and is exactly what a composite equation describes. For each palette color ``P`` the coverage that best explains the pixel is a projection onto the segment from the backdrop to that color:: a = clamp( / |P - key|^2, 0, 1) and the residual says how well it explains it. Take the palette color with the smallest residual. Two properties follow, and they are the point: * **A pure-backdrop pixel is explained by ``a = 0`` with zero residual, for every palette entry.** It becomes fully transparent no matter what its chroma got smeared into — which is precisely the rim that survived choking, un-mixing and edge extension, all of which are identities at ``a == 1``. * **The output color is always a palette color**, never a blend, so no amount of backdrop can be left in it. It also removes the failure mode of `suppress_residual`: a character that genuinely *is* near the key color has that color in its palette, so it matches at ``a = 1`` and is preserved rather than eroded. """ offset = frame - key_rgb directions = palette.astype(np.float32) - key_rgb # A palette entry sitting on the key would make the projection below divide by ~0 and turn backdrop noise into # full coverage. `extract_palette` filters these out; this is the belt to that braces. usable = np.linalg.norm(directions, axis=1) >= 0.15 directions, palette = directions[usable], palette[usable] if len(palette) == 0: return np.zeros(frame.shape[:2], np.float32), np.zeros_like(frame) lengths = np.maximum((directions ** 2).sum(1), 1e-8) best_residual = np.full(frame.shape[:2], np.inf, np.float32) best_alpha = np.zeros(frame.shape[:2], np.float32) best_color = np.zeros_like(frame) # Looped over the palette rather than broadcast: a (H, W, k, 3) tensor is hundreds of MB at 768^2 and this Space # runs on 2 vCPU / 16 GB. for i in range(len(palette)): direction = directions[i] alpha = np.clip((offset @ direction) / lengths[i], 0.0, 1.0) residual = np.linalg.norm(offset - alpha[..., None] * direction, axis=-1) better = residual < best_residual best_residual = np.where(better, residual, best_residual) best_alpha = np.where(better, alpha, best_alpha) best_color = np.where(better[..., None], palette[i], best_color) return best_alpha.astype(np.float32), best_color.astype(np.float32) def suppress_residual( rgb: np.ndarray, alpha: np.ndarray, key_rgb: np.ndarray, low: float = 0.25, high: float = 0.45, ) -> np.ndarray: """Drive alpha to zero wherever the *final* color is still the backdrop. This is the pass that actually removes the outline, and it exists because `unmix` makes that outline worse rather than better. Un-mixing solves ``F = (observed - (1-a)*key)/a``. Feed it a pixel that is 100% backdrop but which the matte mis-read as half-covered and it returns the key color at *full* brightness — the pixel goes from a muted magenta at 50% opacity to a saturated magenta at 30%, which is more visible, not less. Every alpha-side tool is blind to this: choking only moves alpha, and both `unmix` and `extend_edges` are identities at ``a == 1``, so a rim pixel the matte called opaque passes through all of them untouched. So this works on the axis that is actually wrong: how far a pixel's chroma has gone along the backdrop's own direction, as a fraction of the backdrop's chroma. **It is gated to the silhouette boundary, and that gate is not optional.** Color alone cannot identify the rim. Measured across a magenta-keyed frog and a rose-keyed orange cat, the cat's *own fur* scores higher on this fraction (10.9k pixels above 0.7) than the frog's visible magenta rim does (379) — a warm subject genuinely shares the +Cr axis with a red-magenta backdrop, so any global threshold that removes the rim also dissolves the character. What separates them is position: the rim is backdrop that survived *at the edge of the matte*, while the fur is interior. Blurred alpha measures exactly that, so suppression only applies where transparency is within a pixel or two, and interior color of any hue is untouched. """ key_chroma = to_ycc(key_rgb[None, None, :])[0, 0][1:] magnitude = float(np.linalg.norm(key_chroma)) if magnitude < 1e-4: return alpha direction = key_chroma / magnitude fraction = np.einsum("...c,c->...", to_ycc(rgb)[..., 1:], direction) / magnitude # High where transparency is nearby, ~0 anywhere inside the character. The radius has to reach several pixels # inward: measured across a real edge, the rim ran two pixels deep at alpha 0.85 and 0.99, and a tighter gate # had already decayed to 0.16 by the second one and left it standing. boundary = _smoothstep(0.05, 0.35, 1.0 - _box_blur(alpha[..., None], 6)[..., 0]) return (alpha * (1.0 - boundary * _smoothstep(low, high, fraction))).astype(np.float32) def unmix(frame: np.ndarray, alpha: np.ndarray, key_rgb: np.ndarray, strength: float = 1.0) -> np.ndarray: """Recover the character's own color from an edge pixel, given the finished matte. An edge pixel is a real alpha composite of the character over the backdrop, ``observed = a*F + (1-a)*key``, so the character's color comes back out algebraically: ``F = (observed - (1-a)*key) / a``. That removes the fringe rather than trading it for a grey rim, and it is an identity at ``a == 1``, so opaque interior pixels are provably untouched. That last property is why this replaced a chroma-projection despill. Projection subtracts the backdrop's chroma direction from *every* pixel, and a magenta key points almost exactly along +Cr — an axis every warm color shares — so an early version of this turned an orange cat green. """ if strength <= 0: return frame safe = np.maximum(alpha, 1e-3)[..., None] unmixed = np.clip((frame - (1.0 - alpha)[..., None] * key_rgb) / safe, 0.0, 1.0) # Below a few percent coverage the division mostly amplifies noise, and those pixels are invisible anyway — # fade the correction out rather than letting it produce confetti. trust = np.clip(alpha / 0.15, 0.0, 1.0)[..., None] * strength return (frame * (1.0 - trust) + unmixed * trust).astype(np.float32) def key_frame( frame: np.ndarray, key_rgb: np.ndarray, tolerance: float = 0.10, softness: float = 0.09, despill: float = 1.0, despeckle: int = 2, shrink: float = 0.2, feather: float = 0.6, palette: np.ndarray | None = None, ) -> np.ndarray: """Returns RGBA float 0..1. The two alphas are deliberately different, and mixing them up is what leaves a pink rim: * ``raw`` is the measured coverage, and it is the only alpha for which ``observed = a*F + (1-a)*key`` holds. It is therefore what `unmix` must solve against. * ``refined`` is the *output* matte — despeckled, choked, feathered. Feeding it back into the un-mixing equation asks it to invert a composite that never happened: a choked rim pixel claims 6% coverage where the real composite had 25%, the division extrapolates wildly, gets clipped, and the pixel stays the backdrop's color while remaining faintly opaque. That is the halo, reintroduced one step after being removed. So: recover color against the truth, then hand out the tidied matte. """ if palette is not None and len(palette): raw, matched = palette_matte(frame, palette, key_rgb) # A light choke still earns its place: H3's backdrop drifts a little across a frame, so a background pixel # that differs slightly from the measured key solves to a small non-zero coverage rather than exactly zero. refined = refine_matte(raw, despeckle, min(float(shrink), 0.15), feather) # Snap only where the composite is partial. A fully opaque pixel is interior and keeps its original color, # so soft shading and small details survive; the transition band takes the palette color, which is what # makes a backdrop-colored edge impossible. No choke is applied — palette matting derives coverage from the # full RGB rather than from chroma alone, so it does not need one. keep = _smoothstep(0.90, 1.0, refined)[..., None] rgb = matched * (1.0 - keep) + frame * keep return np.concatenate([rgb, refined[..., None]], axis=-1).astype(np.float32) raw = matte(frame, key_rgb, tolerance, softness) refined = refine_matte(raw, despeckle, shrink, feather) rgb = unmix(frame, raw, key_rgb, despill) if despill > 0: # Suppress before extending: the extension is an alpha-weighted blur, so any backdrop-colored pixel still # holding alpha would smear its magenta into the neighbours the extension is supposed to be repairing. refined = suppress_residual(rgb, refined, key_rgb) rgb = extend_edges(rgb, refined) return np.concatenate([rgb, refined[..., None]], axis=-1).astype(np.float32) def key_clip( frames: list[np.ndarray], key_rgb: np.ndarray | None = None, tolerance: float = 0.10, softness: float = 0.09, despill: float = 1.0, despeckle: int = 2, shrink: float = 0.2, feather: float = 0.6, palette: np.ndarray | None = None, ) -> tuple[list[np.ndarray], np.ndarray, list[int]]: """Key every frame against the clip's **median** backdrop, and report which frames that backdrop still describes. MiniMax-H3 is asked to hold the backdrop flat and unchanged and mostly does — but not always. A run of this produced an idle clip whose backdrop slid from magenta to mint green partway through, and both obvious repairs are traps: * Keying every frame against frame 0 leaves the drifted frames completely un-keyed — a solid green card in the middle of the sprite sheet. * Keying each frame against *its own* border is worse. On that clip it did remove the green backdrop, and took the frog with it: the character is green too, so the drifted key matched the subject and shredded it. A drifted frame is not a keying problem, it is a bad frame. So: key everything against the median backdrop, which is stable and correct for the frames that behaved, and return the indices of the frames whose own border still matches it. There are ~73 frames and a sheet needs 4-8, so discarding the drifted ones costs nothing. Returns ``(keyed frames, key color, reliable indices)``. """ measured = np.stack([detect_key_color(f) for f in frames]) if key_rgb is None: key_rgb = np.median(measured, axis=0) # A well-behaved clip drifts by a couple of levels; the magenta->green slide was a distance of ~0.8. Anything # past this is a different backdrop, not noise. reliable = [i for i in range(len(frames)) if float(np.linalg.norm(measured[i] - key_rgb)) < 0.15] keyed = [ key_frame(f, key_rgb, tolerance, softness, despill, despeckle, shrink, feather, palette) for f in frames ] return keyed, key_rgb, (reliable or list(range(len(frames)))) # --------------------------------------------------------------------------- # pose extremes # --------------------------------------------------------------------------- def _silhouettes(frames: list[np.ndarray], size: int = 64) -> np.ndarray: """``(n, size*size)`` float occupancy, cheap enough to compare all-pairs.""" out = [] for f in frames: a = Image.fromarray((np.clip(f[..., 3], 0, 1) * 255).astype(np.uint8), "L") out.append(np.asarray(a.resize((size, size), Image.BILINEAR), np.float32).ravel() / 255.0) return np.stack(out) def pose_extremes(frames: list[np.ndarray], count: int = 8, candidates: list[int] | None = None) -> list[int]: """Pick ``count`` frame indices that read as an animator's extremes. Two things make a frame an extreme, and this uses both: * **It is held.** Motion slows to nothing where a limb reverses direction, so the local minima of frame-to-frame silhouette change are the poses the animation actually sits on. In-betweens are the fast frames. * **It is distinct.** Evenly spacing samples in *time* oversamples whatever the character does slowly and can return eight near-identical frames. Farthest-point sampling in silhouette space instead maximizes the minimum distance between chosen poses, which is the property that makes the packed sheet read as an animation. Frame 0 seeds the selection: it is the conditioning image the clip starts and ends on, so it is the rest pose the loop returns to and belongs on every sheet. """ n = len(frames) # `candidates` excludes frames the keyer could not trust (see `key_clip`). Motion is still measured across the # whole clip so speed stays meaningful; only the *choice* is restricted. allowed = sorted(set(candidates)) if candidates else list(range(n)) if len(allowed) <= count: return allowed sil = _silhouettes(frames) motion = np.concatenate([[0.0], np.linalg.norm(np.diff(sil, axis=0), axis=1)]) travelled = np.cumsum(motion) if travelled[-1] <= 0: return sorted(set(np.linspace(0, n - 1, count).round().astype(int).tolist()) & set(allowed)) or allowed[:count] # Equal steps of *accumulated* silhouette change, so samples land densely where the pose is actually changing # and sparsely through the held parts. Sampling uniformly in time instead looks mushy, and preferring the # slowest frames — which an earlier version here did — oversamples the idle bounce at the head of the clip. allowed_array = np.array(allowed) picks = { int(allowed_array[np.argmin(np.abs(travelled[allowed_array] - target))]) for target in np.linspace(0, travelled[-1], count) } return sorted(picks) def cycle_extremes( frames: list[np.ndarray], count: int = 8, candidates: list[int] | None = None, low: float = 0.2, high: float = 0.85, ) -> list[int]: """Pick ``count`` indices spanning one *seamless cycle*, for moves that repeat (walk, run). A whole-clip selection is wrong for a cycle. The clip starts at the rest pose, accelerates into the gait, and settles back at the end, because that is what the first/last-frame pin asks for. Sample across all of that and the sheet contains the start-up and the settle, so the walk visibly resets instead of looping. Instead: search the sustained middle of the clip for the pair of frames whose silhouettes match most closely — that pair *is* the seam of one gait cycle — and sample between them. """ n = len(frames) allowed = set(candidates) if candidates else set(range(n)) window = [i for i in range(int(n * low), int(n * high)) if i in allowed] if len(window) <= count: return pose_extremes(frames, count, candidates) sil = _silhouettes(frames) min_gap = max(6, n // 10) max_gap = max(min_gap + 1, n // 2) best = None for i in window: for j in window: gap = j - i if not (min_gap <= gap <= max_gap): continue distance = float(np.abs(sil[i] - sil[j]).mean()) if best is None or distance < best[0]: best = (distance, i, j) if best is None: return pose_extremes(frames, count, candidates) _distance, start, end = best return sorted({start + round(k * (end - start) / count) for k in range(count)}) # --------------------------------------------------------------------------- # trimming and packing # --------------------------------------------------------------------------- def content_bbox(frames: list[np.ndarray], threshold: float = 0.15, pad: int = 2) -> tuple[int, int, int, int]: """The union of every frame's opaque area. One box for the whole move, never per frame: cropping each pose to its own bounds re-centers the character on every frame and the sprite jitters when played back. The union keeps the character's position within the cell exactly as generated, so a walk cycle stays planted. """ h, w = frames[0].shape[:2] mask = np.zeros((h, w), bool) for f in frames: mask |= f[..., 3] > threshold if not mask.any(): return 0, 0, w, h rows, cols = np.where(mask.any(1))[0], np.where(mask.any(0))[0] return ( max(0, int(cols[0]) - pad), max(0, int(rows[0]) - pad), min(w, int(cols[-1]) + 1 + pad), min(h, int(rows[-1]) + 1 + pad), ) def to_pil(rgba: np.ndarray) -> Image.Image: return Image.fromarray((np.clip(rgba, 0, 1) * 255).astype(np.uint8), "RGBA") def fit_height(sprites: list[Image.Image], max_height: int) -> list[Image.Image]: """Scale a move down so its cell is at most ``max_height`` tall, or return it untouched. One ratio for the whole move, derived from the shared crop box, so every frame keeps the same scale and the character does not appear to grow between poses. Only ever downscales — upscaling a sprite past what the clip actually resolved just bakes in interpolation. """ if not sprites or max_height <= 0: return sprites height = sprites[0].size[1] if height <= max_height: return sprites ratio = max_height / height size = (max(1, round(sprites[0].size[0] * ratio)), max_height) return [s.resize(size, Image.LANCZOS) for s in sprites] @dataclass class Move: """One animation: a named clip already cut down to its extremes.""" name: str frames: list[Image.Image] = field(default_factory=list) fps: float = 12.0 @property def size(self) -> tuple[int, int]: return self.frames[0].size if self.frames else (0, 0) def pack_sheet(moves: list[Move], columns: int | None = None, margin: int = 0) -> tuple[Image.Image, dict]: """Pack every move onto one uniform grid, one row per move. A uniform cell — the largest frame across every move, not per move — is what lets a runtime address a frame as ``(row, column)`` arithmetic with no per-frame lookup. Sprites are bottom-center anchored inside their cell so a character whose kick is wider than its idle still stands on the same ground line. """ if not moves: raise ValueError("No moves to pack.") cell_w = max(m.size[0] for m in moves) + margin * 2 cell_h = max(m.size[1] for m in moves) + margin * 2 cols = columns or max(len(m.frames) for m in moves) rows = sum(math.ceil(len(m.frames) / cols) for m in moves) sheet = Image.new("RGBA", (cell_w * cols, cell_h * rows), (0, 0, 0, 0)) atlas = { "cell": {"width": cell_w, "height": cell_h}, "columns": cols, "anchor": "bottom-center", "moves": {}, } row = 0 for move in moves: entries = [] for i, frame in enumerate(move.frames): c, r = i % cols, row + i // cols x = c * cell_w + (cell_w - frame.size[0]) // 2 y = r * cell_h + (cell_h - frame.size[1]) # bottom aligned: the ground line is shared sheet.paste(frame, (x, y)) entries.append({"index": c + (r - row) * cols, "row": r, "col": c}) atlas["moves"][move.name] = { "row": row, "frames": len(move.frames), "fps": move.fps, "cells": entries, } row += math.ceil(len(move.frames) / cols) atlas["sheet"] = {"width": sheet.size[0], "height": sheet.size[1], "rows": rows} return sheet, atlas # --------------------------------------------------------------------------- # encoding # --------------------------------------------------------------------------- def save_animation(frames: list[Image.Image], path: str, fps: float = 12.0) -> str: """Write a looping animation, preserving alpha. WebP, because an animated GIF has a 1-bit mask: every partially transparent edge pixel becomes a hard on/off decision and the sprite gets a visible fringe of whatever backdrop the encoder guessed. """ duration = max(20, int(round(1000.0 / max(fps, 1e-3)))) frames[0].save( path, save_all=True, append_images=frames[1:], duration=duration, loop=0, lossless=True, disposal=2, ) return path def read_video(path: str, max_frames: int = 240) -> tuple[list[np.ndarray], float]: """Decode a clip to float RGB frames. Uses imageio-ffmpeg, which ships its own ffmpeg binary.""" import imageio.v3 as iio meta = iio.immeta(path, plugin="pyav") fps = float(meta.get("fps") or 24.0) frames = [] for i, frame in enumerate(iio.imiter(path, plugin="pyav")): if i >= max_frames: break frames.append(np.asarray(frame, np.float32)[..., :3] / 255.0) return frames, fps def data_uri(image: Image.Image) -> str: import io buf = io.BytesIO() image.save(buf, "PNG") return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() def write_atlas(atlas: dict, path: str) -> str: with open(path, "w", encoding="utf-8") as handle: json.dump(atlas, handle, indent=2) return path