Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

All notable public changes to `sprite-gen` are recorded here. Versions track the `version:` field in `SKILL.md` and `pyproject.toml`.

## v2.5.2 - Cleaner video spill correction

- Automatic spill selection distinguishes the key hue from yellow/cyan or red/blue material and discounts dark contamination in the reference matte edge band. Bright key-coloured details still count as material.
- Full correction removes faint key casts while recovering brightness without amplifying residual colour differences into secondary casts. The declared key defines channel groups even when the painted background is dim or asymmetric.
- Alpha, conservative `small` correction and gait selection logic are unchanged. Existing source colours without the key hue remain; this does not reconstruct the original material palette.
- Synthetic regressions cover colour preservation, bright edge details, secondary-cast amplification and imperfect painted keys.

## v2.5.1 - More consistent automatic walk and run loops

- Automatic gait cuts now score whether neighbouring poses repeat one cycle later as well as the last-to-first transition. This reduces selection of irregular motion that happens to have a plausible seam, without preferring an earlier or later part of the clip.
Expand Down
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: sprite-gen
version: 2.5.1
version: 2.5.2
description: "Generates images and game sprites through GPT or Grok with guided provider choices, separate saved defaults, automatic cleanup and optional curation. Handles sprite requests, ordinary image generation/editing, standalone image-to-video clips (i2v, animate this still, 그록 영상, 이매진 비디오, 스틸 움직여줘, first/last frame, reference-to-video, 영상 이어붙이기, 영상 편집, extend/edit a clip), chroma removal, animation atlases, video loops, 큐레이션뷰, image candidates, 팔레트 스왑, palette swap, recolor, rig layers, engine exports, repeating backgrounds, projected shadows, motion/contact inspection and optional scene composition from existing assets."
license: Apache-2.0
depends_on:
Expand Down
23 changes: 20 additions & 3 deletions docs/video-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,16 +141,33 @@ The still the clip was made from settles it. `video-frames --spill` takes:
|---|---|
| `small` (default) | only small key-tinted clusters — the still pipeline's rule, byte-identical output |
| `full` | key-tinted clusters of any size, including faint tints (colour only: alpha is unchanged) |
| `auto` | keys `--reference` (the still) with the same matte and counts its key-tinted pixels at the same threshold used by `full`; a share ≤ 0.5 % means the still has no key-coloured material of its own → `full`, otherwise `small` |
| `auto` | keys `--reference` (the still) with the same matte and counts its interior key-hued pixels at the same threshold used by `full`; a share ≤ 0.5 % means the still has no key-coloured material of its own → `full`, otherwise `small` |

`video-set` passes `--spill auto` with each item's `canvas.png` as the reference (override
with `--spill small|full`), so a green-free character loses the reflections while a
character that *is* green keeps its colour. The decision and its numbers are recorded in
the frames report under `spill`. The correction is the engine's own `despill_color` blend
model (observed = (1−k)·subject + k·key, solved for the subject), so colours without key
tint are untouched. `small` keeps the conservative tint threshold of 40; `full` lowers it to 8.
The `auto` reference check also uses 8, so faint key-coloured material in the
original character keeps the conservative correction.
For `full`, a key hue requires every keyed channel to exceed every non-keyed
channel: `G − max(R, B)` for green, `min(R, B) − G` for magenta. This same
excess selects pixels for correction and drives the `auto` reference check, so yellow/cyan
are not mistaken for green, or red/blue for magenta. The average-channel tint
metric remains unchanged in `small` and the edge matte.

The blend fraction still uses the linear average-channel tint, not hue excess.
Full correction recovers mean brightness but does not amplify colour differences
within the keyed or non-keyed channel group. Otherwise a small red/blue imbalance
can become a strong secondary cast when much of the observed colour is key light.
This is bounded colour recovery, not reconstruction of the original material:
blue or purple already present without the key hue remains unchanged.

The `auto` reference test discounts dark pixels (all channels below 64) in the
matte's 4-pixel edge-unmix band. Such contamination along an antialiased outline
is weak evidence of an intentional material. Bright green/magenta accents still
count even on an edge. The report names the metric, band and dark-only policy.
Genuine key-coloured material above the 0.5% share still keeps the conservative
mode. Tiny accents or dark, edge-only material can fall below that reference test; use `--spill small` when preserving those is essential.

