e054698751
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
573 lines
26 KiB
Python
573 lines
26 KiB
Python
#!/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()
|