Codexのpetスキルの問題点を改善する ... users

Codexでpetという機能があり、いくつかキャラを作成してみたのですが、挙動がおかしいです。 例えば、キャラの顔の大きさがグワグワ変わります。みなさんはそういうことないでしょうか?

画像を確認してみると、画像生成自体は問題なさそうなのですが、切り抜き方に問題があることがわかってきました。 今回はCodexと相談しながらキャラの挙動改善を行った話です。

Before: dokochanのjumpingを各frameで個別fit

dokochan before normalization jumping animation

低い姿勢がセルに合わせて拡大され、ジャンプの上下移動よりもサイズ差が目立っていました。

After: dokochanも共通scale + 足元baseline

dokochan after normalization jumping animation

キャラの大きさを保ったまま、ジャンプをセル内の上下位置の変化として見せます。

作成した順番

01

run manifestを作成

~/.codex/pet-runs/strider-boy にrunフォルダを作り、 Strider Boyのidentity、rowごとのprompt、layout guide、出力先を確定しました。

idle row layout guide
layout guide例: row生成時のフレーム数と余白だけを示す参照画像。
02

base画像を生成

写真から赤ヘルメット、黒サングラス、ネイビー服、青パンツ、赤いバランスバイクを抽出し、 以後のrow生成のcanonical referenceにしました。

generated Strider Boy base pet image
decoded/base.png: row生成の見た目の正解。
03

identity check rowを先に生成

subagentでidlerunning-rightを先に生成しました。 静止と走行で同じ男の子に見えることを確認してから残りrowに進みました。

generated running-right row strip
running-right: 最初に確認した走行row。
04

running-leftはミラーせず生成

baseの赤バイクに文字が入っていたため、左右反転すると意味が壊れます。 そのためrunning-leftはミラー派生ではなく、参照付きで別生成しました。

generated running-left row strip
running-left: 反転ではなく通常生成。
05

残りrowをsubagentで並列生成

wavingjumpingfailedwaitingrunningreviewをrowごとに生成しました。 親エージェントはmanifest記録とpackageだけを担当しました。

generated failed row strip
failed: row単位で生成された実画像。
06

atlas化とQA

生成rowから各フレームを抽出し、透明背景の 192x208 セルへ並べ直しました。 この「全モーションのセルを1枚に敷き詰めた画像」がatlasで、Codex Appは pet.json のrow定義を見ながらatlas上の該当セルを順番に再生します。

QA contact sheet for Strider Boy pet
qa/contact-sheet.png: 全rowの目視確認用。

生成したrow strips

row stripは、1つのモーションだけを横長に並べた生成元画像です。 これをそのままpetとして使うのではなく、各rowからフレームを切り出して、 最後に1枚のspritesheet atlasへ再配置します。

row 0: idle / 6 frames

idle row strip

row 1: running-right / 8 frames

running-right row strip

row 2: running-left / 8 frames

running-left row strip

row 3: waving / 4 frames

waving row strip

row 4: jumping / 5 frames

jumping row strip

row 5: failed / 8 frames

failed row strip

row 6: waiting / 6 frames

waiting row strip

row 7: running / 6 frames

running row strip

row 8: review / 6 frames

review row strip

row stripをどう切り出しているか

生成AIには「横長の1枚に複数ポーズを並べる」と依頼します。 その後、extract_strip_frames.py が背景を透過し、ポーズ単位の連結成分を検出して、 Codex pet用の 192x208 セルへ配置します。

generated jumping row strip
1. まずはマゼンタ背景つきの横長row stripとして生成される。
detected bounding boxes on jumping row strip
2. 背景を透明化し、ポーズごとの不透明pixel領域をbboxとして検出する。
jumping frames placed into 192x208 atlas cells
3. 切り出したフレームを192x208セルへ配置する。青線が足元baseline。
1. chroma key除去

