#!/usr/bin/env python3 """もちバトル: GPT が描いたイラストを、携帯ゲーム機風の小さなドット絵に変換するツール。 やること: 0. ドット絵を整数倍に拡大した画像なら、元のドットの大きさに戻す(劣化なし) 1. 背景を抜く(透過 PNG はそのまま。単色背景は四隅の色、または指定色を抜く) 2. 横に並んだコマを等分に切り分ける(全コマで同じ切り抜き範囲・同じ縮尺を使う) 3. 決められた小さなサイズへ縮小する(box: 平均 / mode: 多数決) 4. 色数を絞り(スプライトは透明+15色)、各色を RGB 各5ビット(15ビットカラー)に丸める 5. 半透明をなくす(透明か不透明かの2値)。必要なら外側に1ドットの輪郭線を付ける 6. コマを横に並べたシートの PNG(インデックスカラー)と、拡大プレビューを書き出す 使い方: python pixelize.py batch 入力フォルダ 出力フォルダ ファイル名から種類を自動で判断して、まとめて変換する(下の NAME_RULES 参照) python pixelize.py convert 入力.png -o 出力.png --preset mochi [--frames 5] ... 1枚ずつ、設定を上書きしながら変換する python pixelize.py check スキン.png [...] スキン規格(仕様書 16.5)を満たしているかを検査する 必要なもの: Python 3.9+、Pillow、numpy """ from __future__ import annotations import argparse import json import sys from dataclasses import dataclass, replace from pathlib import Path import numpy as np from PIL import Image # ---------------------------------------------------------------- 設定 @dataclass(frozen=True) class Preset: size: tuple[int, int] # 1コマの出力サイズ(幅, 高さ) frames: int = 1 # 横に並んだコマ数 alpha: bool = True # 透過あり(スプライト)/ なし(背景・カード) fit: str = "contain" # contain: 全体を収める / cover: 埋めてはみ出しを切る anchor: str = "center" # center / bottom(足元をそろえる)/ foot(足元の影の中心を foot_x に置く) foot_x: int = 24 # anchor="foot" のときの、足元の影の中心の x colors: int = 15 # 透明色を除く最大色数 outline: bool = False # 外側に1ドットの輪郭線を付ける resample: str = "box" # box: 平均 / mode: 多数決(ベタ塗りがくっきり) dither: bool = False # 4×4 の規則ディザ tile: bool = False # 上下左右につなげて敷きつめる絵(スクロール背景) PRESETS: dict[str, Preset] = { # もち: 48×48、5ポーズ。足元を下端にそろえる "mochi": Preset(size=(48, 48), frames=5, anchor="bottom", outline=True, resample="mode"), # 動作シート: 64×48(腕を伸ばす余白として前方に16ドット広い)、3コマ。足元の位置はもちシートと同じ x=24 "moves": Preset(size=(64, 48), frames=3, anchor="foot", outline=True, resample="mode"), # スキンのアイコン: 24×24(任意) "icon": Preset(size=(24, 24), resample="mode"), # 床パネル: 40×24(6枚並べると画面幅 240 になる)。P1・P2 の2コマ "panels": Preset(size=(40, 24), frames=2, fit="cover", resample="mode"), # 床パネルの地形(陣地の色なし)と、陣地の枠(P1・P2 の2コマ、中央は透明) "panel": Preset(size=(40, 24), fit="cover", resample="mode"), "frames": Preset(size=(40, 24), frames=2, resample="mode"), # カード絵: 48×48、透過なし "card": Preset(size=(48, 48), alpha=False, fit="cover", colors=31, resample="mode"), # 効果: 32×32 が基本。コマ数とサイズは FX で上書き "fx": Preset(size=(32, 32)), # チューンのパーツ: 16×16 のタイル7種 "parts": Preset(size=(16, 16), frames=7, fit="cover", resample="mode"), # もち盤: 盤 80×80(16×16 のマスが5×5)、芯・焼き目は 16×16 "board": Preset(size=(80, 80), resample="mode"), "board-cell": Preset(size=(16, 16), resample="mode"), # ロゴ: 200×60(GPT のロゴは約200ドット幅で描かれるため。160×48 では顔がつぶれた) "logo": Preset(size=(200, 60), colors=31, outline=True, resample="mode"), # 背景: 240×160(画面1枚ぶん)、透過なし "bg": Preset(size=(240, 160), alpha=False, fit="cover", colors=63), # スクロール背景のタイル: 64×64 を敷きつめて斜めに流す。透過なし、GBA の背景1枚ぶんの15色 "bg-tile": Preset(size=(64, 64), alpha=False, fit="cover", colors=15, resample="mode", tile=True), } # 効果ごとのコマ数と、32×32 以外のサイズ FX_FRAMES = { "shot": 1, "charge-shot": 3, "charge-glow": 4, "cannon": 3, "blade": 3, "wide-blade": 3, "lance-blade": 3, "grenade": 1, "explosion": 4, "wave": 4, "repair": 4, "cloak": 1, "shell": 2, "land-grab": 4, } FX_SIZE = { "wide-blade": (32, 72), # 縦3マスぶん "lance-blade": (80, 24), # 横2マスぶん "land-grab": (40, 72), # 1列(縦3マス) } OUTLINE_COLOR = "#3a2a20" # スタイルガイドの「濃い焦げ茶」 TRANSPARENT_SLOT = (255, 0, 255) # パレット0番(透明)の色。表示されない def preset_for_name(stem: str) -> tuple[str, Preset] | None: """ファイル名(拡張子なし)から種類を決める。わからなければ None。""" if stem.startswith("mochi-") and stem.endswith("-moves"): return "moves", PRESETS["moves"] # 振る・突く・投げる if stem.startswith("mochi-"): return "mochi", PRESETS["mochi"] if stem == "panels": return "panels", PRESETS["panels"] if stem.startswith("panel-"): return "panel", PRESETS["panel"] if stem == "frames": return "frames", PRESETS["frames"] if stem.startswith("card-"): return "card", PRESETS["card"] if stem.startswith("fx-"): name = stem[3:] if name not in FX_FRAMES: return None p = replace(PRESETS["fx"], frames=FX_FRAMES[name]) if name in FX_SIZE: p = replace(p, size=FX_SIZE[name]) return "fx", p if stem == "parts": return "parts", PRESETS["parts"] if stem == "board": return "board", PRESETS["board"] if stem in ("board-core", "board-yakime"): return "board-cell", PRESETS["board-cell"] if stem == "logo": return "logo", PRESETS["logo"] if stem == "bg-arena" or (stem.startswith("bg-") and stem.endswith("-tile")): return "bg-tile", PRESETS["bg-tile"] if stem.startswith("bg-"): return "bg", PRESETS["bg"] return None # ---------------------------------------------------------------- 色 def parse_hex(s: str) -> tuple[int, int, int]: s = s.lstrip("#") if len(s) != 6: raise ValueError(f"色は #rrggbb で指定してください: {s}") return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16) def snap555(pal: np.ndarray) -> np.ndarray: """各チャンネルを5ビットに丸め、8ビットへ戻す(15ビットカラー)。""" v5 = np.clip(np.rint(pal.astype(np.float64) * 31 / 255), 0, 31).astype(np.int32) return ((v5 << 3) | (v5 >> 2)).astype(np.uint8) def unique_rows(pal: np.ndarray) -> np.ndarray: if len(pal) == 0: return pal _, first = np.unique(pal, axis=0, return_index=True) return pal[np.sort(first)] def build_palette(pixels: np.ndarray, k: int) -> np.ndarray: """不透明画素(N×3)から最大 k 色のパレットを作る(median cut → 15ビット化)。""" if len(pixels) == 0 or k <= 0: return np.zeros((0, 3), np.uint8) if len(pixels) > 400_000: # 大きすぎる場合は間引く(結果はほぼ変わらない) step = len(pixels) // 400_000 + 1 pixels = pixels[::step] # 面積の小さい「とても暗い色」(目・線)は median cut で消えやすいので、1色ぶん取っておく lum = pixels.astype(np.float64) @ np.array([0.299, 0.587, 0.114]) reserve = None if k >= 4: dark = pixels[lum <= np.percentile(lum, 1)] if len(dark) and lum.min() < np.median(lum) * 0.5: reserve = dark.mean(axis=0) k -= 1 img = Image.fromarray(np.ascontiguousarray(pixels.reshape(1, -1, 3).astype(np.uint8))) q = img.quantize(colors=k, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE) used = np.unique(np.asarray(q)) pal = np.array(q.getpalette()[: 3 * 256], np.uint8).reshape(-1, 3)[used] if reserve is not None: d = np.sqrt(((pal.astype(np.float64) - reserve) ** 2).sum(axis=1)).min() if d > 40: pal = np.vstack([pal, np.rint(reserve).astype(np.uint8)]) return unique_rows(snap555(pal)) _W = np.array([3.0, 4.0, 2.0]) # 人の目に合わせた重み(緑の差に敏感) _BAYER4 = (np.array([[0, 8, 2, 10], [12, 4, 14, 6], [3, 11, 1, 9], [15, 7, 13, 5]]) + 0.5) / 16 - 0.5 def map_to_palette(rgb: np.ndarray, pal: np.ndarray, dither: bool = False) -> np.ndarray: """rgb(H×W×3)をパレット番号(H×W)にする。""" x = rgb.astype(np.float64) if dither: h, w = x.shape[:2] t = np.tile(_BAYER4, (h // 4 + 1, w // 4 + 1))[:h, :w] x = x + t[..., None] * 24 best = np.full(x.shape[:2], np.inf) idx = np.zeros(x.shape[:2], np.int32) for i, c in enumerate(pal.astype(np.float64)): d = (((x - c) ** 2) * _W).sum(axis=-1) better = d < best best[better] = d[better] idx[better] = i return idx # ---------------------------------------------------------------- 背景抜き def _dilate(mask: np.ndarray, n: int) -> np.ndarray: m = mask.copy() for _ in range(n): p = np.pad(m, 1) m = m | p[:-2, 1:-1] | p[2:, 1:-1] | p[1:-1, :-2] | p[1:-1, 2:] return m def remove_background(arr: np.ndarray, key: str, tol: float) -> tuple[np.ndarray, str]: """arr: H×W×4 uint8。戻り値: 背景を抜いた配列と、何をしたかの説明。""" a = arr[..., 3] if key == "none": return arr, "背景抜きなし" if key == "auto" and (a < 250).mean() > 0.01: return arr, "透過PNGをそのまま使用" if key in ("auto", "corners"): h, w = a.shape s = max(2, min(h, w) // 64) patches = [arr[:s, :s, :3], arr[:s, -s:, :3], arr[-s:, :s, :3], arr[-s:, -s:, :3]] means = np.array([p.reshape(-1, 3).mean(axis=0) for p in patches]) if np.abs(means - means.mean(axis=0)).max() > 24: if key == "corners": raise ValueError("四隅の色がそろっていないため、背景色を決められません") return arr, "透過も単色背景もないため背景抜きなし" kc = means.mean(axis=0) elif key == "green": kc = np.array([0.0, 255.0, 0.0]) else: kc = np.array(parse_hex(key), np.float64) rgb = arr[..., :3].astype(np.float64) dist = np.sqrt(((rgb - kc) ** 2).sum(axis=-1)) out = arr.copy() bg = dist < tol out[..., 3] = np.where(bg, 0, a) # 緑背景のにじみ取り: 背景に近い縁の画素だけ、緑を赤・青の大きいほうまで下げる if kc[1] > kc[0] + 60 and kc[1] > kc[2] + 60: near = _dilate(bg, 2) & ~bg r, g, b = out[..., 0].astype(np.int32), out[..., 1].astype(np.int32), out[..., 2].astype(np.int32) out[..., 1] = np.where(near, np.minimum(g, np.maximum(r, b)), g).astype(np.uint8) return out, f"背景色 #{int(kc[0]):02x}{int(kc[1]):02x}{int(kc[2]):02x} を除去" # ---------------------------------------------------------------- 縮小 def resize_premultiplied(arr: np.ndarray, w: int, h: int) -> np.ndarray: img = Image.fromarray(np.ascontiguousarray(arr)).convert("RGBa") return np.asarray(img.resize((w, h), Image.Resampling.BOX).convert("RGBA")) def downscale_mode(idx: np.ndarray, w: int, h: int, k: int, dark: np.ndarray | None = None, dark_share: float = 0.2) -> np.ndarray: """パレット番号の画像を、ブロックごとの多数決で縮小する(-1 は透明)。 dark(暗い色のパレット番号の配列)を渡すと、ブロックの dark_share 以上が暗い色のとき 暗い色を優先する。目や細い輪郭線のような小さく暗い部分が、多数決で消えないようにするため。 """ H, W = idx.shape if H < h or W < w: # 拡大になる場合は最近傍 ys = (np.arange(h) * H / h).astype(int) xs = (np.arange(w) * W / w).astype(int) return idx[ys][:, xs] ys = np.rint(np.linspace(0, H, h + 1)).astype(int) xs = np.rint(np.linspace(0, W, w + 1)).astype(int) out = np.full((h, w), -1, np.int32) for j in range(h): for i in range(w): block = idx[ys[j]:max(ys[j + 1], ys[j] + 1), xs[i]:max(xs[i + 1], xs[i] + 1)].ravel() opaque = block[block >= 0] if len(opaque) * 2 >= len(block): counts = np.bincount(opaque, minlength=k) if dark is not None and len(dark) and counts[dark].sum() >= dark_share * len(opaque): out[j, i] = dark[counts[dark].argmax()] else: out[j, i] = counts.argmax() return out # ---------------------------------------------------------------- 仕上げ def add_outline(idx: np.ndarray, color_index: int) -> np.ndarray: op = idx >= 0 ring = _dilate(op, 1) & ~op out = idx.copy() out[ring] = color_index return out def clean_specks(idx: np.ndarray) -> np.ndarray: """上下左右がすべて同じ別の色に囲まれた1ドットを、その色に置き換える。""" p = np.pad(idx, 1, constant_values=-2) n = [p[:-2, 1:-1], p[2:, 1:-1], p[1:-1, :-2], p[1:-1, 2:]] same = (n[0] == n[1]) & (n[1] == n[2]) & (n[2] == n[3]) target = same & (n[0] != idx) & (n[0] >= 0) & (idx >= 0) out = idx.copy() out[target] = n[0][target] return out # ---------------------------------------------------------------- ドット絵の元サイズ検出 def _axis_grid(arr: np.ndarray, axis: int) -> tuple[int, int]: """一方向について、色の変わり目が k ピクセルおきにそろっているかを調べる。(k, 位相) を返す。""" a = arr if axis == 1 else arr.transpose(1, 0, 2) rows = a[:: max(1, a.shape[0] // 96)] diff = np.any(rows[:, 1:] != rows[:, :-1], axis=2) pos = np.concatenate([np.nonzero(d)[0] + 1 for d in diff]) if len(diff) else np.array([]) if len(pos) < 40: return 1, 0 for k in range(16, 1, -1): counts = np.bincount(pos % k, minlength=k) if counts.max() >= 0.95 * len(pos): return k, int(counts.argmax()) return 1, 0 def detect_dot_grid(arr: np.ndarray) -> tuple[int, int, int]: """ドット絵を整数倍に拡大した画像なら (1ドットの大きさ, 縦の位相, 横の位相)。違えば (1, 0, 0)。""" kx, px = _axis_grid(arr, 1) ky, py = _axis_grid(arr, 0) if kx >= 2 and kx == ky: return kx, py, px return 1, 0, 0 # ---------------------------------------------------------------- 変換本体 @dataclass class Result: sheet: np.ndarray # 高さ×(幅×コマ数) のパレット番号(-1 は透明) palette: np.ndarray # K×3 notes: list[str] def convert(img: Image.Image, p: Preset, key: str = "auto", tol: float = 60.0, fixed_palette: np.ndarray | None = None, clean: bool = False, outline_color: str = OUTLINE_COLOR, native: str = "auto") -> Result: notes: list[str] = [] arr = np.asarray(img.convert("RGBA")).copy() if p.alpha: arr, msg = remove_background(arr, key, tol) notes.append(msg) else: arr[..., 3] = 255 # ドット絵を拡大した画像なら、まず元のドットの大きさに戻す(劣化なし) is_native = False if native == "auto": k, py, px = detect_dot_grid(arr) if k >= 2: arr = np.ascontiguousarray(arr[(py + k // 2) % k::k, (px + k // 2) % k::k]) is_native = True notes.append(f"ドット絵を検出(1ドット={k}px、元の大きさ {arr.shape[1]}×{arr.shape[0]})") if is_native: # 縮小は多数決で行う。元より大きくは拡大しない p = replace(p, resample="mode") # コマに分ける fw = arr.shape[1] // p.frames if fw == 0: raise ValueError("コマ数が画像の幅より多いです") frames = [arr[:, i * fw:(i + 1) * fw] for i in range(p.frames)] # 全コマ共通の切り抜き範囲(描かれている部分の外接矩形の和) union = np.logical_or.reduce([f[..., 3] >= 128 for f in frames]) if not union.any(): raise ValueError("描かれている部分が見つかりません(背景抜きの許容値を下げてみてください)") ys, xs = np.nonzero(union) y0, y1, x0, x1 = ys.min(), ys.max() + 1, xs.min(), xs.max() + 1 crops = [np.ascontiguousarray(f[y0:y1, x0:x1]) for f in frames] bh, bw = y1 - y0, x1 - x0 if is_native and p.fit == "contain" and min(p.size[0] / bw, p.size[1] / bh) >= 0.9: # ほぼ縮小しないなら元の1ドットの輪郭線が残るので、輪郭線を足さない p = replace(p, outline=False) margin = 1 if p.outline else 0 tw, th = p.size[0] - 2 * margin, p.size[1] - 2 * margin s = (min if p.fit == "contain" else max)(tw / bw, th / bh) if is_native and p.fit == "contain": s = min(s, 1.0) nw, nh = max(1, round(bw * s)), max(1, round(bh * s)) k = p.colors - (1 if p.outline else 0) if p.resample == "box": small = [resize_premultiplied(c, nw, nh) for c in crops] opaque_px = np.concatenate([c[..., :3][c[..., 3] >= 128] for c in small]) pal = fixed_palette if fixed_palette is not None else build_palette(opaque_px, k) if p.outline: # 輪郭線の色を先に入れておき、目などの暗い部分がこの色に寄るようにする pal = unique_rows(np.vstack([pal, snap555(np.array([parse_hex(outline_color)]))])) idxs = [] for c in small: m = map_to_palette(c[..., :3], pal, p.dither) m[c[..., 3] < 128] = -1 idxs.append(m) else: opaque_px = np.concatenate([c[..., :3][c[..., 3] >= 128] for c in crops]) pal = fixed_palette if fixed_palette is not None else build_palette(opaque_px, k) if p.outline: # 輪郭線の色を先に入れておき、目などの暗い部分がこの色に寄るようにする pal = unique_rows(np.vstack([pal, snap555(np.array([parse_hex(outline_color)]))])) lum = pal.astype(np.float64) @ np.array([0.299, 0.587, 0.114]) dark_idx = np.nonzero(lum < min(90.0, np.median(lum) * 0.45))[0] idxs = [] for c in crops: full = map_to_palette(c[..., :3], pal) full[c[..., 3] < 128] = -1 idxs.append(downscale_mode(full, nw, nh, len(pal), dark=dark_idx)) oc = None if p.outline: col = snap555(np.array([parse_hex(outline_color)]))[0] hit = np.where((pal == col).all(axis=1))[0] if len(hit): oc = int(hit[0]) else: pal = np.vstack([pal, col]) oc = len(pal) - 1 W, Hh = p.size placed = [] for m in idxs: canvas = np.full((Hh, W), -1, np.int32) if p.fit == "cover": cy, cx = (m.shape[0] - th) // 2, (m.shape[1] - tw) // 2 canvas[margin:margin + th, margin:margin + tw] = m[cy:cy + th, cx:cx + tw] else: ox = margin + (tw - nw) // 2 oy = margin + (th - nh if p.anchor in ("bottom", "foot") else (th - nh) // 2) if p.anchor == "foot": rows = np.nonzero((m >= 0).any(axis=1))[0] if len(rows): xs = np.nonzero(m[rows.max()] >= 0)[0] ox = p.foot_x - int(round((xs.min() + xs.max()) / 2)) # はみ出した部分は切り捨てて貼る y0, x0 = max(oy, 0), max(ox, 0) y1, x1 = min(oy + nh, Hh), min(ox + nw, W) if y1 > y0 and x1 > x0: canvas[y0:y1, x0:x1] = m[y0 - oy:y1 - oy, x0 - ox:x1 - ox] if (y0, x0, y1, x1) != (oy, ox, oy + nh, ox + nw): notes.append("一部がコマからはみ出したため切り捨てました") if clean: canvas = clean_specks(canvas) if oc is not None: canvas = add_outline(canvas, oc) if not p.alpha: canvas[canvas < 0] = 0 placed.append(canvas) notes.append(f"{p.frames}コマ × {W}×{Hh}、{len(pal)}色(上限 {p.colors})") sheet = np.hstack(placed) if p.tile: notes.extend(seam_notes(sheet)) return Result(sheet, pal, notes) def seam_notes(a: np.ndarray) -> list[str]: """敷きつめたときの継ぎ目を調べる。端どうしの色の食い違いが、絵の内側の隣どうしより ずっと多ければ、継ぎ目が見えるおそれがある。""" out = [] for name, inner, seam in ( ("左右", (a[:, 1:] != a[:, :-1]).mean(), (a[:, 0] != a[:, -1]).mean()), ("上下", (a[1:] != a[:-1]).mean(), (a[0] != a[-1]).mean()), ): ok = seam <= inner * 2 + 0.1 out.append(f"{name}の継ぎ目 {'OK' if ok else '要確認'}(端の食い違い {seam:.0%}、内側 {inner:.0%})") return out # ---------------------------------------------------------------- 書き出し def save_outputs(res: Result, p: Preset, out: Path, preview: int) -> None: out.parent.mkdir(parents=True, exist_ok=True) pal = [tuple(int(v) for v in c) for c in res.palette] if p.alpha: full = [TRANSPARENT_SLOT] + pal data = (res.sheet + 1).astype(np.uint8) else: full = pal data = np.clip(res.sheet, 0, None).astype(np.uint8) img = Image.frombytes("P", (data.shape[1], data.shape[0]), np.ascontiguousarray(data).tobytes()) flat = [v for c in full for v in c] img.putpalette(flat + [0] * (768 - len(flat))) if p.alpha: img.info["transparency"] = 0 img.save(out, transparency=0) else: img.save(out) if preview > 1: rgba = img.convert("RGBA") rgba.resize((rgba.width * preview, rgba.height * preview), Image.Resampling.NEAREST) \ .save(out.with_name(out.stem + f".preview-x{preview}.png")) if p.tile: # 画面 240×160 に敷きつめた見え方(継ぎ目の確認用) scr = Image.new("RGBA", (240, 160)) for ty in range(0, 160, rgba.height): for tx in range(0, 240, rgba.width): scr.paste(rgba, (tx, ty)) scr.resize((240 * preview, 160 * preview), Image.Resampling.NEAREST) \ .save(out.with_name(out.stem + f".screen-x{preview}.png")) meta = { "frames": p.frames, "frame_size": list(p.size), "transparent_index": 0 if p.alpha else None, "colors": ["#%02x%02x%02x" % c for c in full], "bgr555": ["0x%04x" % ((c[2] >> 3) << 10 | (c[1] >> 3) << 5 | (c[0] >> 3)) for c in full], } out.with_suffix(".json").write_text(json.dumps(meta, ensure_ascii=False, indent=2), "utf-8") def load_palette(path: str | None) -> np.ndarray | None: if not path: return None data = json.loads(Path(path).read_text("utf-8")) cols = data["colors"] if isinstance(data, dict) else data pal = np.array([parse_hex(c) for c in cols], np.uint8) if isinstance(data, dict) and data.get("transparent_index") == 0: pal = pal[1:] return unique_rows(snap555(pal)) # ---------------------------------------------------------------- スキン規格の検査(仕様書 16.5) SKIN_SPECS = { # (幅, 高さ): (名前, コマ数, 最大バイト数, 足元チェック) (240, 48): ("もちシート", 5, 64 * 1024, True), (192, 48): ("動作シート", 3, 32 * 1024, True), (24, 24): ("アイコン", 1, 16 * 1024, False), } def check_skin(path: Path) -> tuple[list[str], list[str]]: """スキン規格を検査する。(違反のリスト, 警告のリスト) を返す。違反が空なら合格。""" errors: list[str] = [] warns: list[str] = [] if path.suffix.lower() != ".png": errors.append("PNG ではありません") return errors, warns size = path.stat().st_size with Image.open(path) as img: if img.format != "PNG": return ["PNG ではありません"], warns spec = SKIN_SPECS.get(img.size) if spec is None: sizes = "、".join(f"{w}×{h}({v[0]})" for (w, h), v in SKIN_SPECS.items()) return [f"サイズが {img.size[0]}×{img.size[1]} です。{sizes} のどれかにしてください"], warns name, frames, max_bytes, foot = spec # サーバーと同じ受け付け条件: 静止画、8ビットのインデックスカラーか RGBA、インターレースなし if getattr(img, "is_animated", False): errors.append("アニメーション PNG は使えません") if img.mode not in ("P", "RGBA"): errors.append(f"色の種類が {img.mode} です(インデックスカラーか RGBA にしてください)") if img.info.get("interlace"): errors.append("インターレースの PNG は使えません") arr = np.asarray(img.convert("RGBA")) if size > max_bytes: errors.append(f"ファイルが大きすぎます({size} バイト、上限 {max_bytes})") a = arr[..., 3] semi = int(((a > 0) & (a < 255)).sum()) if semi: errors.append(f"半透明のドットが {semi} 個あります(完全な透明か不透明だけにしてください)") opaque = arr[..., :3][a == 255] cols = unique_rows(opaque) if len(opaque) else opaque if len(cols) > 15: errors.append(f"色が {len(cols)} 色あります(透明を除いて15色まで)") off = [c for c in cols if not (snap555(c[None])[0] == c).all()] if off: warns.append(f"15ビットカラーに丸められていない色が {len(off)} 色あります(サーバーが丸めて受け付けます)") fw = arr.shape[1] // frames for i in range(frames): fa = a[:, i * fw:(i + 1) * fw] == 255 if not fa.any(): errors.append(f"{i + 1}コマ目が空です") continue if fa[0, 0] or fa[0, -1] or fa[-1, 0] or fa[-1, -1]: errors.append(f"{i + 1}コマ目の四隅が透明ではありません(背景は透明にしてください)") if foot: ys = np.nonzero(fa.any(axis=1))[0] gap = arr.shape[0] - 1 - ys.max() if gap > 2: errors.append(f"{i + 1}コマ目の足元が下端から {gap} ドット浮いています(0〜2 ドットにしてください)") if not errors: warns.insert(0, f"{name}として合格") return errors, warns # ---------------------------------------------------------------- CLI def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description="イラストを携帯ゲーム機風のドット絵に変換する") sub = ap.add_subparsers(dest="cmd", required=True) def common(sp): sp.add_argument("--key", default="auto", help="背景抜き: auto(既定。透過ならそのまま、なければ四隅の色)/ corners / green / none / #rrggbb") sp.add_argument("--tol", type=float, default=60.0, help="背景色とみなす色の距離(既定 60)") sp.add_argument("--preview", type=int, default=4, help="拡大プレビューの倍率(1 で出さない)") sp.add_argument("--palette", help="固定パレット(このツールが出す .json)。全素材の色をそろえたいとき") sp.add_argument("--clean", action="store_true", help="孤立した1ドットを周りの色に置き換える") sp.add_argument("--native", choices=["auto", "off"], default="auto", help="auto(既定): ドット絵を拡大した画像なら元のドットの大きさに戻してから処理する") b = sub.add_parser("batch", help="フォルダ内の PNG をファイル名から判断してまとめて変換") b.add_argument("indir") b.add_argument("outdir") common(b) c = sub.add_parser("convert", help="1枚を変換") c.add_argument("input") c.add_argument("-o", "--output", required=True) c.add_argument("--preset", required=True, choices=sorted(PRESETS)) c.add_argument("--size", help="1コマのサイズ 幅x高さ(例 32x32)") c.add_argument("--frames", type=int) c.add_argument("--colors", type=int) c.add_argument("--anchor", choices=["center", "bottom"]) c.add_argument("--fit", choices=["contain", "cover"]) c.add_argument("--resample", choices=["box", "mode"]) c.add_argument("--outline", action=argparse.BooleanOptionalAction) c.add_argument("--dither", action=argparse.BooleanOptionalAction) common(c) k = sub.add_parser("check", help="スキン規格(もちシート 240×48 / 動作シート 192×48 / アイコン 24×24)を満たすか検査する") k.add_argument("files", nargs="+") a = ap.parse_args(argv) if a.cmd == "check": bad = 0 for f in a.files: errs, warns = check_skin(Path(f)) print(f"[{'不合格' if errs else '合格'}] {f}") for e in errs: print(f" - 違反: {e}") for w in warns: print(f" - {w}") bad += bool(errs) return 1 if bad else 0 fixed = load_palette(a.palette) jobs: list[tuple[Path, Path, Preset]] = [] if a.cmd == "batch": indir, outdir = Path(a.indir), Path(a.outdir) for f in sorted(indir.glob("*.png")): hit = preset_for_name(f.stem) if hit is None: print(f"[スキップ] {f.name}: ファイル名から種類がわかりません") continue jobs.append((f, outdir / f.name, hit[1])) else: p = PRESETS[a.preset] over = {} if a.size: w, h = a.size.lower().split("x") over["size"] = (int(w), int(h)) for name in ("frames", "colors", "anchor", "fit", "resample", "outline", "dither"): v = getattr(a, name) if v is not None: over[name] = v jobs.append((Path(a.input), Path(a.output), replace(p, **over))) failed = 0 for src, dst, p in jobs: try: with Image.open(src) as img: res = convert(img, p, key=a.key, tol=a.tol, fixed_palette=fixed, clean=a.clean, native=a.native) save_outputs(res, p, dst, a.preview) print(f"[OK] {src.name} -> {dst} " + " / ".join(res.notes)) except Exception as e: # 1枚の失敗で全体を止めない failed += 1 print(f"[失敗] {src.name}: {e}") return 1 if failed else 0 if __name__ == "__main__": sys.exit(main())