2026-05-08
Codexのpetスキルの問題点を改善する ... users
Codexでpetという機能があり、いくつかキャラを作成してみたのですが、挙動がおかしいです。 例えば、キャラの顔の大きさがグワグワ変わります。みなさんはそういうことないでしょうか?
画像を確認してみると、画像生成自体は問題なさそうなのですが、切り抜き方に問題があることがわかってきました。 今回はCodexと相談しながらキャラの挙動改善を行った話です。
Before: dokochanのjumpingを各frameで個別fit
低い姿勢がセルに合わせて拡大され、ジャンプの上下移動よりもサイズ差が目立っていました。
After: dokochanも共通scale + 足元baseline
キャラの大きさを保ったまま、ジャンプをセル内の上下位置の変化として見せます。
作成した順番
run manifestを作成
~/.codex/pet-runs/strider-boy にrunフォルダを作り、
Strider Boyのidentity、rowごとのprompt、layout guide、出力先を確定しました。
base画像を生成
写真から赤ヘルメット、黒サングラス、ネイビー服、青パンツ、赤いバランスバイクを抽出し、 以後のrow生成のcanonical referenceにしました。
identity check rowを先に生成
subagentでidleとrunning-rightを先に生成しました。
静止と走行で同じ男の子に見えることを確認してから残りrowに進みました。
running-leftはミラーせず生成
baseの赤バイクに文字が入っていたため、左右反転すると意味が壊れます。
そのためrunning-leftはミラー派生ではなく、参照付きで別生成しました。
残りrowをsubagentで並列生成
waving、jumping、failed、
waiting、running、reviewをrowごとに生成しました。
親エージェントはmanifest記録とpackageだけを担当しました。
atlas化とQA
生成rowから各フレームを抽出し、透明背景の 192x208 セルへ並べ直しました。
この「全モーションのセルを1枚に敷き詰めた画像」がatlasで、Codex Appは
pet.json のrow定義を見ながらatlas上の該当セルを順番に再生します。
生成したrow strips
row stripは、1つのモーションだけを横長に並べた生成元画像です。 これをそのままpetとして使うのではなく、各rowからフレームを切り出して、 最後に1枚のspritesheet atlasへ再配置します。
row 0: idle / 6 frames
row 1: running-right / 8 frames
row 2: running-left / 8 frames
row 3: waving / 4 frames
row 4: jumping / 5 frames
row 5: failed / 8 frames
row 6: waiting / 6 frames
row 7: running / 6 frames
row 8: review / 6 frames
row stripをどう切り出しているか
生成AIには「横長の1枚に複数ポーズを並べる」と依頼します。
その後、extract_strip_frames.py が背景を透過し、ポーズ単位の連結成分を検出して、
Codex pet用の 192x208 セルへ配置します。
row画像のマゼンタ背景を色距離で透明化します。 背景が完全な単色でなくても、しきい値内の近似色は透明になります。
透明化後の不透明pixelを探索し、つながった塊を検出します。 大きい塊を各フレームの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していました。 これだと、しゃがむ・倒れる・ジャンプするような姿勢で、実際の高さ変化が「拡大縮小」に変換され、 頭や体が不自然に大きく見えることがあります。
各フレームのbboxだけを見てセルに収めるため、低い姿勢ほど拡大されやすく、 row内のキャラサイズが揺れます。
先に全rowの全フレームを抽出し、一番大きいキャラ矩形で基礎scaleを決めます。 その上で、左右移動のように本来同じ背丈で見えるべきrowだけ、行全体に同じ補正をかけます。
足元を基準に配置し、余った空間は頭より上に出します。 ジャンプや失敗姿勢はサイズ変更ではなく位置変化として残します。
Before: jumpingを各frameで個別fit
低い姿勢や小さく検出された姿勢をセルいっぱいに寄せるため、ジャンプの上下移動よりも 「キャラが伸び縮みしている」ように見えます。
After: 共通scale + 足元baseline
同じrowから切り出したフレームを、共通scaleのまま足元基準で配置します。 余白は頭上に逃がし、ジャンプはサイズ差ではなく上下位置の変化として残します。
- 実装は
extract_strip_frames.py。全フレーム最大矩形から1つのscaleを決定 - 左右移動rowは立ち姿系の標準高さを参照し、row全体で同じ倍率に補正
- frameごとの個別拡大はしないため、row内の同一性は維持
- 足元baselineで揃え、頭上の余白で高さ差を吸収
- 最終検証の
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全体に同じ倍率をかけます。 これにより、移動中のキャラサイズだけが縮む違和感を抑えます。
- running-right 平均高さを約138pxから約188pxへ補正
- running-left 平均高さを約140pxから約188pxへ補正
frames-manifest.jsonにrow_scaleを記録qa/review.jsonとfinal/validation.jsonは errors 0 / warnings 0
dokochanにも同じ改善を適用
dokochanも旧正規化ではjumping rowが他rowと違う大きさに見え、jumpの上下移動が弱くなっていました。 同じ全体最大矩形scale、足元baseline、row単位identity補正で再finalizeし、正式petアセットも更新済みです。
Before: dokochanのjumpingを各frameで個別fit
低い姿勢がセルに合わせて拡大され、ジャンプの上下移動よりもサイズ差が目立っていました。
After: dokochanも共通scale + 足元baseline
キャラの大きさを保ったまま、ジャンプをセル内の上下位置の変化として見せます。
~/.codex/pets/dokochan/spritesheet.webpは更新済み~/.codex/pet-runs/dokochan/final/validation.json: errors 0 / warnings 0frames/frames-manifest.json:mode: global/row_scale記録済み
pet-runs は作業履歴、pets がCodex Appの正式アセット置き場です。
今回は両方のpetについて正式アセット側も新しいspritesheetへ交換済みです。
今回の試行錯誤とノウハウ
最終的な学びは、petの品質は画像生成だけでは決まらず、生成済みrowをどう切り抜き、 どの単位で同一性を守るかで大きく変わる、という点です。 ここでは今回採用した判断と、途中で捨てた方針をまとめます。
1枚の画像で全姿勢を一括生成するのではなく、baseを先に作り、 idle / running-right / running-left / jumping などをrow stripとして個別生成します。 その後、deterministicな切り抜き処理で192x208セルへ配置します。
右向きが左を向くなど、row自体の意味が壊れている場合は再生成が必要です。 ただし、ジャンプや左右移動のサイズ違和感は生成をやり直すと泥沼化しやすいため、 まず切り抜き・正規化アルゴリズムで直します。
各frameのbboxをセルいっぱいに拡大すると、しゃがみ・ジャンプ・倒れ姿勢の高さ差が消えます。 その結果、動きが上下移動ではなく頭や体の拡大縮小に見えます。
全rowの全フレームを先に抽出し、一番大きいキャラ矩形がセルに収まるscaleを基礎値にします。 ジャンプの上昇や失敗姿勢の低さは、足元baselineからの位置変化として残します。
左右移動rowは元画像のbboxが立ち姿より低いことがあり、全体scale固定だけでは小さく見えます。 idle / waving / waiting / review から標準高さを推定し、running系だけをrow全体で同じ倍率に補正します。
jumping / failed は高さが変わること自体が表現なので、row_scale補正対象から外します。 running-right / running-left / running は同じ背丈で見えるべき移動状態なので補正対象にします。
最終的にやっていること
背景を透明化し、各ポーズの不透明pixelをまとめてframeとして取り出します。 そのうえで全row共通の基礎scaleを決め、running系だけ標準身長に近づくようrow単位で補正し、 最後に足元baselineへ揃えてatlasへ配置します。
採用しなかった方針
- サイズ違和感のたびに画像生成をやり直す
- frameごとにセルへ最大fitする
- 全rowを同じ高さへ強制し、jumping / failed の意味まで消す
- pet-runsだけを正式アセットとして扱う
~/.codex/pet-runs/<pet> は作業履歴とQA置き場です。
Codex Appが読む正式アセットは ~/.codex/pets/<pet>/pet.json と
~/.codex/pets/<pet>/spritesheet.webp です。
今後の課題
今回の方式は、画像生成をやり直さずに実用的な品質へ寄せるためのbbox/row統計ベースの改善です。 ただし、キャラ本体を意味的に理解しているわけではないため、まだ弱点があります。
自転車、ハンドル、手足、髪の跳ねなどを意味的に分離していないため、長い小物があるrowではbboxや幅の基準が引っ張られる可能性があります。
idle / waving / waiting / review から標準高さを推定しているため、これらのrow自体が崩れているpetでは補正基準も崩れます。
running系は補正し、jumping / failed は補正しない、というルールは今回のpetには合っていますが、別キャラや別モーションでは調整が必要になる場合があります。
次に入れるなら
APIなしでさらに強くするなら、semantic segmentationではなく、足元点・頭頂点・胴体中心を推定する軽量ヒューリスティックが現実的です。 それにより、bbox全体ではなく「キャラ本体らしさ」に近い基準でrow_scaleを決められます。
残る不確実性
- 極端に小さいrowでは
MAX_IDENTITY_ROW_SCALEの上限に当たる - 横長propがあるpetでは横幅制約で拡大しきれない
- 頭頂や足元を本当に認識しているわけではなく、alpha bboxに基づく推定である
完成物と検証結果
- QA結果の
qa/review.jsonは errors 0 / warnings 0 - 最終検証の
final/validation.jsonは errors 0 / warnings 0 - 完成spritesheetの
final/spritesheet.webpは 1536x1872 RGBA - プレビュー動画
qa/videos/*.mp4は9 row分を生成済み - 正式アセット
~/.codex/pets/strider-boyに pet.json と spritesheet.webp をpackage済み
画像生成はすべて組み込み画像生成で行い、API KEY fallbackは使っていません。 row生成はsubagentに委譲し、親エージェントだけがmanifest記録とpackage処理を行いました。