row画像のマゼンタ背景を色距離で透明化します。 背景が完全な単色でなくても、しきい値内の近似色は透明になります。

2. connected components

透明化後の不透明pixelを探索し、つながった塊を検出します。 大きい塊を各フレームのseedとして左から順に並べます。

3. noiseを近いseedへ結合

小さな部品やハンドルなど、別componentになったpixelは、 X座標が近いseedへまとめて1ポーズとして扱います。

extract_strip_frames.py の全文を開く
#!/usr/bin/env python3
"""Extract generated horizontal row strips into 192x208 sprite frames."""

from __future__ import annotations

import argparse
import json
import math
import re
from pathlib import Path

from PIL import Image

CELL_WIDTH = 192
CELL_HEIGHT = 208
CELL_PADDING = 10
ROW_FRAME_COUNTS = {
    "idle": 6,
    "running-right": 8,
    "running-left": 8,
    "waving": 4,
    "jumping": 5,
    "failed": 8,
    "waiting": 6,
    "running": 6,
    "review": 6,
}
REFERENCE_HEIGHT_STATES = {"idle", "waving", "waiting", "review"}
IDENTITY_HEIGHT_STATES = {"running-right", "running-left", "running"}
MAX_IDENTITY_ROW_SCALE = 1.45


def parse_states(raw: str) -> list[str]:
    if raw.strip().lower() == "all":
        return list(ROW_FRAME_COUNTS)
    states = [item.strip() for item in raw.split(",") if item.strip()]
    unknown = sorted(set(states) - set(ROW_FRAME_COUNTS))
    if unknown:
        raise SystemExit(f"unknown state(s): {', '.join(unknown)}")
    return states


def parse_hex_color(value: str) -> tuple[int, int, int]:
    if not re.fullmatch(r"#[0-9a-fA-F]{6}", value):
        raise SystemExit(f"invalid chroma key color: {value}; expected #RRGGBB")
    return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))


def load_chroma_key(decoded_dir: Path, override: str | None) -> tuple[int, int, int]:
    if override:
        return parse_hex_color(override)
    request_path = decoded_dir.parent / "pet_request.json"
    if request_path.is_file():
        request = json.loads(request_path.read_text(encoding="utf-8"))
        chroma_key = request.get("chroma_key")
        if isinstance(chroma_key, dict) and isinstance(chroma_key.get("hex"), str):
            return parse_hex_color(chroma_key["hex"])
    return parse_hex_color("#00FF00")


def color_distance(
    red: int,
    green: int,
    blue: int,
    key: tuple[int, int, int],
) -> float:
    return math.sqrt((red - key[0]) ** 2 + (green - key[1]) ** 2 + (blue - key[2]) ** 2)


def remove_chroma_background(
    image: Image.Image,
    chroma_key: tuple[int, int, int],
    threshold: float,
) -> Image.Image:
    rgba = image.convert("RGBA")
    pixels = rgba.load()
    for y in range(rgba.height):
        for x in range(rgba.width):
            red, green, blue, alpha = pixels[x, y]
            if color_distance(red, green, blue, chroma_key) <= threshold:
                pixels[x, y] = (red, green, blue, 0)
    return rgba


def fit_to_cell(image: Image.Image) -> Image.Image:
    bbox = image.getbbox()
    target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
    if bbox is None:
        return target

    sprite = image.crop(bbox)
    max_width = CELL_WIDTH - CELL_PADDING
    max_height = CELL_HEIGHT - CELL_PADDING
    scale = min(max_width / sprite.width, max_height / sprite.height, 1.0)
    if scale != 1.0:
        sprite = sprite.resize(
            (max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))),
            Image.Resampling.LANCZOS,
        )
    left = (CELL_WIDTH - sprite.width) // 2
    top = (CELL_HEIGHT - sprite.height) // 2
    target.alpha_composite(sprite, (left, top))
    return target


