diff --git a/tools/gen_plane_sprites.py b/tools/gen_plane_sprites.py new file mode 100644 index 0000000..fa8579f --- /dev/null +++ b/tools/gen_plane_sprites.py @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""竖版射击游戏主角机三帧精灵生成流水线(平飞 / 左滚转 / 右滚转)。 + +流水线(每步独立、可重复执行、独立校验): + base AI 文生图:强制左右对称的平飞基准图 -> assets/raw/{name}-base.png + relief 确定性:高度图浮雕投影生成左右滚转帧 -> assets/raw/{name}-bank-{left,right}.png + bank 备选路线:AI 图生图滚转帧(需 prompt 调试) + align 校正已有图朝向(AI 路线备用:模板匹配 / --rotate 手动) + process 裁边 + 统一缩放 + 居中 -> assets/art/{name}*.png + check 校验 PNG:尺寸 / 四角透明 / 内容占比 / 左右对称度 + +机型参数全部走 profile: + --profile p38 使用内置机型档案 + --profile my.json 使用自定义 JSON 档案(字段见 BUILTIN_PROFILES["p38"]) + +密钥来源(优先级):环境变量 APIYI_API_KEY > {root}/tools/.apikey > 脚本旁 .apikey +""" + +import argparse +import base64 +import json +import math +import os +import sys +import time +import urllib.error +import urllib.request +import uuid +from io import BytesIO +from pathlib import Path + +from PIL import Image + +ALPHA_THRESHOLD = 16 # 低于此 alpha 视为背景噪点 +HTTP_TIMEOUT = 300 # 生图较慢 +RETRIES = 2 + +ROOT = Path.cwd() +RAW_DIR = ROOT / "assets" / "raw" +ART_DIR = ROOT / "assets" / "art" + +# ---------------------------------------------------------------- 机型档案 + +BUILTIN_PROFILES = { + "p38": { + "name": "P-38", + "target": "48x56", + # AI 文生图基准 prompt:关键约束 = 正俯视 + 严格左右对称 + 机头正上 + 透明背景 + "base_prompt": ( + "Top-down view sprite of a WWII olive-drab twin-boom P-38 Lightning style fighter " + "plane in LEVEL flight, seen from directly above. PERFECTLY LEFT-RIGHT SYMMETRIC: " + "the left and right wings, engine nacelles and twin tail booms are exact mirror " + "images of each other. Nose pointing STRAIGHT UP toward the top edge. Flat " + "cel-shaded vertical arcade shoot-em-up game asset, bold clean silhouette, olive " + "drab body, glass canopy, plane centered with margin around it, fully transparent " + "background, no shadow, no text, no watermark." + ), + # AI 图生图滚转 prompt 的部件清单(完整性约束用) + "bank_parts": "both wings, both engine nacelles, both tail booms and the horizontal tailplane", + "roll_deg": 20, + # relief 浮雕投影参数 + "relief": {"angle": 45.0, "height": 48.0, "side_dark": 0.4, "slope_th": 0.3}, + # 图像 API + "api": {"base": "https://api.apiyi.com/v1", "model": "gpt-image-1", "size": "1024x1024"}, + } +} + + +def bank_prompt(prof: dict, side: str) -> str: + """AI 图生图滚转 prompt。关键:直接描述飞机滚转姿态(哪侧翼低/透视缩短/侧面微露), + 不要描述相机移动(模型会把 camera moved 理解成前视 3/4 插画)。 + 左滚转 = 左翼低、左翼缩短、机身右侧面微露。""" + parts = prof["bank_parts"] + deg = prof["roll_deg"] + if side == "left": + low, long_, exposed = "LEFT", "right", "RIGHT" + else: + low, long_, exposed = "RIGHT", "left", "LEFT" + # 螺旋桨条款仅螺旋桨机需要;喷气机/科幻机跳过 + prop_clause = ("The propellers remain thin edge-on lines seen from above, NEVER face-on circles. " + if prof.get("propellers", True) else "") + return ( + f"Draw the aircraft banking/rolling to the {low} at a {deg}-degree angle, viewed from " + "directly above. The aircraft is NOT rotated in the image - the nose points " + "STRAIGHT UP and the fuselage stays perfectly VERTICAL. Only the ROLL angle changes: " + f"the {low} wing is LOWER than the {long_} wing (banking {low.lower()}), so the {low.lower()} wing " + f"appears slightly shorter (foreshortened) and the {long_} wing appears slightly " + f"longer. The fuselage tilts so a thin sliver of the {exposed} side surface of the " + f"fuselage pod becomes visible (darker side shading on the {exposed.lower()} edges). " + "The glass canopy tilts slightly, showing its side. " + prop_clause + + f"The aircraft stays COMPLETE: {parts} fully visible. Flat cel-shaded game sprite, " + "transparent background, no shadow, no text." + ) + + +# ---------------------------------------------------------------- 工具 + +def load_key() -> str: + env = os.environ.get("APIYI_API_KEY", "").strip() + if env: + return env + for key_file in (ROOT / "tools" / ".apikey", Path(__file__).resolve().parent / ".apikey"): + if key_file.exists(): + return key_file.read_text(encoding="utf-8").strip() + sys.exit("[error] 未找到 API 密钥:请设置环境变量 APIYI_API_KEY 或创建 tools/.apikey") + + +def _request_with_retry(req: urllib.request.Request, label: str) -> bytes: + last_err = None + for attempt in range(1, RETRIES + 2): + try: + print(f"[gen] POST {req.full_url} ({label}, attempt {attempt})") + with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp: + return resp.read() + except urllib.error.HTTPError as e: + detail = e.read().decode("utf-8", "replace")[:500] + last_err = f"HTTP {e.code}: {detail}" + print(f"[warn] {last_err}") + if e.code not in (429, 500, 502, 503, 504): + break + except (urllib.error.URLError, TimeoutError) as e: + last_err = str(e) + print(f"[warn] {last_err}") + if attempt <= RETRIES: + wait = 5 * attempt + print(f"[gen] {wait}s 后重试...") + time.sleep(wait) + sys.exit(f"[error] 请求失败: {last_err}") + + +def _extract_png(data: dict) -> bytes: + item = data["data"][0] + if item.get("b64_json"): + return base64.b64decode(item["b64_json"]) + if item.get("url"): + print(f"[gen] 下载图片: {item['url']}") + with urllib.request.urlopen(item["url"], timeout=120) as r: + return r.read() + raise RuntimeError(f"响应中没有图像数据: {json.dumps(data)[:300]}") + + +def generate_image(api: dict, prompt: str) -> bytes: + """文生图:POST /images/generations,返回 PNG 字节。""" + payload = {"model": api["model"], "prompt": prompt, "size": api["size"], "n": 1} + if api["model"].startswith("gpt-image"): + payload["background"] = "transparent" + req = urllib.request.Request( + api["base"].rstrip("/") + "/images/generations", + data=json.dumps(payload).encode("utf-8"), method="POST", + headers={"Authorization": "Bearer " + load_key(), "Content-Type": "application/json"}, + ) + try: + return _extract_png(json.loads(_request_with_retry(req, f"generations {api['model']} {api['size']}").decode("utf-8"))) + except RuntimeError as e: + sys.exit(f"[error] {e}") + + +def _multipart_body(fields: dict, file_field: str, file_path: Path) -> tuple: + boundary = "----pygen" + uuid.uuid4().hex + parts = [] + for name, value in fields.items(): + parts.append( + f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n".encode("utf-8") + ) + parts.append( + f"--{boundary}\r\nContent-Disposition: form-data; name=\"{file_field}\"; " + f"filename=\"{file_path.name}\"\r\nContent-Type: image/png\r\n\r\n".encode("utf-8") + ) + parts.append(file_path.read_bytes()) + parts.append(f"\r\n--{boundary}--\r\n".encode("utf-8")) + return b"".join(parts), boundary + + +def edit_image(api: dict, ref_path: Path, prompt: str) -> bytes: + """图生图:POST /images/edits(multipart),以 ref_path 为参考图。""" + fields = {"model": api["model"], "prompt": prompt, "size": api["size"], "n": "1"} + if api["model"].startswith("gpt-image"): + fields["background"] = "transparent" + body, boundary = _multipart_body(fields, "image", ref_path) + req = urllib.request.Request( + api["base"].rstrip("/") + "/images/edits", + data=body, method="POST", + headers={ + "Authorization": "Bearer " + load_key(), + "Content-Type": f"multipart/form-data; boundary={boundary}", + }, + ) + try: + return _extract_png(json.loads(_request_with_retry(req, f"edits {api['model']} ref={ref_path.name}").decode("utf-8"))) + except RuntimeError as e: + sys.exit(f"[error] {e}") + + +# ---------------------------------------------------------------- 图像处理与校验 + +def clean_and_save(raw: bytes, out_path: Path) -> None: + """AI 原图落地:清理低 alpha 噪点,保持原始尺寸。""" + img = Image.open(BytesIO(raw)).convert("RGBA") + r, g, b, a = img.split() + a = a.point(lambda v: 0 if v < ALPHA_THRESHOLD else v) + img = Image.merge("RGBA", (r, g, b, a)) + out_path.parent.mkdir(parents=True, exist_ok=True) + img.save(out_path) + print(f"[ok] 原始图 -> {out_path.relative_to(ROOT)} ({img.width}x{img.height})") + + +def process_to_sprite(raw_path: Path, target_w: int, target_h: int, out_path: Path, + scale_override: float = None) -> None: + """裁边 -> 缩放 -> 目标画布居中。 + scale_override 给定则用统一缩放系数——机身纵向长度是滚转不变量, + 各帧独立缩放会导致同一架飞机跨帧纵向尺寸不一致(尾翼看似离机身更远)。""" + img = Image.open(raw_path).convert("RGBA") + bbox = img.getchannel("A").getbbox() + if bbox is None: + sys.exit(f"[error] {raw_path.name} 整图全透明") + img = img.crop(bbox) + scale = scale_override if scale_override else min(target_w / img.width, target_h / img.height) + new_size = (max(1, round(img.width * scale)), max(1, round(img.height * scale))) + img = img.resize(new_size, Image.LANCZOS) + canvas = Image.new("RGBA", (target_w, target_h), (0, 0, 0, 0)) + canvas.paste(img, ((target_w - new_size[0]) // 2, (target_h - new_size[1]) // 2), img) + out_path.parent.mkdir(parents=True, exist_ok=True) + canvas.save(out_path) + print(f"[ok] {out_path.relative_to(ROOT)} ({target_w}x{target_h}, 裁边后 {new_size[0]}x{new_size[1]} 居中)") + + +def _flat_pixels(img: Image.Image) -> list: + getter = getattr(img, "get_flattened_data", None) + return list(getter() if getter else img.getdata()) + + +def check_image(path: Path, want_symmetry: bool) -> None: + """校验:尺寸 / 四角透明 / 内容占比 / 左右对称度。""" + img = Image.open(path).convert("RGBA") + a = img.getchannel("A") + corners = [a.getpixel(p) for p in ((0, 0), (img.width - 1, 0), (0, img.height - 1), (img.width - 1, img.height - 1))] + hist = a.histogram() + solid = sum(hist[ALPHA_THRESHOLD + 1:]) + ratio = solid / (img.width * img.height) + bbox = a.getbbox() + print(f"[check] {path.name}: {img.width}x{img.height} 四角A={corners} 内容占比={ratio:.1%} bbox={bbox}") + if not all(c == 0 for c in corners): + print("[warn] 四角并非全透明,可能留有背景") + if want_symmetry: + # 左右对称度:以中心竖轴镜像,比较非透明像素的 alpha+RGB 平均差异 + left = img.crop((0, 0, img.width // 2, img.height)) + right = img.crop((img.width - img.width // 2, 0, img.width, img.height)).transpose(Image.FLIP_LEFT_RIGHT) + lp, rp = _flat_pixels(left), _flat_pixels(right) + diff, count = 0.0, 0 + for (lr, lg, lb, la), (rr, rg, rb, ra) in zip(lp, rp): + if la > ALPHA_THRESHOLD or ra > ALPHA_THRESHOLD: + diff += abs(la - ra) + (abs(lr - rr) + abs(lg - rg) + abs(lb - rb)) / 3.0 + count += 1 + if count == 0: + print("[check] 对称度: 无内容像素") + else: + score = max(0.0, 100.0 - (diff / count) / 255.0 * 100.0) + print(f"[check] 左右对称度: {score:.1f}%(比较像素 {count} 个,越高越对称)") + + +# ---------------------------------------------------------------- 对齐校正 + +def _alpha_mask(img: Image.Image, size: int = 128): + """缩小后的二值 alpha 掩码(list of lists,True=有内容)。""" + small = img.getchannel("A").resize((size, size), Image.BILINEAR) + px = small.load() + return [[px[x, y] > ALPHA_THRESHOLD for x in range(size)] for y in range(size)] + + +def mask_iou(a, b) -> float: + inter = sum(1 for y in range(len(a)) for x in range(len(a)) if a[y][x] and b[y][x]) + union = sum(1 for y in range(len(a)) for x in range(len(a)) if a[y][x] or b[y][x]) + return inter / union if union else 0.0 + + +def align_vertical(img_path: Path, base_path: Path, manual_deg: float = None) -> None: + """把侧身图原地校正为机头正上。 + 手动模式(manual_deg 给定,正值=顺时针度数):直接旋转,跳过自动检测。 + 自动模式:以基准图为模板,在 ±90° 范围内搜索 IoU 最大的旋转角(粗搜 5° + 细搜 0.5°), + 直接对齐"同一架飞机"的形状——不要用二阶矩主轴法,滚转机翼会带偏主轴。""" + img = Image.open(img_path).convert("RGBA") + if manual_deg is not None: + img = img.rotate(-manual_deg, resample=Image.BICUBIC, expand=True) + print(f"[align] 手动顺时针旋转 {manual_deg:.1f}°(expand 防裁剪)") + img.save(img_path) + return + if base_path.exists(): + base_mask = _alpha_mask(Image.open(base_path).convert("RGBA")) + # 粗搜:±90°,5° 步进 + best_angle, best_iou = 0.0, -1.0 + for deg in range(-90, 91, 5): + rotated = img.rotate(-deg, resample=Image.BILINEAR, expand=True) + iou = mask_iou(_alpha_mask(rotated), base_mask) + if iou > best_iou: + best_iou, best_angle = iou, float(deg) + # 细搜:最优 ±5° 内 0.5° 步进 + for deg10 in range(-10, 11): + deg = best_angle + deg10 * 0.5 + rotated = img.rotate(-deg, resample=Image.BILINEAR, expand=True) + iou = mask_iou(_alpha_mask(rotated), base_mask) + if iou > best_iou: + best_iou, best_angle = iou, deg + img = img.rotate(-best_angle, resample=Image.BICUBIC, expand=True) + print(f"[align] 模板匹配最优角 {best_angle:+.1f}°(IoU {best_iou:.2f}),已旋转") + else: + print("[warn] 无基准图,跳过自动对齐") + img.save(img_path) + + +# ---------------------------------------------------------------- 滚转侧身(relief 浮雕投影) + +def _chamfer_distance(mask) -> list: + """3-4 chamfer 二遍距离变换,返回到最近透明像素的距离场(像素单位)。""" + h, w = len(mask), len(mask[0]) + INF = float("inf") + d = [[0.0 if not mask[y][x] else INF for x in range(w)] for y in range(h)] + for y in range(h): + for x in range(w): + if d[y][x] == 0.0: + continue + best = d[y][x] + if y > 0: + best = min(best, d[y - 1][x] + 3) + if x > 0: + best = min(best, d[y][x - 1] + 3) + if y > 0 and x > 0: + best = min(best, d[y - 1][x - 1] + 4) + if y > 0 and x < w - 1: + best = min(best, d[y - 1][x + 1] + 4) + d[y][x] = best + for y in range(h - 1, -1, -1): + for x in range(w - 1, -1, -1): + if d[y][x] == 0.0: + continue + best = d[y][x] + if y < h - 1: + best = min(best, d[y + 1][x] + 3) + if x < w - 1: + best = min(best, d[y][x + 1] + 3) + if y < h - 1 and x < w - 1: + best = min(best, d[y + 1][x + 1] + 4) + if y < h - 1 and x > 0: + best = min(best, d[y + 1][x - 1] + 4) + d[y][x] = best + return [[v / 3.0 for v in row] for row in d] + + +def relief_project(base_img: Image.Image, angle_deg: float, max_h: float, + side_dark: float, slope_th: float) -> Image.Image: + """相机在飞机 Up-Right 平面内从正上方移到右上方 angle_deg(等距、看向机心)的正交投影: + x' = x·cosθ − h·sinθ(有高度的部件向左移),y 不变。 + 高度图 = 剪影距离变换(管状机身/尾撑高、薄机翼低,几何天然正确)。 + 朝向相机的陡坡被行内拉伸、露出为新侧面并按"脊亮缘暗"加暗;浅坡(机翼)保原色。 + 生成的是左滚转帧,右滚转帧由调用方水平镜像得到。""" + w, hgt = base_img.size + hw, hh2 = w // 2, hgt // 2 + # 半分辨率算高度图(chamfer+blur 全分辨率太慢) + small = base_img.getchannel("A").resize((hw, hh2), Image.BILINEAR) + spx = small.load() + mask = [[spx[x, y] > ALPHA_THRESHOLD for x in range(hw)] for y in range(hh2)] + dist = _chamfer_distance(mask) + cap = max_h / 2.0 + hmap = [[min(dist[y][x], cap) for x in range(hw)] for y in range(hh2)] + # 3x3 盒式平滑(压低梯度,保证行映射单调) + sm = [[0.0] * hw for _ in range(hh2)] + for y in range(hh2): + for x in range(hw): + acc = cnt = 0.0 + for dy in (-1, 0, 1): + for dx in (-1, 0, 1): + yy, xx = y + dy, x + dx + if 0 <= yy < hh2 and 0 <= xx < hw: + acc += hmap[yy][xx] + cnt += 1 + sm[y][x] = acc / cnt + # 双线性升回全分辨率,消除 2x2 方块梯度纹 + hsmall = Image.new("F", (hw, hh2)) + hsmall.putdata([v for row in sm for v in row]) + hbig = hsmall.resize((w, hgt), Image.BILINEAR) + hpx = hbig.load() + + c = math.cos(math.radians(angle_deg)) + s = math.sin(math.radians(angle_deg)) + margin = 8 + out_w = int(math.ceil(w * c + max_h * s)) + 2 * margin + out = Image.new("RGBA", (out_w, hgt), (0, 0, 0, 0)) + px = base_img.load() + opx = out.load() + for y in range(hgt): + last_x = None + for x in range(w): + r, g, b, a = px[x, y] + if a <= ALPHA_THRESHOLD: + last_x = None + continue + h_full = hpx[x, y] * 2.0 + # 全分辨率梯度:右邻减当前(h 随 x 递减 = 朝右侧面) + gx = (hpx[min(x + 1, w - 1), y] - hpx[x, y]) * 2.0 + # 只有陡坡(真实侧面)才加暗:脊线处亮、靠剪影边缘处暗;浅坡(机翼)保原色 + if -gx > slope_th: + ridge = min(1.0, h_full / max_h) # 1=脊线, 0=边缘 + f = 1.0 - side_dark * (1.0 - ridge) # 脊亮缘暗 + else: + f = 1.0 + col = (int(r * f), int(g * f), int(b * f), a) + xr = int(round(x * c - h_full * s)) + margin + if last_x is None: + opx[xr, y] = col + last_x = xr + elif xr > last_x: + # 陡坡被拉伸:缝隙用当前(加暗的)侧面颜色填充 + for xx in range(last_x + 1, xr + 1): + opx[xx, y] = col + last_x = xr + else: + # 背向相机的坡被压缩:覆盖式落笔 + opx[last_x, y] = col + return out + + +# ---------------------------------------------------------------- 子命令 + +def cmd_base(prof, args) -> None: + if args.dry: + print(prof["base_prompt"]) + return + raw = generate_image(prof["api"], prof["base_prompt"]) + clean_and_save(raw, RAW_DIR / f"{prof['name']}-base.png") + print("[done] base 完成。请 check --symmetry 校验对称性后再进入下一步。") + + +def cmd_bank(prof, args) -> None: + name = prof["name"] + ref = Path(args.ref) if args.ref else RAW_DIR / f"{name}-base.png" + if args.dry: + print(f"[ref] {ref}\n{bank_prompt(prof, args.side)}") + return + if not ref.exists(): + sys.exit(f"[error] 基准图不存在: {ref}(先跑 base)") + raw = edit_image(prof["api"], ref, bank_prompt(prof, args.side)) + out = RAW_DIR / f"{name}-bank-{args.side}.png" + clean_and_save(raw, out) + if not args.no_align: + align_vertical(out, ref) + print("[done] bank 完成。目检合格后跑 process。") + + +def cmd_relief(prof, args) -> None: + name = prof["name"] + ref = Path(args.ref) if args.ref else RAW_DIR / f"{name}-base.png" + if not ref.exists(): + sys.exit(f"[error] 基准图不存在: {ref}(先跑 base)") + r = prof["relief"] + angle = args.angle if args.angle is not None else r["angle"] + height = args.height if args.height is not None else r["height"] + side_dark = args.side_dark if args.side_dark is not None else r["side_dark"] + base_img = Image.open(ref).convert("RGBA") + RAW_DIR.mkdir(parents=True, exist_ok=True) + left = relief_project(base_img, angle, height, side_dark, r["slope_th"]) + left_path = RAW_DIR / f"{name}-bank-left.png" + left.save(left_path) + print(f"[ok] {left_path.relative_to(ROOT)} ({left.width}x{left.height})") + right = left.transpose(Image.FLIP_LEFT_RIGHT) + right_path = RAW_DIR / f"{name}-bank-right.png" + right.save(right_path) + print(f"[ok] {right_path.relative_to(ROOT)}(左帧水平镜像 = 相机在左上方的对称视角)") + print(f"[done] 浮雕投影完成(angle={angle}°, height={height}px, side_dark={side_dark})。目检后跑 process。") + + +def cmd_process(prof, args) -> None: + name = prof["name"] + w, h = (int(x) for x in (args.target or prof["target"]).split("x")) + jobs = [(f"{name}-base.png", f"{name}.png"), (f"{name}-bank-left.png", f"{name}-bank-left.png"), + (f"{name}-bank-right.png", f"{name}-bank-right.png")] + # 统一缩放系数:以基准图外接框为准——机身纵向长度是滚转不变量,必须跨帧一致 + base_raw = RAW_DIR / f"{name}-base.png" + scale_override = None + if base_raw.exists(): + bbox = Image.open(base_raw).getchannel("A").getbbox() + if bbox: + bw, bh = bbox[2] - bbox[0], bbox[3] - bbox[1] + scale_override = min(w / bw, h / bh) + print(f"[process] 统一缩放系数 {scale_override:.4f}(基准图外接框 {bw}x{bh})") + for src_name, dst_name in jobs: + src = RAW_DIR / src_name + if not src.exists(): + print(f"[skip] {src_name} 不在 raw 目录,跳过") + continue + process_to_sprite(src, w, h, ART_DIR / dst_name, scale_override=scale_override) + if args.mirror_right: + src = ART_DIR / f"{name}-bank-left.png" + if src.exists(): + img = Image.open(src).transpose(Image.FLIP_LEFT_RIGHT) + img.save(ART_DIR / f"{name}-bank-right.png") + print(f"[ok] {name}-bank-right.png(由 left 水平镜像)") + else: + print("[skip] 无 bank-left,无法镜像 right") + print("[done] process 完成。godot --import 后即可生效。") + + +def cmd_check(prof, args) -> None: + check_image(Path(args.file), args.symmetry) + + +def cmd_align(prof, args) -> None: + img_path = Path(args.file) + if not img_path.exists(): + sys.exit(f"[error] 文件不存在: {img_path}") + ref = Path(args.ref) if args.ref else RAW_DIR / f"{prof['name']}-base.png" + align_vertical(img_path, ref, manual_deg=args.rotate) + print("[done] 对齐完成,请目检确认机头朝上。") + + +def main() -> None: + global ROOT, RAW_DIR, ART_DIR + parser = argparse.ArgumentParser(description="竖版主角机三帧精灵生成流水线") + parser.add_argument("--profile", default="p38", help="机型档案:内置名(p38)或 JSON 文件路径") + parser.add_argument("--root", default=str(Path.cwd()), help="项目根目录(默认当前目录)") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_base = sub.add_parser("base", help="AI 文生图:对称平飞基准图") + p_base.add_argument("--dry", action="store_true", help="只打印提示词不调用") + p_base.set_defaults(func=cmd_base) + + p_bank = sub.add_parser("bank", help="AI 图生图:滚转侧身帧(备选路线)") + p_bank.add_argument("--side", choices=["left", "right"], default="left") + p_bank.add_argument("--ref", help="参考基准图路径") + p_bank.add_argument("--no-align", action="store_true", help="跳过生成后的自动对齐校正") + p_bank.add_argument("--dry", action="store_true", help="只打印提示词不调用") + p_bank.set_defaults(func=cmd_bank) + + p_relief = sub.add_parser("relief", help="确定性滚转侧身:高度图浮雕投影(零 API,推荐)") + p_relief.add_argument("--angle", type=float, default=None, help="相机偏角(度),默认取 profile") + p_relief.add_argument("--height", type=float, default=None, help="机身最大高度(原始图像素),默认取 profile") + p_relief.add_argument("--side-dark", type=float, default=None, help="露出侧面的最大暗度 0-1,默认取 profile") + p_relief.add_argument("--ref", help="基准图路径") + p_relief.set_defaults(func=cmd_relief) + + p_proc = sub.add_parser("process", help="裁边 + 统一缩放 + 居中落地 assets/art/") + p_proc.add_argument("--target", help="目标尺寸如 48x56,默认取 profile") + p_proc.add_argument("--mirror-right", action="store_true", help="由 bank-left 镜像生成 bank-right") + p_proc.set_defaults(func=cmd_process) + + p_align = sub.add_parser("align", help="校正已有侧身图朝向(AI 路线备用)") + p_align.add_argument("file", help="要校正的侧身图(原地修改)") + p_align.add_argument("--ref", help="基准图路径") + p_align.add_argument("--rotate", type=float, default=None, + help="手动模式:顺时针旋转指定度数,跳过自动检测") + p_align.set_defaults(func=cmd_align) + + p_chk = sub.add_parser("check", help="校验 PNG:尺寸/透明/对称度") + p_chk.add_argument("file") + p_chk.add_argument("--symmetry", action="store_true", help="额外检查左右对称度") + p_chk.set_defaults(func=cmd_check) + + args = parser.parse_args() + ROOT = Path(args.root).resolve() + RAW_DIR = ROOT / "assets" / "raw" + ART_DIR = ROOT / "assets" / "art" + + if args.profile.endswith(".json"): + prof = json.loads(Path(args.profile).read_text(encoding="utf-8")) + elif args.profile in BUILTIN_PROFILES: + prof = json.loads(json.dumps(BUILTIN_PROFILES[args.profile])) + else: + sys.exit(f"[error] 未知 profile: {args.profile}(内置: {', '.join(BUILTIN_PROFILES)})") + args.func(prof, args) + + +if __name__ == "__main__": + main() diff --git a/tools/profiles/plane_funnel.json b/tools/profiles/plane_funnel.json new file mode 100644 index 0000000..8ea65bd --- /dev/null +++ b/tools/profiles/plane_funnel.json @@ -0,0 +1,10 @@ +{ + "name": "plane_funnel", + "target": "48x56", + "base_prompt": "Top-down view sprite of a futuristic blue-gray delta-wing laser fighter jet in LEVEL flight, seen from directly above. PERFECTLY LEFT-RIGHT SYMMETRIC: the left and right wings and tail fins are exact mirror images of each other. Sleek needle nose pointing STRAIGHT UP toward the top edge, single slim central fuselage, one large triangular delta main wing, two small vertical tail fins at the rear, glowing pale-blue glass canopy, thin cyan energy accent strip across the wing, small yellow wingtip lights, cyan engine glow at the rear. Flat cel-shaded vertical arcade shoot-em-up game asset, bold clean silhouette, blue-gray body (#3e6288), plane centered with margin around it, fully transparent background, no shadow, no text, no watermark.", + "bank_parts": "both delta wings, the central fuselage, both vertical tail fins and the engine exhaust", + "propellers": false, + "roll_deg": 20, + "relief": {"angle": 45.0, "height": 48.0, "side_dark": 0.4, "slope_th": 0.3}, + "api": {"base": "https://api.apiyi.com/v1", "model": "gpt-image-1", "size": "1024x1024"} +}