From 08f26ad630567f1ccfe8a9a6156f508c77d6cc09 Mon Sep 17 00:00:00 2001 From: Soohong Kim Date: Sun, 20 Sep 2026 21:40:28 +0900 Subject: [PATCH 1/3] fix(chroma): distinguish key hue and dark fringe from reference material --- docs/video-pipeline.md | 16 +++++-- sprite_gen/frames/cutout.py | 10 ++-- sprite_gen/frames/extract.py | 42 ++++++++++++---- sprite_gen/video/frames.py | 12 +++-- tests/video/test_spill_hue_evidence.py | 66 ++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 18 deletions(-) create mode 100644 tests/video/test_spill_hue_evidence.py diff --git a/docs/video-pipeline.md b/docs/video-pipeline.md index 6d9e203..d13690d 100644 --- a/docs/video-pipeline.md +++ b/docs/video-pipeline.md @@ -141,7 +141,7 @@ 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 @@ -149,8 +149,18 @@ character that *is* green keeps its colour. The decision and its numbers are rec 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 drives correction strength and 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 `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 diff --git a/sprite_gen/frames/cutout.py b/sprite_gen/frames/cutout.py index 978147a..52e3cb1 100644 --- a/sprite_gen/frames/cutout.py +++ b/sprite_gen/frames/cutout.py @@ -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 @@ -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 ) @@ -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. @@ -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) diff --git a/sprite_gen/frames/extract.py b/sprite_gen/frames/extract.py index ef1d324..a5677e1 100644 --- a/sprite_gen/frames/extract.py +++ b/sprite_gen/frames/extract.py @@ -156,6 +156,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 @@ -365,24 +375,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( @@ -392,9 +415,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). @@ -527,8 +551,10 @@ 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) + spill_key_tint = float(spill_field(np.asarray(painted_key, dtype=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 @@ -569,7 +595,7 @@ def remove_chroma_background( red, green, blue, alpha = (int(value) for value in data[y, x]) color = (red, green, blue) coverage, despilled = despill_color( - color, painted_key, key_tint, key_tint_score(color, chroma_key) + color, painted_key, spill_key_tint, tints_left[index] ) if coverage > 0: data[y, x] = (*despilled, alpha) diff --git a/sprite_gen/video/frames.py b/sprite_gen/video/frames.py index 4c13174..8862e7f 100644 --- a/sprite_gen/video/frames.py +++ b/sprite_gen/video/frames.py @@ -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 @@ -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( @@ -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 diff --git a/tests/video/test_spill_hue_evidence.py b/tests/video/test_spill_hue_evidence.py new file mode 100644 index 0000000..490bff8 --- /dev/null +++ b/tests/video/test_spill_hue_evidence.py @@ -0,0 +1,66 @@ +"""Spill decisions distinguish key hue from warm material and edge contamination.""" +from pathlib import Path + +import pytest +from PIL import Image, ImageDraw + +from sprite_gen.video import frames + + +def _reference(path: Path, key: str, color: tuple[int, int, int], *, fringe=False): + bg = (0, 255, 0) if key == 'green' else (255, 0, 255) + im = Image.new('RGB', (160, 160), bg) + draw = ImageDraw.Draw(im) + draw.rectangle((24, 24, 135, 135), fill=color) + if fringe: + draw.rectangle((24, 24, 135, 135), outline=(4, 19, 6), width=2) + im.save(path) + return path + + +@pytest.mark.parametrize('key,color', [ + ('green', (235, 210, 65)), # yellow: green exceeds the mean, but not red + ('green', (65, 210, 235)), # cyan: green exceeds the mean, but not blue + ('magenta', (220, 80, 65)), # red: red/blue mean is not evidence of magenta + ('magenta', (65, 80, 220)), # blue +]) +def test_non_key_hues_do_not_disable_full_or_get_recoloured(tmp_path, key, color): + source = _reference(tmp_path/'ref.png', key, color) + assert frames.decide_spill(source, key)['mode'] == 'full' + frames.key_frames([source], tmp_path/'out', key=key, check_edges=False, spill='full') + assert Image.open(tmp_path/'out/ref.png').getpixel((80, 80)) == (*color, 255) + + +def test_a_dark_key_tinted_outline_is_not_interior_material(tmp_path): + source = _reference(tmp_path/'ref.png', 'green', (220, 220, 220), fringe=True) + decision = frames.decide_spill(source, 'green') + assert decision['mode'] == 'full' + assert decision['key_material_px'] == 0 + + +@pytest.mark.parametrize('key,color', [('green',(80,115,90)),('magenta',(115,80,110))]) +def test_genuine_mild_key_material_still_keeps_conservative_mode(tmp_path,key,color): + source = _reference(tmp_path/'ref.png',key,color) + assert frames.decide_spill(source,key)['mode']=='small' + + +@pytest.mark.parametrize('key,color', [('green',(180,220,195)),('magenta',(220,180,210))]) +def test_full_removes_key_excess_without_punching_alpha(tmp_path,key,color): + source=_reference(tmp_path/'ref.png',key,color) + frames.key_frames([source],tmp_path/'full',key=key,check_edges=False,spill='full') + frames.key_frames([source],tmp_path/'small',key=key,check_edges=False,spill='small') + full=Image.open(tmp_path/'full/ref.png');small=Image.open(tmp_path/'small/ref.png') + assert full.getchannel('A').tobytes()==small.getchannel('A').tobytes() + r,g,b,a=full.getpixel((80,80)) + assert a==255 + assert (g-max(r,b) if key=='green' else min(r,b)-g)<=1 + + +def test_a_bright_thin_green_detail_at_the_outline_is_material(tmp_path): + source = _reference(tmp_path/'ref.png', 'green', (220, 220, 220)) + im = Image.open(source) + # A mild green survives the unchanged edge matte; stronger boundary greens + # are already unmixed in the baseline, before the spill decision runs. + ImageDraw.Draw(im).rectangle((24, 24, 135, 135), outline=(160, 180, 165), width=2) + im.save(source) + assert frames.decide_spill(source, 'green')['mode'] == 'small' From e83708fb2ebfb38bdeffe8716d0d84bbb68bcd68 Mon Sep 17 00:00:00 2001 From: Soohong Kim Date: Sun, 20 Sep 2026 22:05:09 +0900 Subject: [PATCH 2/3] fix(chroma): recover brightness without amplifying secondary casts --- docs/video-pipeline.md | 9 +++- sprite_gen/frames/extract.py | 36 +++++++++++---- tests/video/test_spill_hue_evidence.py | 62 ++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 9 deletions(-) diff --git a/docs/video-pipeline.md b/docs/video-pipeline.md index d13690d..1411098 100644 --- a/docs/video-pipeline.md +++ b/docs/video-pipeline.md @@ -151,10 +151,17 @@ model (observed = (1−k)·subject + k·key, solved for the subject), so colours tint are untouched. `small` keeps the conservative tint threshold of 40; `full` lowers it to 8. 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 drives correction strength and the `auto` reference check, so yellow/cyan +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 diff --git a/sprite_gen/frames/extract.py b/sprite_gen/frames/extract.py index a5677e1..03da182 100644 --- a/sprite_gen/frames/extract.py +++ b/sprite_gen/frames/extract.py @@ -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) @@ -553,8 +568,6 @@ def remove_chroma_background( # the image, and a pixel it despilled is no longer a spill candidate. 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) - spill_key_tint = float(spill_field(np.asarray(painted_key, dtype=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 @@ -594,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, spill_key_tint, tints_left[index] + 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) diff --git a/tests/video/test_spill_hue_evidence.py b/tests/video/test_spill_hue_evidence.py index 490bff8..37fa247 100644 --- a/tests/video/test_spill_hue_evidence.py +++ b/tests/video/test_spill_hue_evidence.py @@ -21,6 +21,8 @@ def _reference(path: Path, key: str, color: tuple[int, int, int], *, fringe=Fals @pytest.mark.parametrize('key,color', [ ('green', (235, 210, 65)), # yellow: green exceeds the mean, but not red ('green', (65, 210, 235)), # cyan: green exceeds the mean, but not blue + ('green', (30, 70, 180)), # blue material is not green spill + ('green', (180, 70, 30)), # red material is not green spill ('magenta', (220, 80, 65)), # red: red/blue mean is not evidence of magenta ('magenta', (65, 80, 220)), # blue ]) @@ -64,3 +66,63 @@ def test_a_bright_thin_green_detail_at_the_outline_is_material(tmp_path): ImageDraw.Draw(im).rectangle((24, 24, 135, 135), outline=(160, 180, 165), width=2) im.save(source) assert frames.decide_spill(source, 'green')['mode'] == 'small' + + +@pytest.mark.parametrize('key,color', [ + ('green', (72, 230, 80)), + ('green', (80, 230, 72)), + ('magenta', (220, 80, 212)), +]) +def test_full_does_not_amplify_colour_noise_into_another_hue(tmp_path, key, color): + source = _reference(tmp_path/'ref.png', key, color) + frames.key_frames([source], tmp_path/'out', key=key, check_edges=False, spill='full') + r, g, b, a = Image.open(tmp_path/'out/ref.png').getpixel((80, 80)) + assert a == 255 + # Removing a strong key cast must not magnify a small red/blue difference + # into saturated cyan, blue, or orange on otherwise neutral material. + assert abs(r-b) <= abs(color[0]-color[2]) + 1 + assert abs(g-(r+b)/2) <= 1 + # Merely capping green would leave this pale subject dark. + assert (r+g+b)/3 > 2 * min(color) + + +@pytest.mark.parametrize('key,color,expected', [ + ('green', (80, 165, 80), (120, 120, 120, 255)), + ('magenta', (165, 80, 165), (120, 120, 120, 255)), +]) +def test_full_still_recovers_brightness_from_a_neutral_key_blend(tmp_path, key, color, expected): + source = _reference(tmp_path/'ref.png', key, color) + frames.key_frames([source], tmp_path/'out', key=key, check_edges=False, spill='full') + assert Image.open(tmp_path/'out/ref.png').getpixel((80, 80)) == expected + + +@pytest.mark.parametrize('key,color', [ + ('green', (70, 210, 150)), + ('green', (150, 210, 70)), + ('magenta', (210, 70, 150)), +]) +def test_full_keeps_non_key_colour_differences_instead_of_turning_everything_grey(tmp_path, key, color): + source = _reference(tmp_path/'ref.png', key, color) + frames.key_frames([source], tmp_path/'out', key=key, check_edges=False, spill='full') + r, g, b, a = Image.open(tmp_path/'out/ref.png').getpixel((80, 80)) + assert a == 255 + assert abs((r-b)-(color[0]-color[2])) <= 1 + assert abs(g-(r+b)/2) <= 1 + + +@pytest.mark.parametrize('key,painted,color', [ + ('green', (6, 250, 0), (80, 220, 90)), + ('green', (6, 170, 0), (100, 160, 110)), + ('magenta', (250, 6, 240), (210, 100, 200)), + ('magenta', (220, 40, 150), (180, 140, 170)), +]) +def test_an_imperfect_painted_key_cannot_introduce_a_secondary_cast(tmp_path, key, painted, color): + source = tmp_path/'painted.png' + im = Image.new('RGB', (160, 160), painted) + ImageDraw.Draw(im).rectangle((24, 24, 135, 135), fill=color) + im.save(source) + frames.key_frames([source], tmp_path/'out', key=key, check_edges=False, spill='full') + r, g, b, a = Image.open(tmp_path/'out/painted.png').getpixel((80, 80)) + assert a == 255 + assert abs((r-b)-(color[0]-color[2])) <= 1 + assert abs(g-(r+b)/2) <= 1 From e65655ba01c474686e43b1e27ad9597bb1e309d1 Mon Sep 17 00:00:00 2001 From: Soohong Kim Date: Sun, 20 Sep 2026 22:09:34 +0900 Subject: [PATCH 3/3] chore(release): prepare v2.5.2 --- CHANGELOG.md | 7 +++++++ SKILL.md | 2 +- pyproject.toml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2a9ae..97471f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/SKILL.md b/SKILL.md index 417cf55..c0daaeb 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 0ee1217..6d51194 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"