def fit_sprites_to_cells_consistently(
    sprites: list[tuple[Image.Image, tuple[int, int, int, int]]],
    scale: float | None = None,
) -> list[Image.Image]:
    if not sprites:
        return []

    max_width = CELL_WIDTH - CELL_PADDING
    max_height = CELL_HEIGHT - CELL_PADDING
    max_sprite_width = max(sprite.width for sprite, _bbox in sprites)
    global_min_y = min(bbox[1] for _sprite, bbox in sprites)
    global_max_y = max(bbox[3] for _sprite, bbox in sprites)
    vertical_span = max(1, global_max_y - global_min_y)
    if scale is None:
        scale = min(
            max_width / max(1, max_sprite_width),
            max_height / vertical_span,
            1.0,
        )
    baseline_y = CELL_HEIGHT - max(4, CELL_PADDING // 2)
    frames: list[Image.Image] = []

    for sprite, bbox in sprites:
        target = Image.new("RGBA", (CELL_WIDTH, CELL_HEIGHT), (0, 0, 0, 0))
        scaled_width = max(1, round(sprite.width * scale))
        scaled_height = max(1, round(sprite.height * scale))
        scaled = sprite
        if scaled.size != (scaled_width, scaled_height):
            scaled = sprite.resize((scaled_width, scaled_height), Image.Resampling.LANCZOS)
        left = (CELL_WIDTH - scaled.width) // 2
        # Keep row-local vertical motion, but anchor it from the lowest foot point.
        top = baseline_y - round((global_max_y - bbox[1]) * scale)
        top = max(0, min(CELL_HEIGHT - scaled.height, top))
        target.alpha_composite(scaled, (left, top))
        frames.append(target)

    return frames


def connected_components(image: Image.Image) -> list[dict[str, object]]:
    alpha = image.getchannel("A")
    width, height = image.size
    data = alpha.tobytes()
    visited = bytearray(width * height)
    components: list[dict[str, object]] = []

    for start, alpha_value in enumerate(data):
        if alpha_value <= 16 or visited[start]:
            continue

        stack = [start]
        visited[start] = 1
        pixels: list[int] = []
        min_x = width
        min_y = height
        max_x = 0
        max_y = 0

        while stack:
            current = stack.pop()
            pixels.append(current)
            x = current % width
            y = current // width
            min_x = min(min_x, x)
            min_y = min(min_y, y)
            max_x = max(max_x, x)
            max_y = max(max_y, y)

            if x > 0:
                neighbor = current - 1
                if not visited[neighbor] and data[neighbor] > 16:
                    visited[neighbor] = 1
                    stack.append(neighbor)
            if x + 1 < width:
                neighbor = current + 1
                if not visited[neighbor] and data[neighbor] > 16:
                    visited[neighbor] = 1
                    stack.append(neighbor)
            if y > 0:
                neighbor = current - width
                if not visited[neighbor] and data[neighbor] > 16:
                    visited[neighbor] = 1
                    stack.append(neighbor)
            if y + 1 < height:
                neighbor = current + width
                if not visited[neighbor] and data[neighbor] > 16:
                    visited[neighbor] = 1
                    stack.append(neighbor)

        components.append(
            {
                "pixels": pixels,
                "area": len(pixels),
                "bbox": (min_x, min_y, max_x + 1, max_y + 1),
                "center_x": (min_x + max_x + 1) / 2,
            }
        )

    return components


def component_group_image(
    source: Image.Image,
    components: list[dict[str, object]],
    padding: int = 4,
) -> tuple[Image.Image, tuple[int, int, int, int]]:
    width, height = source.size
    min_x = max(0, min(component["bbox"][0] for component in components) - padding)
    min_y = max(0, min(component["bbox"][1] for component in components) - padding)
    max_x = min(width, max(component["bbox"][2] for component in components) + padding)
    max_y = min(height, max(component["bbox"][3] for component in components) + padding)

    output = Image.new("RGBA", (max_x - min_x, max_y - min_y), (0, 0, 0, 0))
    source_pixels = source.load()
    output_pixels = output.load()
    for component in components:
        for pixel_index in component["pixels"]:
            x = pixel_index % width
            y = pixel_index // width
            output_pixels[x - min_x, y - min_y] = source_pixels[x, y]
    return output, (min_x, min_y, max_x, max_y)


def extract_component_sprites(
    strip: Image.Image,
    frame_count: int,
) -> list[tuple[Image.Image, tuple[int, int, int, int]]] | None:
    components = connected_components(strip)
    if not components:
        return None

    largest_area = max(component["area"] for component in components)
    seed_threshold = max(120, largest_area * 0.20)
    seeds = [component for component in components if component["area"] >= seed_threshold]
    if len(seeds) < frame_count:
        seeds = sorted(components, key=lambda component: component["area"], reverse=True)[
            :frame_count
        ]
    if len(seeds) < frame_count:
        return None

    seeds = sorted(
        sorted(seeds, key=lambda component: component["area"], reverse=True)[:frame_count],
        key=lambda component: component["center_x"],
    )
    seed_ids = {id(seed) for seed in seeds}
    groups: list[list[dict[str, object]]] = [[seed] for seed in seeds]
    noise_threshold = max(12, largest_area * 0.002)

    for component in components:
        if id(component) in seed_ids or component["area"] < noise_threshold:
            continue
        nearest_index = min(
            range(len(seeds)),
            key=lambda index: abs(seeds[index]["center_x"] - component["center_x"]),
        )
        groups[nearest_index].append(component)

    return [component_group_image(strip, group) for group in groups]


def extract_component_frames(strip: Image.Image, frame_count: int) -> list[Image.Image] | None:
    sprites = extract_component_sprites(strip, frame_count)
    if sprites is None:
        return None
    return fit_sprites_to_cells_consistently(sprites)


def extract_slot_sprites(
    strip: Image.Image,
    frame_count: int,
) -> list[tuple[Image.Image, tuple[int, int, int, int]]]:
    slot_width = strip.width / frame_count
    sprites = []
    for index in range(frame_count):
        left = round(index * slot_width)
        right = round((index + 1) * slot_width)
        crop = strip.crop((left, 0, right, strip.height))
        bbox = crop.getbbox()
        if bbox is None:
            sprites.append((Image.new("RGBA", (1, 1), (0, 0, 0, 0)), (left, 0, left + 1, 1)))
            continue
        sprite = crop.crop(bbox)
        sprites.append((sprite, (left + bbox[0], bbox[1], left + bbox[2], bbox[3])))
    return sprites


def extract_slot_frames(strip: Image.Image, frame_count: int) -> list[Image.Image]:
    return fit_sprites_to_cells_consistently(extract_slot_sprites(strip, frame_count))


def extract_state_sprites(
    strip_path: Path,
    state: str,
    chroma_key: tuple[int, int, int],
    threshold: float,
    method: str,
) -> dict[str, object]:
    frame_count = ROW_FRAME_COUNTS[state]
    with Image.open(strip_path) as opened:
        strip = remove_chroma_background(opened, chroma_key, threshold)

    sprites = None
    used_method = method
    if method in {"auto", "components"}:
        sprites = extract_component_sprites(strip, frame_count)
        if sprites is None and method == "components":
            raise SystemExit(f"could not find {frame_count} sprite components in {strip_path}")
        if sprites is not None:
            used_method = "components"

    if sprites is None:
        sprites = extract_slot_sprites(strip, frame_count)
        used_method = "slots"

    return {"state": state, "sprites": sprites, "method": used_method}


def global_scale_for_states(states: list[dict[str, object]]) -> float:
    sprites = [
        sprite
        for state in states
        for sprite, _bbox in state["sprites"]
        if isinstance(sprite, Image.Image)
    ]
    if not sprites:
        return 1.0
    max_width = max(sprite.width for sprite in sprites)
    max_height = max(sprite.height for sprite in sprites)
    return min(
        (CELL_WIDTH - CELL_PADDING) / max(1, max_width),
        (CELL_HEIGHT - CELL_PADDING) / max(1, max_height),
        1.0,
    )


def median(values: list[float]) -> float:
    if not values:
        return 0.0
    ordered = sorted(values)
    middle = len(ordered) // 2
    if len(ordered) % 2:
        return ordered[middle]
    return (ordered[middle - 1] + ordered[middle]) / 2


def row_median_height(state: dict[str, object]) -> float:
    heights = [
        sprite.height
        for sprite, _bbox in state["sprites"]
        if isinstance(sprite, Image.Image)
    ]
    return median([float(height) for height in heights])


def identity_row_scales(states: list[dict[str, object]]) -> dict[str, float]:
    reference_heights = [
        row_median_height(state)
        for state in states
        if state["state"] in REFERENCE_HEIGHT_STATES
    ]
    canonical_height = median([height for height in reference_heights if height > 0])
    row_scales: dict[str, float] = {}
    for state in states:
        state_name = str(state["state"])
        row_height = row_median_height(state)
        if canonical_height <= 0 or row_height <= 0 or state_name not in IDENTITY_HEIGHT_STATES:
            row_scales[state_name] = 1.0
            continue
        row_scales[state_name] = min(MAX_IDENTITY_ROW_SCALE, canonical_height / row_height)
    return row_scales


def write_state_frames(
    state: str,
    sprites: list[tuple[Image.Image, tuple[int, int, int, int]]],
    output_root: Path,
    used_method: str,
    scale: float | None,
) -> dict[str, object]:
    state_dir = output_root / state
    state_dir.mkdir(parents=True, exist_ok=True)
    frames = fit_sprites_to_cells_consistently(sprites, scale=scale)
    outputs = []
    for index, frame in enumerate(frames):
        output = state_dir / f"{index:02d}.png"
        frame.save(output)
        outputs.append(str(output))
    return {"state": state, "frames": outputs, "method": used_method}


def extract_state(
    strip_path: Path,
    state: str,
    output_root: Path,
    chroma_key: tuple[int, int, int],
    threshold: float,
    method: str,
) -> dict[str, object]:
    frame_count = ROW_FRAME_COUNTS[state]
    with Image.open(strip_path) as opened:
        strip = remove_chroma_background(opened, chroma_key, threshold)

    state_dir = output_root / state
    state_dir.mkdir(parents=True, exist_ok=True)

    frames = None
    used_method = method
    if method in {"auto", "components"}:
        frames = extract_component_frames(strip, frame_count)
        if frames is None and method == "components":
            raise SystemExit(f"could not find {frame_count} sprite components in {strip_path}")
        if frames is not None:
            used_method = "components"

    if frames is None:
        frames = extract_slot_frames(strip, frame_count)
        used_method = "slots"

    outputs = []
    for index, frame in enumerate(frames):
        output = state_dir / f"{index:02d}.png"
        frame.save(output)
        outputs.append(str(output))
    return {"state": state, "frames": outputs, "method": used_method}


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--decoded-dir", required=True)
    parser.add_argument("--output-dir", required=True)
    parser.add_argument("--states", default="all")
    parser.add_argument("--chroma-key", help="Override chroma key as #RRGGBB.")
    parser.add_argument("--key-threshold", type=float, default=96.0)
    parser.add_argument(
        "--method",
        choices=("auto", "components", "slots"),
        default="auto",
        help="Use connected sprite components when possible, or fixed equal slots.",
    )
    parser.add_argument(
        "--normalization",
        choices=("global", "row"),
        default="global",
        help="Use one atlas-wide scale by default; row keeps the older per-row scale.",
    )
    args = parser.parse_args()

    decoded_dir = Path(args.decoded_dir).expanduser().resolve()
    output_dir = Path(args.output_dir).expanduser().resolve()
    chroma_key = load_chroma_key(decoded_dir, args.chroma_key)
    states = parse_states(args.states)
    manifest = []
    if args.normalization == "global":
        extracted_states = []
        for state in states:
            strip_path = decoded_dir / f"{state}.png"
            if not strip_path.is_file():
                raise SystemExit(f"missing generated strip for {state}: {strip_path}")
            extracted_states.append(
                extract_state_sprites(
                    strip_path,
                    state,
                    chroma_key,
                    args.key_threshold,
                    args.method,
                )
            )
        common_scale = global_scale_for_states(extracted_states)
        row_scales = identity_row_scales(extracted_states)
        scales = {
            extracted["state"]: common_scale * row_scales[str(extracted["state"])]
            for extracted in extracted_states
        }
        for extracted in extracted_states:
            manifest.append(
                write_state_frames(
                    extracted["state"],
                    extracted["sprites"],
                    output_dir,
                    extracted["method"],
                    scales[extracted["state"]],
                )
            )
    else:
        scales = None
        row_scales = None
        for state in states:
            strip_path = decoded_dir / f"{state}.png"
            if not strip_path.is_file():
                raise SystemExit(f"missing generated strip for {state}: {strip_path}")
            manifest.append(
                extract_state(
                    strip_path,
                    state,
                    output_dir,
                    chroma_key,
                    args.key_threshold,
                    args.method,
                )
            )

    (output_dir / "frames-manifest.json").write_text(
        json.dumps(
            {
                "ok": True,
                "chroma_key": {
                    "hex": f"#{chroma_key[0]:02X}{chroma_key[1]:02X}{chroma_key[2]:02X}",
                    "rgb": list(chroma_key),
                    "threshold": args.key_threshold,
                },
                "normalization": {
                    "mode": args.normalization,
                    "scale": scales,
                    "row_scale": row_scales,
                    "reference_height_states": sorted(REFERENCE_HEIGHT_STATES),
                    "identity_height_states": sorted(IDENTITY_HEIGHT_STATES),
                    "anchor": "row-local foot baseline with atlas-wide base scale and row-level identity height correction",
                },
                "rows": manifest,
            },
            indent=2,
        )
        + "\n",
        encoding="utf-8",
    )
    print(json.dumps({"ok": True, "frames_root": str(output_dir), "states": states}, indent=2))


if __name__ == "__main__":
    main()

正規化問題と改善

旧処理では、各フレームを個別bboxで切り抜いて個別にfitしていました。 これだと、しゃがむ・倒れる・ジャンプするような姿勢で、実際の高さ変化が「拡大縮小」に変換され、 頭や体が不自然に大きく見えることがあります。

旧: frameごとの個別scale

各フレームのbboxだけを見てセルに収めるため、低い姿勢ほど拡大されやすく、 row内のキャラサイズが揺れます。

新: row単位でidentityを保つ

先に全rowの全フレームを抽出し、一番大きいキャラ矩形で基礎scaleを決めます。 その上で、左右移動のように本来同じ背丈で見えるべきrowだけ、行全体に同じ補正をかけます。

新: 足元baselineを保持

足元を基準に配置し、余った空間は頭より上に出します。 ジャンプや失敗姿勢はサイズ変更ではなく位置変化として残します。

Before: jumpingを各frameで個別fit

before normalization jumping animation

低い姿勢や小さく検出された姿勢をセルいっぱいに寄せるため、ジャンプの上下移動よりも 「キャラが伸び縮みしている」ように見えます。

After: 共通scale + 足元baseline

after normalization jumping animation

同じrowから切り出したフレームを、共通scaleのまま足元基準で配置します。 余白は頭上に逃がし、ジャンプはサイズ差ではなく上下位置の変化として残します。

normalized v2 contact sheet
改善後contact sheet。特にjumping rowで、上下移動がセル内のポジション変化として残る。
  1. 実装は extract_strip_frames.py。全フレーム最大矩形から1つのscaleを決定
  2. 左右移動rowは立ち姿系の標準高さを参照し、row全体で同じ倍率に補正
  3. frameごとの個別拡大はしないため、row内の同一性は維持
  4. 足元baselineで揃え、頭上の余白で高さ差を吸収
  5. 最終検証の final/validation.json は errors 0 / warnings 0

この改善は画像生成のやり直しではなく、既存の生成rowからフレームを切り出すdeterministic処理の修正です。 見た目のidentityはそのまま、アニメーション時のスケール揺れだけを減らしています。

左右移動が小さくなる問題の改善

画像生成には戻らず、切り抜き後の正規化だけで改善しました。 原因は、左右移動rowの元bboxが立ち姿より低く、全体scale固定だけではその小ささがそのまま残ったことです。 新処理では、idle / waving / waiting / review から標準キャラ高さを推定し、running-right / running-left / running だけをrow単位で同じ倍率に補正します。

検出

chroma key除去後、連結成分から各フレームのキャラbboxを抽出します。 frame単位ではなくrow単位で中央値を取り、偶然の手足や小物に引っ張られにくくします。

標準高さ

idle / waving / waiting / review を基準rowとして、標準キャラ高さを推定します。 failedやjumpingは意図的に高さが変わるので、この補正対象から外します。

補正

running-right / running-left / running はrow全体に同じ倍率をかけます。 これにより、移動中のキャラサイズだけが縮む違和感を抑えます。

identity normalized strider boy contact sheet
Strider Boy after: running-right / running-left / running が idle に近い高さへ揃ったcontact sheet。
  1. running-right 平均高さを約138pxから約188pxへ補正
  2. running-left 平均高さを約140pxから約188pxへ補正
  3. frames-manifest.jsonrow_scale を記録
  4. qa/review.jsonfinal/validation.json は errors 0 / warnings 0

dokochanにも同じ改善を適用

dokochanも旧正規化ではjumping rowが他rowと違う大きさに見え、jumpの上下移動が弱くなっていました。 同じ全体最大矩形scale、足元baseline、row単位identity補正で再finalizeし、正式petアセットも更新済みです。

Before: dokochanのjumpingを各frameで個別fit

dokochan before normalization jumping animation

低い姿勢がセルに合わせて拡大され、ジャンプの上下移動よりもサイズ差が目立っていました。

After: dokochanも共通scale + 足元baseline

dokochan after normalization jumping animation

キャラの大きさを保ったまま、ジャンプをセル内の上下位置の変化として見せます。

dokochan normalized contact sheet
dokochan after: 全体最大矩形scaleとrow単位identity補正で再finalizeしたcontact sheet。
  1. ~/.codex/pets/dokochan/spritesheet.webp は更新済み
  2. ~/.codex/pet-runs/dokochan/final/validation.json: errors 0 / warnings 0
  3. frames/frames-manifest.json: mode: global / row_scale 記録済み

pet-runs は作業履歴、pets がCodex Appの正式アセット置き場です。 今回は両方のpetについて正式アセット側も新しいspritesheetへ交換済みです。

今回の試行錯誤とノウハウ

最終的な学びは、petの品質は画像生成だけでは決まらず、生成済みrowをどう切り抜き、 どの単位で同一性を守るかで大きく変わる、という点です。 ここでは今回採用した判断と、途中で捨てた方針をまとめます。

1. 生成はrow単位

1枚の画像で全姿勢を一括生成するのではなく、baseを先に作り、 idle / running-right / running-left / jumping などをrow stripとして個別生成します。 その後、deterministicな切り抜き処理で192x208セルへ配置します。

2. 画像生成に戻りすぎない

右向きが左を向くなど、row自体の意味が壊れている場合は再生成が必要です。 ただし、ジャンプや左右移動のサイズ違和感は生成をやり直すと泥沼化しやすいため、 まず切り抜き・正規化アルゴリズムで直します。

3. frame単位fitは使わない

各frameのbboxをセルいっぱいに拡大すると、しゃがみ・ジャンプ・倒れ姿勢の高さ差が消えます。 その結果、動きが上下移動ではなく頭や体の拡大縮小に見えます。

4. 全体最大矩形を基礎scaleにする

全rowの全フレームを先に抽出し、一番大きいキャラ矩形がセルに収まるscaleを基礎値にします。 ジャンプの上昇や失敗姿勢の低さは、足元baselineからの位置変化として残します。

5. 左右移動はrow単位補正

左右移動rowは元画像のbboxが立ち姿より低いことがあり、全体scale固定だけでは小さく見えます。 idle / waving / waiting / review から標準高さを推定し、running系だけをrow全体で同じ倍率に補正します。

6. 補正対象を分ける

jumping / failed は高さが変わること自体が表現なので、row_scale補正対象から外します。 running-right / running-left / running は同じ背丈で見えるべき移動状態なので補正対象にします。

最終的にやっていること

背景を透明化し、各ポーズの不透明pixelをまとめてframeとして取り出します。 そのうえで全row共通の基礎scaleを決め、running系だけ標準身長に近づくようrow単位で補正し、 最後に足元baselineへ揃えてatlasへ配置します。

採用しなかった方針

  1. サイズ違和感のたびに画像生成をやり直す
  2. frameごとにセルへ最大fitする
  3. 全rowを同じ高さへ強制し、jumping / failed の意味まで消す
  4. pet-runsだけを正式アセットとして扱う

~/.codex/pet-runs/<pet> は作業履歴とQA置き場です。 Codex Appが読む正式アセットは ~/.codex/pets/<pet>/pet.json~/.codex/pets/<pet>/spritesheet.webp です。

今後の課題

今回の方式は、画像生成をやり直さずに実用的な品質へ寄せるためのbbox/row統計ベースの改善です。 ただし、キャラ本体を意味的に理解しているわけではないため、まだ弱点があります。

本体と小物の分離

自転車、ハンドル、手足、髪の跳ねなどを意味的に分離していないため、長い小物があるrowではbboxや幅の基準が引っ張られる可能性があります。

基準rowへの依存

idle / waving / waiting / review から標準高さを推定しているため、これらのrow自体が崩れているpetでは補正基準も崩れます。

固定ルールの限界

running系は補正し、jumping / failed は補正しない、というルールは今回のpetには合っていますが、別キャラや別モーションでは調整が必要になる場合があります。

次に入れるなら

APIなしでさらに強くするなら、semantic segmentationではなく、足元点・頭頂点・胴体中心を推定する軽量ヒューリスティックが現実的です。 それにより、bbox全体ではなく「キャラ本体らしさ」に近い基準でrow_scaleを決められます。

残る不確実性

  1. 極端に小さいrowでは MAX_IDENTITY_ROW_SCALE の上限に当たる
  2. 横長propがあるpetでは横幅制約で拡大しきれない
  3. 頭頂や足元を本当に認識しているわけではなく、alpha bboxに基づく推定である

完成物と検証結果

complete contact sheet
contact-sheet: 全row、使用セル、未使用透明セルの一覧。
  1. QA結果の qa/review.json は errors 0 / warnings 0
  2. 最終検証の final/validation.json は errors 0 / warnings 0
  3. 完成spritesheetの final/spritesheet.webp は 1536x1872 RGBA
  4. プレビュー動画 qa/videos/*.mp4 は9 row分を生成済み
  5. 正式アセット ~/.codex/pets/strider-boy に pet.json と spritesheet.webp をpackage済み

画像生成はすべて組み込み画像生成で行い、API KEY fallbackは使っていません。 row生成はsubagentに委譲し、親エージェントだけがmanifest記録とpackage処理を行いました。