## 3b. Canvas shape for raised limbs and wide costumes

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ build-backend = "setuptools.build_meta"
name = "sprite-gen"
# Release discipline: keep this package metadata version synchronized with
# SKILL.md's `version:` field in the same release commit.
version = "2.5.1"
version = "2.5.2"
description = "Component-row pipeline for clean 2D game sprites and animation atlases"
readme = "README.md"
license = "Apache-2.0"
Expand Down
10 changes: 7 additions & 3 deletions sprite_gen/frames/cutout.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,8 @@ def _matte_route(


def extract_route(image: Image.Image, kind: str, *, spill_max_fraction: float | None = None,
spill_min_tint: float | None = None) -> tuple[Image.Image, dict[str, Any]]:
spill_min_tint: float | None = None,
spill_require_hue: bool = False) -> tuple[Image.Image, dict[str, Any]]:
"""Magenta/green key background → reuse the verified `extract` chroma engine (no drift).

The engine keys from the background colour it detects on the borders
Expand All @@ -286,11 +287,13 @@ def extract_route(image: Image.Image, kind: str, *, spill_max_fraction: float |

target = KEY_TARGETS[kind]
painted = detect_background_key_rgb(image, target)
extra: dict[str, float] = {}
extra: dict[str, float | bool] = {}
if spill_max_fraction is not None:
extra["spill_max_fraction"] = spill_max_fraction
if spill_min_tint is not None:
extra["spill_min_tint"] = spill_min_tint
if spill_require_hue:
extra["spill_require_hue"] = True
result = remove_chroma_background(
image, target, _EXTRACT_KEY_THRESHOLD, _EXTRACT_FRINGE_THRESHOLD, _EXTRACT_FRINGE_DELTA, **extra
)
Expand All @@ -313,6 +316,7 @@ def cutout(
white_check_dir: Path | None = None,
spill_max_fraction: float | None = None,
spill_min_tint: float | None = None,
spill_require_hue: bool = False,
) -> dict[str, Any]:
"""Cut a uniform-background imported image to a clean transparent RGBA PNG.

Expand All @@ -332,7 +336,7 @@ def cutout(
route = _detect_key_kind(_corner_average(image)) if key == "auto" else key
if route in ("magenta", "green"):
result, route_stats = extract_route(image, route, spill_max_fraction=spill_max_fraction,
spill_min_tint=spill_min_tint)
spill_min_tint=spill_min_tint, spill_require_hue=spill_require_hue)
else:
result, route_stats = _matte_route(image, input_path, strength, band, erode, tolerance)

Expand Down
72 changes: 59 additions & 13 deletions sprite_gen/frames/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,22 +84,37 @@ def despill_color(
chroma_key: tuple[int, int, int],
key_tint: float,
tint: float,
*,
chroma_groups: tuple[list[int], list[int]] | None = None,
) -> tuple[float, tuple[int, int, int]]:
"""Estimate the key fraction of a blend pixel and remove it from the RGB.

Blend model: observed = (1-k)*subject + k*key. key_tint_score is linear in
the channels and scores the key itself at `key_tint`, so k = tint/key_tint
recovers a subject estimate whose own tint score is ~0. Returns the
subject coverage (1-k) and the despilled color.
subject coverage (1-k) and the despilled color. Opaque full-spill correction
supplies the declared key's `chroma_groups` to limit chroma gain: recover
mean brightness but do not magnify channel differences within either group.
Small colour errors otherwise become strong secondary casts when coverage
is small. Groups come from the declared key, even if the painted key is dim.
"""
k = min(tint / key_tint, 1.0)
coverage = 1.0 - k
if coverage <= 0:
return 0.0, (0, 0, 0)
red, green, blue = (
min(255, max(0, round((color[index] - k * chroma_key[index]) / coverage)))
for index in range(3)
)
unmixed = [(color[index] - k * chroma_key[index]) / coverage for index in range(3)]
if chroma_groups is not None:
for channels in chroma_groups:
if not channels:
continue
mean = sum(unmixed[index] for index in channels) / len(channels)
observed_mean = sum(color[index] for index in channels) / len(channels)
for index in channels:
# Preserve observed differences, including with a slightly
# impure painted key; subtracting its channel imbalance here
# would manufacture another cast in otherwise neutral pixels.
unmixed[index] = mean + color[index] - observed_mean
red, green, blue = (min(255, max(0, round(value))) for value in unmixed)
return coverage, (red, green, blue)


