From 88ff8016bf8ac83225b43a77a4db7a7e6fd50148 Mon Sep 17 00:00:00 2001 From: Soohong Kim Date: Sun, 20 Sep 2026 20:54:34 +0900 Subject: [PATCH 1/2] fix(loop): rank gait cuts by neighbouring repeat consistency --- docs/video-pipeline.md | 20 +++++-- sprite_gen/video/loop.py | 46 ++++++++++++++--- tests/video/test_gait_window_context.py | 69 +++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 tests/video/test_gait_window_context.py diff --git a/docs/video-pipeline.md b/docs/video-pipeline.md index dfc749e..6d9e203 100644 --- a/docs/video-pipeline.md +++ b/docs/video-pipeline.md @@ -173,11 +173,22 @@ was 17). `video-loop` therefore: 2. reads the **global period profile** `P[L] = mean_j |f[j] − f[j+L]|` and takes the *smallest* local minimum that is within 15 % of the deepest one — exact repeats dip again at 2× and 3× the period, the half-period look-alike dips noticeably less; -3. only then picks the **start** with the best seam for that period (± 1 frame): +3. only then ranks **starts** for that period (± 1 frame): `seam = D[i+L-1][i]` over the mean adjacent distance inside the cycle. - Choose the ratio closest to 1 in log space, so a repeated pose at the wrap does - not win just because its distance is small. `next_frame_distance` retains the - distance to the frame after the cycle for diagnostics. + Penalise distance from 1 in log space, so a repeated pose at the wrap does + not win just because its distance is small. For walks and runs, also compare + corresponding frames one cycle apart in the neighbourhood of the cut: a + quarter-cycle on either side, clipped to available source pairs. Add their + mean distance divided by the candidate's mean adjacent distance to the wrap + penalty. This favours a coherent repeating region over an accidental endpoint + match, without preferring an early or late start. Other states keep wrap-only + ranking. `cycle.selection` records the half-open source-pair range, repeat + error, normalised error, wrap penalty and combined score; `next_frame_distance` + retains the single-frame diagnostic. Fixed cuts do not use this ranking. + +This neighbourhood check measures temporal consistency, not anatomical leg +identity. A consistently repeated malformed motion can still score well; visual +review remains necessary when correct limb alternation matters. Windows come from the state profile (`STATE_PROFILES`). **Gait states take theirs in seconds**, because a stride is a fact about the body, not about the clip length: walk @@ -327,4 +338,3 @@ reported) and `foot_x` (the mean foot column inside every cell), plus the spec l `anchor` as `[foot_x, h]` so a scene stands the sprite on its foot line; `video-set`'s table row carries `drift_px`. The default stays `none`: existing strips do not change, and `drift_px` / `foot_sway_px` are 0 when they were not measured. - diff --git a/sprite_gen/video/loop.py b/sprite_gen/video/loop.py index 14ea31e..efb146c 100644 --- a/sprite_gen/video/loop.py +++ b/sprite_gen/video/loop.py @@ -8,7 +8,9 @@ was 28, run picked 25 where it was 17). So the true period is read from the GLOBAL profile `P[L] = mean_j |f[j] - f[j+L]|` — its deepest local minimum inside the state's window — and only then is the start chosen as the best seam for that -period. Idle motion is tiny and not strictly periodic: its window is opened to +period. Gaits also compare the motion around the two cycle boundaries so an +accidental single-frame seam cannot outrank a coherent repeat. Idle motion is +tiny and not strictly periodic: its window is opened to most of the clip, where the seam is lowest. Everything downstream is measured, never assumed: the seam ratio (wrap distance @@ -153,13 +155,35 @@ def distance_matrix(files: list[Path]) -> np.ndarray: return D +def _repeat_context(D: np.ndarray, start: int, length: int, step: float) -> dict[str, Any]: + """Compare a neighbourhood at the cut with the same poses one cycle later. + + A quarter-cycle on either side samples half a cycle of motion instead of one + coincident pose. At a clip edge only real pairs participate; no padding or + synthetic wrap is evidence of repetition. Normalise by the candidate's + playback step so the cost is independent of character size and contrast. + This measures temporal consistency, not limb identity. + """ + radius = max(1, length // 4) + first = max(0, start - radius) + stop = min(len(D) - length, start + radius + 1) + error = float(np.mean([D[j, j + length] for j in range(first, stop)])) + return { + "context_pair_range": [first, stop], # half-open source indices + "context_repeat_error": error, + "context_repeat_over_step": error / step if step > 0 else math.inf, + } + + def detect_cycle(D: np.ndarray, *, min_len: int, max_len: int, gait_floor: int | None = None) -> dict[str, Any]: - """Global period (deepest local minimum of the averaged profile) then the best-seam start. + """Global period, then a wrap-compatible start with coherent gait context. `gait_floor` (frames) turns on the half-period guard: a period below it is one step of a two-step gait, so the doubled period is taken when it repeats about as well (see GAIT_DOUBLE_TOL). Above the floor, ambiguous non-exact harmonics may also - retain two phase occurrences; the report flags that decision for visual review.""" + retain two phase occurrences; the report flags that decision for visual review. + Gait starts balance the wrap step with observed repetition around the cut; + other states keep their wrap-only ranking.""" n = D.shape[0] max_len = min(max_len, n - 2) if min_len < 2 or max_len < min_len: @@ -217,6 +241,7 @@ def detect_cycle(D: np.ndarray, *, min_len: int, max_len: int, gait_floor: int | # playback step. Minimising distance alone rewards a repeated pose (a stall). # Log distance penalises steps that are too short or too long symmetrically. best: dict[str, Any] | None = None + best_score = math.inf for L in (period - 1, period, period + 1): if L < min_len or L > max_len: continue @@ -225,12 +250,21 @@ def detect_cycle(D: np.ndarray, *, min_len: int, max_len: int, gait_floor: int | inner = float(adjacent[i : i + L - 1].mean()) ratio = seam / inner if inner > 0 else math.inf score = abs(math.log(ratio)) if ratio > 0 else math.inf - if best is None or score < best["wrap_score"]: + selection = None + if gait_floor is not None: + selection = _repeat_context(D, i, L, inner) + selection["method"] = "repeat-context-and-wrap" + selection["wrap_log_error"] = score + score += selection["context_repeat_over_step"] + selection["score"] = score + if best is None or score < best_score: + best_score = score best = {"start": i, "length": L, "seam": seam, "inner_mean_adjacent": inner, - "ratio": ratio, "wrap_score": score, + "ratio": ratio, "next_frame_distance": float(D[i, i + L]) if i + L < n else None} + if selection is not None: + best["selection"] = selection assert best is not None - best.pop("wrap_score") best["period_global"] = period best["half_period_guard"] = guard best["review_recommended"] = review_recommended diff --git a/tests/video/test_gait_window_context.py b/tests/video/test_gait_window_context.py new file mode 100644 index 0000000..6985a14 --- /dev/null +++ b/tests/video/test_gait_window_context.py @@ -0,0 +1,69 @@ +"""A locally convincing seam must not outrank a coherent repeating trajectory.""" +from __future__ import annotations + +import math + +import pytest + +from sprite_gen._deps import np +from sprite_gen.video import loop + + +def _changing_cadence(*, reverse: bool) -> np.ndarray: + """Steady fractional-period motion followed by an irregular cadence. + + A chance endpoint match in the irregular section looks like a normal playback + step, although the neighbouring poses do not repeat at that interval. + Reversing time places the coherent region at the end instead. + """ + t = np.arange(150, dtype=float) + phase = t * 2 * math.pi / 24.5 + phase[75:] += 0.7 * np.sin(np.arange(75) * 0.63) + points = np.stack([np.cos(phase), np.sin(phase)], axis=1) + if reverse: + points = points[::-1] + return np.linalg.norm(points[:, None] - points[None, :], axis=-1).astype(np.float32) + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_gait_prefers_a_coherent_region_over_a_lucky_seam(reverse: bool) -> None: + D = _changing_cadence(reverse=reverse) + result = loop.detect_cycle(D, min_len=20, max_len=28, gait_floor=8) + start, length = result["start"], result["length"] + if reverse: + assert start >= 75, "the clean region can be at the end, not only at the beginning" + else: + assert start + length <= 75, "an accidental seam in irregular motion must lose" + assert length == 24 + assert 0.75 < result["ratio"] < 2.0 + + +def test_context_measurement_is_reported_at_the_selected_boundary() -> None: + D = _changing_cadence(reverse=False) + result = loop.detect_cycle(D, min_len=20, max_len=28, gait_floor=8) + evidence = result["selection"] + start, length = result["start"], result["length"] + first, stop = evidence["context_pair_range"] + assert 0 <= first <= start < stop <= len(D) - length + measured = float(np.mean([D[j, j + length] for j in range(first, stop)])) + assert evidence["context_repeat_error"] == pytest.approx(measured) + assert evidence["context_repeat_over_step"] == pytest.approx(measured / result["inner_mean_adjacent"]) + assert evidence["method"] == "repeat-context-and-wrap" + + +def test_non_gait_keeps_its_wrap_only_selection() -> None: + result = loop.detect_cycle(_changing_cadence(reverse=False), min_len=20, max_len=28) + assert result["start"] == 102 + assert result["length"] == 23 + assert "selection" not in result + + +def test_exact_gait_remains_a_single_period() -> None: + t = np.arange(96) % 24 + phase = t * 2 * math.pi / 24 + points = np.stack([np.cos(phase), np.sin(phase)], axis=1) + D = np.linalg.norm(points[:, None] - points[None, :], axis=-1).astype(np.float32) + result = loop.detect_cycle(D, min_len=10, max_len=40, gait_floor=8) + assert result["length"] == 24 + assert not result["half_period_guard"]["applied"] + assert result["ratio"] == pytest.approx(1.0) From ffcc5261dc99d21d0d3c75df3e919b8318cb54dd Mon Sep 17 00:00:00 2001 From: Soohong Kim Date: Sun, 20 Sep 2026 21:11:38 +0900 Subject: [PATCH 2/2] release: v2.5.1 automatic gait window selection --- CHANGELOG.md | 6 ++++++ SKILL.md | 2 +- pyproject.toml | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 381e982..fe2a9ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable public changes to `sprite-gen` are recorded here. Versions track the `version:` field in `SKILL.md` and `pyproject.toml`. +## 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. +- Loop reports expose the selected neighbourhood, normalized repeat error and combined score in `cycle.selection`. Existing period detection, manual cuts, non-gait states and source playback speed are preserved. +- Synthetic regressions cover steady and irregular motion in both time directions. The repeat metric measures temporal consistency; it does not identify anatomical left/right limbs or repair malformed source motion. + ## v2.5.0 - API image generation and smoother loop cuts - New `openai` image provider: `sprite-gen gen --provider openai` calls the OpenAI Images REST API with nothing but `OPENAI_API_KEY` — the credential a headless container (a Modal worker, CI, a SaaS backend) can have, where the `codex` route's interactive ChatGPT login cannot exist. New images go to `/v1/images/generations`, `--ref` switches to `/v1/images/edits` as multipart with the references as repeated `image[]` parts in order (up to 16), and gpt-image's inline base64 is decoded and published as a verified PNG without resizing. Default model `gpt-image-2.5-flare`. `--transparent` asks for `background: transparent` with `output_format: png` — the same measured `native` strategy as codex, and a live run came back 84 % alpha-0 with the subject at alpha 251–254. `--aspect-ratio` maps to one of the gpt-image `size` values that satisfy the API's constraints (both sides divisible by 16, ratio within 1:3..3:1, 655,360–8,294,400 pixels); a ratio with no exact size is refused rather than rounded to a nearby one you would be billed for. A missing or empty key, a rejected key and a failed request are all terminal: this provider never falls back to codex, to another credential, or to a retry. diff --git a/SKILL.md b/SKILL.md index 261eb2d..417cf55 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: sprite-gen -version: 2.5.0 +version: 2.5.1 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 9d183cd..0ee1217 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.0" +version = "2.5.1" description = "Component-row pipeline for clean 2D game sprites and animation atlases" readme = "README.md" license = "Apache-2.0"