Expand Down Expand Up @@ -156,6 +171,16 @@ def _key_tint_field(rgb: np.ndarray, keyed_channels: list[int],
return keyed_sum / len(keyed_channels) - unkeyed_sum / len(unkeyed_channels)


def _key_excess_field(rgb: np.ndarray, keyed_channels: list[int],
unkeyed_channels: list[int]) -> np.ndarray:
"""Key hue requires every keyed channel to exceed every other channel.

Green uses G-max(R,B); magenta uses min(R,B)-G. Unlike the averaged
tint axis this does not call yellow/cyan green or red/blue magenta.
"""
return rgb[..., keyed_channels].min(axis=-1) - rgb[..., unkeyed_channels].max(axis=-1)


def _grow_into(seed: np.ndarray, allowed: np.ndarray) -> np.ndarray:
"""Every `allowed` pixel 8-connected to `seed` through `allowed` pixels (seed included)."""
reached = seed & allowed
Expand Down Expand Up @@ -365,24 +390,37 @@ def detect_background_key_rgb(
# Full correction also admits faint key tints. Auto mode must inspect the
# reference at this same threshold before choosing full correction.
_SPILL_FULL_MIN_TINT = 8.0
DEFAULT_UNMIX_REACH = 4


def key_material_pixels(image: Image.Image, chroma_key: tuple[int, int, int],
min_tint: float = _SPILL_MIN_TINT) -> tuple[int, int]:
min_tint: float = _SPILL_MIN_TINT, *, require_hue: bool = False,
ignore_fringe: int = 0) -> tuple[int, int]:
"""Opaque pixels that are key-tinted past `min_tint` (the trapped-spill bar), and all
opaque pixels, of an already keyed RGBA image. A still that has almost none carries no
key-coloured material of its own.

The bar is a parameter because the decision and the treatment have to read the same
one: judging a reference at 40 and then despilling it down to 8 would call a subject
with a mild green of its own "no key material" and then scrub that green away."""
with a mild green of its own "no key material" and then scrub that green away.
`require_hue` uses channel excess rather than the mean-channel tint axis;
`ignore_fringe` discounts dark pixels in that band beside transparency;
bright key-coloured accents remain material evidence. The denominator stays
total subject area."""
keyed_channels, unkeyed_channels = _key_channel_split(chroma_key)
data = np.asarray(image.convert("RGBA")).astype(np.int32)
opaque = data[..., 3] > 0
if not keyed_channels:
return 0, int(opaque.sum())
tint = _key_tint_field(data[..., :3], keyed_channels, unkeyed_channels)
return int((opaque & (tint > min_tint)).sum()), int(opaque.sum())
field = _key_excess_field if require_hue else _key_tint_field
tint = field(data[..., :3], keyed_channels, unkeyed_channels)
# Discount only dark outline contamination in the matte's unmix band.
# A bright green/magenta accent is material even when it is thin or on an edge.
fringe = ~opaque
for _ in range(ignore_fringe):
fringe = _grow_chebyshev(fringe)
dark_fringe = fringe & (data[..., :3].max(axis=-1) < _KEY_CHANNEL_DARK)
return int((opaque & ~dark_fringe & (tint > min_tint)).sum()), int(opaque.sum())


def remove_chroma_background(
Expand All @@ -392,9 +430,10 @@ def remove_chroma_background(
fringe_threshold: float,
fringe_delta: float,
*,
unmix_reach: int = 4,
unmix_reach: int = DEFAULT_UNMIX_REACH,
spill_max_fraction: float = 0.005,
spill_min_tint: float = _SPILL_MIN_TINT,
spill_require_hue: bool = False,
background_key: tuple[int, int, int] | None = None,
) -> Image.Image:
"""Key `chroma_key` out of `image` (hard cut + soft-alpha fringe unmix + trapped-spill despill).
Expand Down Expand Up @@ -527,8 +566,8 @@ def remove_chroma_background(
spill_limit = max(32, round(subject_count * spill_max_fraction))
# Re-scored on the *current* colors: the unmix pass above rewrote part of
# the image, and a pixel it despilled is no longer a spill candidate.
current_tint = _key_tint_field(data[..., :3].astype(np.int32),
keyed_channels, unkeyed_channels)
spill_field = _key_excess_field if spill_require_hue else _key_tint_field
current_tint = spill_field(data[..., :3].astype(np.int32), keyed_channels, unkeyed_channels)
# Candidacy and acceptance read the same bar: a cluster can never be accepted
# below `spill_min_tint`, so admitting only pixels at or above `fringe_delta`
# would silently keep the lowered bar from reaching anything when it is the
Expand Down Expand Up @@ -568,8 +607,15 @@ def remove_chroma_background(
y = index // width
red, green, blue, alpha = (int(value) for value in data[y, x])
color = (red, green, blue)
# Hue excess decides *whether* this is spill. It is nonlinear
# and cannot estimate a blend fraction: G-max(R,B) drives G
# to the larger channel, turning a slight blue bias into cyan.
# Use the linear tint axis for unmixing and bound the gain on
# the remaining chroma when treating opaque full-spill pixels.
coverage, despilled = despill_color(
color, painted_key, key_tint, key_tint_score(color, chroma_key)
color, painted_key, key_tint,
key_tint_score(color, chroma_key) if spill_require_hue else tints_left[index],
chroma_groups=(keyed_channels, unkeyed_channels) if spill_require_hue else None,
)
if coverage > 0:
data[y, x] = (*despilled, alpha)
Expand Down
12 changes: 8 additions & 4 deletions sprite_gen/video/frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

from sprite_gen.frames.cutout import cutout
from sprite_gen.frames.extract import is_border_key_candidate
from sprite_gen.frames.extract import _SPILL_FULL_MIN_TINT
from sprite_gen.frames.extract import _SPILL_FULL_MIN_TINT, DEFAULT_UNMIX_REACH
from sprite_gen.spec.runio import atomic_write_text

EDGE_ROWS = 4 # rows/cols inspected at each edge
Expand Down Expand Up @@ -139,11 +139,14 @@ def decide_spill(reference: Path, key: str) -> dict[str, Any]:
keyed, _ = extract_route(image, kind)
# judged at the bar `full` would treat with, so a subject that owns a mild key tint
# is not first called "no key material" and then scrubbed of it
material, subject = key_material_pixels(keyed, KEY_TARGETS[kind], SPILL_FULL_MIN_TINT)
material, subject = key_material_pixels(keyed, KEY_TARGETS[kind], SPILL_FULL_MIN_TINT,
require_hue=True, ignore_fringe=DEFAULT_UNMIX_REACH)
share = material / subject if subject else 0.0
mode = "full" if share <= SPILL_REFERENCE_MAX else "small"
return {"mode": mode, "reference": str(reference), "key": kind, "key_material_px": material,
"subject_px": subject, "key_material_share": round(share, 5), "share_max": SPILL_REFERENCE_MAX}
"subject_px": subject, "key_material_share": round(share, 5), "share_max": SPILL_REFERENCE_MAX,
"material_metric": "key-channel-excess", "reference_fringe_ignored_px": DEFAULT_UNMIX_REACH,
"reference_fringe_policy": "dark-only"}


def key_frames(
Expand All @@ -164,7 +167,8 @@ def key_frames(
contacts: list[dict[str, Any]] = []
for src in raw_files:
dst = keyed_dir / src.name
stats = cutout(src, dst, key=key, spill_max_fraction=spill_max, spill_min_tint=spill_tint)
stats = cutout(src, dst, key=key, spill_max_fraction=spill_max, spill_min_tint=spill_tint,
spill_require_hue=spill == "full")
image = Image.open(dst).convert("RGBA")
hist = image.getchannel("A").histogram()
w, h = image.size
Expand Down
Loading
Loading