Compare commits
2 Commits
ca023844c9
...
a20eec8d57
| Author | SHA1 | Date | |
|---|---|---|---|
| a20eec8d57 | |||
| bb34641bfe |
@@ -42,14 +42,14 @@ tools/world-sketcher/
|
||||
|
||||
## 画笔颜色 → 材质映射
|
||||
|
||||
| 画笔 | 颜色 | 地形 | UE 材质 |
|
||||
|------|------|------|---------|
|
||||
| 道路 Road | `#909090` 灰 | 道路 | MI_DevGray |
|
||||
| 建筑 Building | `#E07020` 橙 | 建筑物 | MI_DevOrange |
|
||||
| 水域 Water | `#40A0D0` 天蓝 | 河流/水塘 | MI_DevSkyBlue |
|
||||
| 植被 Greenery | `#40B040` 绿 | 树木/草地 | MI_DevGreen |
|
||||
| 不可通行 Blocked | `#A07040` 棕 | 山体/围墙 | MI_DevBrown |
|
||||
| 区域 Zone | `#D04040` 红 | 区域划分 | MI_DevRed |
|
||||
| 画笔 | 颜色 | 地形 | UE 材质 | 默认高度 |
|
||||
|------|------|------|---------|---------|
|
||||
| 道路 Road | `#909090` 灰 | 道路 | MI_DevGray | — |
|
||||
| 建筑 Building | `#E07020` 橙 | 建筑物 | MI_DevOrange | 3m |
|
||||
| 水域 Water | `#40A0D0` 天蓝 | 河流/水塘 | MI_DevSkyBlue | — |
|
||||
| 植被 Greenery | `#40B040` 绿 | 树木/草地 | MI_DevGreen | 2m |
|
||||
| 不可通行 Blocked | `#A07040` 棕 | 山体/围墙 | MI_DevBrown | 3m |
|
||||
| 区域 Zone | `#D04040` 红 | 区域划分 | MI_DevRed | 3m |
|
||||
|
||||
---
|
||||
|
||||
@@ -71,12 +71,15 @@ tools/world-sketcher/
|
||||
5. 进入 L2 后用建筑、道路等画笔细化区域内部
|
||||
6. 完成后点"保存全部",将文件保存到 `output/` 目录
|
||||
|
||||
### 步骤 3:AI 分析草图
|
||||
### 步骤 3:AI 读取布局数据
|
||||
|
||||
1. 读取 PNG 草图(使用 vision 分析)
|
||||
2. 读取 JSON 伴随数据(scale、elements、层级关系)
|
||||
3. 交叉参考场景设计案 `场景-v<XX>-<名称>.md` 的氛围描述
|
||||
4. 产出布局解读文档
|
||||
**数据源以 JSON 为准,不依赖 PNG 像素分析。**
|
||||
|
||||
1. 读取 JSON 布局数据(`*-scene.json`):elements(含 `height_m`、`connections`)、regions(含 `bounds_world_m`)、层级关系
|
||||
2. 交叉参考场景设计案 `场景-v<XX>-<名称>.md` 的氛围描述,将 JSON 区域与场景设计案的区域描述建立映射
|
||||
3. 如需视觉确认,可参考预览 PNG(非必须,仅用于核对区域形状)
|
||||
|
||||
> **说明**:PNG 文件仍保存在 ZIP 中用于保存/加载画布,但 AI 分析流程以结构化 JSON 为准。
|
||||
|
||||
### 步骤 4:产出布局解读文档
|
||||
|
||||
@@ -99,8 +102,8 @@ tools/world-sketcher/
|
||||
## L1 场景总览
|
||||
|
||||
### 识别区域
|
||||
| 区域编号 | 名称 | 推测坐标范围 (cm) | 推测尺寸 | 地形类型 | 标识色建议 |
|
||||
|---------|------|-------------------|---------|---------|-----------|
|
||||
| 区域编号 | 名称 | 推测坐标范围 (cm) | 推测尺寸 | 地形类型 | 高度 (m) | 连通到 | 标识色建议 |
|
||||
|---------|------|-------------------|---------|---------|---------|--------|-----------|
|
||||
|
||||
### 识别道路/河流
|
||||
| 编号 | 名称 | 类型 | 走向 | 长度 |
|
||||
@@ -111,7 +114,7 @@ tools/world-sketcher/
|
||||
## L2 区域详图(如有)
|
||||
|
||||
### <区域名>
|
||||
| 编号 | 名称 | 类型 | 推测坐标 | 尺寸 |
|
||||
| 编号 | 名称 | 类型 | 推测坐标 | 尺寸 | 高度 (m) | 连通到 |
|
||||
|
||||
## 待确认问题
|
||||
1. ...
|
||||
@@ -120,6 +123,12 @@ tools/world-sketcher/
|
||||
- ...
|
||||
```
|
||||
|
||||
**数据来源说明**:
|
||||
- 区域坐标、尺寸:来自 JSON 中 `regions[].bounds_world_m`,乘以 100 转为 cm
|
||||
- 高度:来自 JSON 中 `elements[].height_m`,直接采用(无需 AI 推断)
|
||||
- 连通关系:来自 JSON 中 `elements[].connections`,直接采用(无需 AI 推断)
|
||||
- 地形类型和标识色:来自 JSON 中 `elements[].type` → brush_legend 映射
|
||||
|
||||
### 步骤 5:用户确认
|
||||
|
||||
将解读文档呈现给用户,等待用户确认或修正。确认后,该文档作为关卡执行案的前置输入。
|
||||
@@ -143,3 +152,5 @@ tools/world-sketcher/
|
||||
- 坐标系与 `zone_config.py` 一致:原点西南角,X+ 东,Y+ 北,单位 cm
|
||||
- AI 解读时如有不确定之处,**必须**列入"待确认问题",不要自行假设
|
||||
- L3 建筑内部本期不实现,仅预留 `has_interior` 标记
|
||||
- **高度 (`height_m`)**:由用户在双击命名时输入,AI 直接采用,不做推断
|
||||
- **连通关系 (`connections`)**:由用户在双击命名时勾选,双向同步,AI 直接采用
|
||||
|
||||
@@ -111,6 +111,11 @@ scripts/.gitea-tokens.json
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# ==============================
|
||||
# AI 生图工具 — API 密钥
|
||||
# ==============================
|
||||
ai-art-research/.env
|
||||
|
||||
# ==============================
|
||||
# 其他
|
||||
# ==============================
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# API易 平台配置
|
||||
# 复制此文件为 .env 并填入你的 API Key
|
||||
# cp .env.example .env
|
||||
|
||||
# API 令牌(必填)— 在 https://api.apiyi.com/token 创建
|
||||
APIYI_API_KEY=sk-your-api-key-here
|
||||
|
||||
# API 地址(通常不需要修改)
|
||||
APIYI_BASE_URL=https://api.apiyi.com/v1
|
||||
|
||||
# 默认生图模型
|
||||
# 可选: gpt-image-2, gemini-3-1-flash-image-preview, dall-e-3, flux-pro-v1.1
|
||||
APIYI_MODEL=gpt-image-2
|
||||
@@ -0,0 +1,454 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ChildHood AI Art Generation Tool
|
||||
通过 API易 平台调用 AI 生图模型
|
||||
|
||||
Usage:
|
||||
python image_gen.py --test # 测试 API 连通性
|
||||
python image_gen.py --generate "千禧年中国城郊的小卖部门口" # 文生图(自动加风格前缀)
|
||||
python image_gen.py --generate "a red apple" --no-style # 纯文生图(不加风格前缀)
|
||||
python image_gen.py --calibrate # 风格校准(5 个测试物体)
|
||||
python image_gen.py --fuse --style style.png --content photo.jpg # 双参考融合
|
||||
python image_gen.py --fuse --style style.png --content photo.jpg --prompt "自定义 prompt"
|
||||
|
||||
Requirements:
|
||||
pip install openai python-dotenv
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
# Windows 控制台 UTF-8 支持
|
||||
if sys.platform == "win32":
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 路径配置
|
||||
# ---------------------------------------------------------------------------
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent # ai-art-research/tools/
|
||||
RESEARCH_DIR = SCRIPT_DIR.parent # ai-art-research/
|
||||
DOTENV_PATH = RESEARCH_DIR / ".env"
|
||||
OUTPUT_BASE = RESEARCH_DIR / "生成结果"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 加载 .env
|
||||
# ---------------------------------------------------------------------------
|
||||
if DOTENV_PATH.exists():
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(DOTENV_PATH)
|
||||
|
||||
API_KEY = os.getenv("APIYI_API_KEY", "")
|
||||
BASE_URL = os.getenv("APIYI_BASE_URL", "https://api.apiyi.com/v1")
|
||||
DEFAULT_MODEL = os.getenv("APIYI_MODEL", "gpt-image-2")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 风格 Prompt 种子(来自工作流方案 v0.1)
|
||||
# ---------------------------------------------------------------------------
|
||||
STYLE_SEED_EN = (
|
||||
"3D low-poly game art, flat cel-shaded with solid color blocks, "
|
||||
"bright warm-cool balanced saturated palette, soft diffused lighting "
|
||||
"with subtle shadows, simplified geometric forms with clean edges, "
|
||||
"low-angle third-person perspective, nostalgic warm summer atmosphere"
|
||||
)
|
||||
|
||||
# 风格校准测试物体(中文名, 英文 prompt)
|
||||
CALIBRATION_OBJECTS = [
|
||||
("乌鸦", "a crow standing on the ground"),
|
||||
("茶杯", "a teacup on a wooden table"),
|
||||
("老人", "an old man standing in a village street"),
|
||||
("山湖", "a mountain lake with trees"),
|
||||
("苹果", "a red apple on the ground"),
|
||||
]
|
||||
|
||||
# 双参考融合默认 prompt
|
||||
DEFAULT_FUSION_PROMPT = """\
|
||||
图1 是我的风格参考(一张游戏截图)。图2 是我的内容参考(一张现实照片)。
|
||||
请生成一张新图片,用图1的美术风格来呈现图2中的场景内容。
|
||||
|
||||
风格要求:
|
||||
- 高度简化的低多边形几何形体,干净利落的边缘
|
||||
- 平面色块着色,不用渐变或写实纹理
|
||||
- 鲜艳明亮的暖冷平衡色调(暖黄、天蓝、翠绿为主)
|
||||
- 柔和散射光,阴影淡而柔和
|
||||
- 微低角度第三人称视角
|
||||
- 温暖怀旧的夏日午后氛围
|
||||
|
||||
内容要求:
|
||||
- 保留图2中的建筑类型、布局、空间关系
|
||||
- 但把所有建筑元素替换为千禧年中国城郊的风格
|
||||
- 色调保持温暖怀旧"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 工具函数
|
||||
# ---------------------------------------------------------------------------
|
||||
def get_client() -> "OpenAI":
|
||||
"""创建 OpenAI 客户端"""
|
||||
if not API_KEY:
|
||||
print("❌ 未找到 API Key")
|
||||
print(f" 请在 {DOTENV_PATH} 中设置: APIYI_API_KEY=sk-xxx")
|
||||
sys.exit(1)
|
||||
from openai import OpenAI
|
||||
return OpenAI(api_key=API_KEY, base_url=BASE_URL)
|
||||
|
||||
|
||||
def _extract_image_bytes(img_obj) -> bytes:
|
||||
"""从 API 响应中提取图片字节数据"""
|
||||
# 优先 base64
|
||||
b64 = getattr(img_obj, "b64_json", None)
|
||||
if b64:
|
||||
return base64.b64decode(b64)
|
||||
# 其次 URL
|
||||
url = getattr(img_obj, "url", None)
|
||||
if url:
|
||||
print(" ⬇️ 正在下载图片...")
|
||||
return urllib.request.urlopen(url).read()
|
||||
raise ValueError("响应中未找到图片数据(无 b64_json 也无 url)")
|
||||
|
||||
|
||||
def _sanitize_filename(s: str, max_len: int = 40) -> str:
|
||||
"""清理文件名中的非法字符"""
|
||||
result = "".join(c if c.isalnum() or c in "._-" else "_" for c in s)
|
||||
return result[:max_len].rstrip("_")
|
||||
|
||||
|
||||
def save_result(image_bytes: bytes, metadata: dict, subdir: str = "") -> Path:
|
||||
"""保存图片和元数据,返回图片路径"""
|
||||
ts = datetime.now().strftime("%y%m%d-%H%M%S")
|
||||
model_slug = metadata.get("model", "unknown").replace("/", "-").replace(".", "-")
|
||||
|
||||
out_dir = OUTPUT_BASE / subdir if subdir else OUTPUT_BASE
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
prompt_tag = _sanitize_filename(metadata.get("prompt", "gen"))
|
||||
filename = f"{ts}_{model_slug}_{prompt_tag}.png"
|
||||
|
||||
img_path = out_dir / filename
|
||||
img_path.write_bytes(image_bytes)
|
||||
|
||||
meta_path = img_path.with_suffix(".meta.json")
|
||||
meta_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
print(f"✅ 已保存: {img_path.name}")
|
||||
return img_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 功能:测试连通性
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_test(client):
|
||||
"""测试 API 易连通性,生成一张简单测试图"""
|
||||
print("🔗 测试 API易 连通性...")
|
||||
print(f" Base URL: {BASE_URL}")
|
||||
print(f" Model: {DEFAULT_MODEL}")
|
||||
print(f" API Key: {API_KEY[:8]}...{API_KEY[-4:]}")
|
||||
print()
|
||||
|
||||
prompt = f"{STYLE_SEED_EN}, a simple red cube on a white background, test image"
|
||||
print(f" Prompt: {prompt[:80]}...")
|
||||
|
||||
response = client.images.generate(
|
||||
model=DEFAULT_MODEL,
|
||||
prompt=prompt,
|
||||
size="1024x1024",
|
||||
quality="low",
|
||||
n=1,
|
||||
)
|
||||
|
||||
image_bytes = _extract_image_bytes(response.data[0])
|
||||
save_result(image_bytes, {
|
||||
"model": DEFAULT_MODEL,
|
||||
"prompt": prompt,
|
||||
"mode": "connection_test",
|
||||
"size": "1024x1024",
|
||||
"quality": "low",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}, subdir="测试")
|
||||
|
||||
print()
|
||||
print("✅ API 连通性测试通过!")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 功能:文生图
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_generate(client, prompt: str, use_style: bool, size: str, quality: str,
|
||||
model: str, subdir: str):
|
||||
"""文生图"""
|
||||
model = model or DEFAULT_MODEL
|
||||
full_prompt = f"{STYLE_SEED_EN}, {prompt}" if use_style else prompt
|
||||
|
||||
print(f"🎨 文生图")
|
||||
print(f" 模型: {model}")
|
||||
print(f" 尺寸: {size} 质量: {quality}")
|
||||
print(f" 风格前缀: {'是' if use_style else '否'}")
|
||||
print(f" Prompt: {full_prompt[:100]}{'...' if len(full_prompt) > 100 else ''}")
|
||||
print()
|
||||
|
||||
response = client.images.generate(
|
||||
model=model,
|
||||
prompt=full_prompt,
|
||||
size=size,
|
||||
quality=quality,
|
||||
n=1,
|
||||
)
|
||||
|
||||
image_bytes = _extract_image_bytes(response.data[0])
|
||||
save_result(image_bytes, {
|
||||
"model": model,
|
||||
"prompt": full_prompt,
|
||||
"mode": "text_to_image",
|
||||
"style_prefix_used": use_style,
|
||||
"size": size,
|
||||
"quality": quality,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}, subdir=subdir)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 功能:风格校准
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_calibrate(client, model: str):
|
||||
"""风格校准 — 5 个测试物体"""
|
||||
model = model or DEFAULT_MODEL
|
||||
subdir = "风格校准"
|
||||
|
||||
print("🎯 开始风格校准测试")
|
||||
print(f" 模型: {model}")
|
||||
print(f" 风格种子: {STYLE_SEED_EN[:60]}...")
|
||||
print(f" 测试物体: {', '.join(zh for zh, _ in CALIBRATION_OBJECTS)}")
|
||||
print()
|
||||
|
||||
success = 0
|
||||
for zh_name, en_prompt in CALIBRATION_OBJECTS:
|
||||
full_prompt = f"{STYLE_SEED_EN}, {en_prompt}"
|
||||
print(f" 🎨 生成: {zh_name} ...")
|
||||
|
||||
try:
|
||||
response = client.images.generate(
|
||||
model=model,
|
||||
prompt=full_prompt,
|
||||
size="1024x1024",
|
||||
quality="medium",
|
||||
n=1,
|
||||
response_format="b64_json",
|
||||
)
|
||||
image_bytes = _extract_image_bytes(response.data[0])
|
||||
save_result(image_bytes, {
|
||||
"model": model,
|
||||
"prompt": full_prompt,
|
||||
"mode": "style_calibration",
|
||||
"object_zh": zh_name,
|
||||
"object_en": en_prompt,
|
||||
"style_seed": STYLE_SEED_EN,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}, subdir=subdir)
|
||||
success += 1
|
||||
except Exception as e:
|
||||
print(f" ❌ 失败: {e}")
|
||||
print()
|
||||
|
||||
print(f"🎉 风格校准完成!成功 {success}/{len(CALIBRATION_OBJECTS)} 张")
|
||||
print(f" 输出目录: {OUTPUT_BASE / subdir}")
|
||||
print()
|
||||
print("📋 验证方法:打开上述目录,检查 5 张图是否视觉风格统一。")
|
||||
print(" 如果风格不一致,需要调整 STYLE_SEED_EN 后重新运行。")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 功能:双参考融合
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_fuse(client, style_path: str, content_path: str, prompt: str,
|
||||
model: str, subdir: str):
|
||||
"""双参考图融合"""
|
||||
model = model or DEFAULT_MODEL
|
||||
fusion_prompt = prompt or DEFAULT_FUSION_PROMPT
|
||||
subdir = subdir or "双参考融合"
|
||||
|
||||
style_file = Path(style_path).resolve()
|
||||
content_file = Path(content_path).resolve()
|
||||
|
||||
if not style_file.exists():
|
||||
print(f"❌ 风格参考图不存在: {style_file}")
|
||||
sys.exit(1)
|
||||
if not content_file.exists():
|
||||
print(f"❌ 内容参考图不存在: {content_file}")
|
||||
sys.exit(1)
|
||||
|
||||
print("🔗 双参考融合")
|
||||
print(f" 模型: {model}")
|
||||
print(f" 风格参考: {style_file.name}")
|
||||
print(f" 内容参考: {content_file.name}")
|
||||
print(f" Prompt: {fusion_prompt[:80]}...")
|
||||
print()
|
||||
|
||||
# 编码图片为 base64
|
||||
style_b64 = base64.b64encode(style_file.read_bytes()).decode()
|
||||
content_b64 = base64.b64encode(content_file.read_bytes()).decode()
|
||||
|
||||
# 确定图片 MIME 类型
|
||||
ext_map = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".webp": "image/webp", ".gif": "image/gif"}
|
||||
style_mime = ext_map.get(style_file.suffix.lower(), "image/png")
|
||||
content_mime = ext_map.get(content_file.suffix.lower(), "image/png")
|
||||
|
||||
# 尝试方法 1: images.edit(带多图的编辑模式)
|
||||
image_bytes = None
|
||||
errors = []
|
||||
|
||||
# 方法 1: images.edit
|
||||
print(" 尝试方式 1: images.edit ...")
|
||||
try:
|
||||
response = client.images.edit(
|
||||
model=model,
|
||||
image=[
|
||||
{"type": "image_file", "image_file": {
|
||||
"file_data": f"data:{style_mime};base64,{style_b64}",
|
||||
"filename": style_file.name,
|
||||
}},
|
||||
{"type": "image_file", "image_file": {
|
||||
"file_data": f"data:{content_mime};base64,{content_b64}",
|
||||
"filename": content_file.name,
|
||||
}},
|
||||
],
|
||||
prompt=fusion_prompt,
|
||||
size="1024x1024",
|
||||
)
|
||||
image_bytes = _extract_image_bytes(response.data[0])
|
||||
print(" ✅ 方式 1 成功")
|
||||
except Exception as e:
|
||||
errors.append(f"images.edit: {e}")
|
||||
print(f" ⚠️ 方式 1 失败: {e}")
|
||||
|
||||
# 方法 2: images.generate with b64 image in prompt (chat-style)
|
||||
if image_bytes is None:
|
||||
print(" 尝试方式 2: images.generate with extra_body ...")
|
||||
try:
|
||||
response = client.images.generate(
|
||||
model=model,
|
||||
prompt=fusion_prompt,
|
||||
size="1024x1024",
|
||||
quality="medium",
|
||||
n=1,
|
||||
response_format="b64_json",
|
||||
extra_body={
|
||||
"image": [
|
||||
f"data:{style_mime};base64,{style_b64[:100]}...", # 截断防止请求过大
|
||||
],
|
||||
},
|
||||
)
|
||||
image_bytes = _extract_image_bytes(response.data[0])
|
||||
print(" ✅ 方式 2 成功")
|
||||
except Exception as e:
|
||||
errors.append(f"images.generate+extra_body: {e}")
|
||||
print(f" ⚠️ 方式 2 失败: {e}")
|
||||
|
||||
if image_bytes is None:
|
||||
print()
|
||||
print("❌ 双参考融合 API 调用失败。")
|
||||
print(" 这可能是因为 API易 不支持多图输入的 images.edit 端点。")
|
||||
print()
|
||||
print("💡 替代方案:")
|
||||
print(" 1. 使用 API易 网页界面上传多张参考图手动操作")
|
||||
print(" 2. 或将风格描述写入 prompt,仅上传内容参考图(单图模式)")
|
||||
print()
|
||||
print(" 所有错误:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
sys.exit(1)
|
||||
|
||||
save_result(image_bytes, {
|
||||
"model": model,
|
||||
"prompt": fusion_prompt,
|
||||
"mode": "dual_reference_fusion",
|
||||
"style_reference": str(style_file),
|
||||
"content_reference": str(content_file),
|
||||
"size": "1024x1024",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}, subdir=subdir)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI 入口
|
||||
# ---------------------------------------------------------------------------
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="ChildHood AI Art Generation Tool — 通过 API易 生图",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""\
|
||||
示例:
|
||||
python image_gen.py --test # 测试连通性
|
||||
python image_gen.py -g "千禧年中国城郊的小卖部门口" # 文生图
|
||||
python image_gen.py -g "a red apple" --no-style # 不加风格前缀
|
||||
python image_gen.py --calibrate # 风格校准
|
||||
python image_gen.py --fuse --style ref.png --content photo.jpg # 双参考融合
|
||||
python image_gen.py --fuse -s style.png -c photo.jpg -p "自定义" # 自定义 prompt
|
||||
|
||||
环境变量 (ai-art-research/.env):
|
||||
APIYI_API_KEY API易 令牌 (必填)
|
||||
APIYI_BASE_URL API 地址 (默认 https://api.apiyi.com/v1)
|
||||
APIYI_MODEL 默认模型 (默认 gpt-image-2)
|
||||
""",
|
||||
)
|
||||
|
||||
# 互斥的操作模式
|
||||
mode = parser.add_mutually_exclusive_group()
|
||||
mode.add_argument("--test", action="store_true", help="测试 API 连通性")
|
||||
mode.add_argument("-g", "--generate", type=str, metavar="PROMPT", help="文生图")
|
||||
mode.add_argument("--calibrate", action="store_true", help="风格校准(5 个测试物体)")
|
||||
mode.add_argument("--fuse", action="store_true", help="双参考图融合模式")
|
||||
|
||||
# 参数
|
||||
parser.add_argument("-s", "--style", type=str, help="风格参考图路径(配合 --fuse)")
|
||||
parser.add_argument("-c", "--content", type=str, help="内容参考图路径(配合 --fuse)")
|
||||
parser.add_argument("-p", "--prompt", type=str, help="自定义 prompt")
|
||||
parser.add_argument("-m", "--model", type=str, help=f"模型 (默认: {DEFAULT_MODEL})")
|
||||
parser.add_argument("--no-style", action="store_true", help="文生图不加风格前缀")
|
||||
parser.add_argument("--size", type=str, default="1024x1024", help="图片尺寸")
|
||||
parser.add_argument("--quality", type=str, default="medium",
|
||||
choices=["low", "medium", "high"], help="质量")
|
||||
parser.add_argument("--subdir", type=str, default="", help="输出子目录名")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 无参数时显示帮助
|
||||
if len(sys.argv) == 1:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
client = get_client()
|
||||
|
||||
if args.test:
|
||||
cmd_test(client)
|
||||
elif args.generate:
|
||||
cmd_generate(
|
||||
client, args.generate,
|
||||
use_style=not args.no_style,
|
||||
size=args.size, quality=args.quality,
|
||||
model=args.model, subdir=args.subdir or "文生图",
|
||||
)
|
||||
elif args.calibrate:
|
||||
cmd_calibrate(client, model=args.model)
|
||||
elif args.fuse:
|
||||
if not args.style or not args.content:
|
||||
parser.error("双参考融合需要 --style (-s) 和 --content (-c) 参数")
|
||||
cmd_fuse(
|
||||
client, args.style, args.content,
|
||||
prompt=args.prompt, model=args.model, subdir=args.subdir,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"model": "gpt-image-2",
|
||||
"prompt": "3D low-poly game art, flat cel-shaded with solid color blocks, bright warm-cool balanced saturated palette, soft diffused lighting with subtle shadows, simplified geometric forms with clean edges, low-angle third-person perspective, nostalgic warm summer atmosphere, a simple red cube on a white background, test image",
|
||||
"mode": "connection_test",
|
||||
"size": "1024x1024",
|
||||
"quality": "low",
|
||||
"timestamp": "2026-06-10T00:35:40.485529"
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -8,9 +8,12 @@
|
||||
1. 双击 `sketch.html` 在浏览器中打开(推荐 Chrome / Edge)
|
||||
2. 左侧设置场景名称和世界尺寸(米)
|
||||
3. 选择画笔,在画布上绘制
|
||||
4. **双击** 已绘制区域 → 为该区域命名
|
||||
5. 勾选「可进入」→ 创建下级详图(L1 → L2)
|
||||
6. 保存 PNG + JSON
|
||||
4. **双击** 已绘制区域 → 弹出命名对话框:
|
||||
- 输入名称(如"家"、"小卖部")
|
||||
- 设置**高度**(建筑/区域/不可通行类型时显示,默认 3m)
|
||||
- 勾选**连通到**其他已命名区域(建立区域间连接关系)
|
||||
- 勾选「可进入」→ 创建下级详图(L1 → L2)
|
||||
5. 保存 ZIP(含 PNG + JSON)
|
||||
|
||||
---
|
||||
|
||||
@@ -32,15 +35,15 @@
|
||||
|
||||
## 画笔图例
|
||||
|
||||
| 画笔 | 颜色 | 用途 | 对应 UE 材质 |
|
||||
|------|------|------|-------------|
|
||||
| 道路 Road | 灰 `#909090` | 道路、马路、小路 | MI_DevGray |
|
||||
| 建筑 Building | 橙 `#E07020` | 建筑物 | MI_DevOrange |
|
||||
| 水域 Water | 天蓝 `#40A0D0` | 河流、水塘、水体 | MI_DevSkyBlue |
|
||||
| 植被 Greenery | 绿 `#40B040` | 树木、草地、绿化带 | MI_DevGreen |
|
||||
| 不可通行 Blocked | 棕 `#A07040` | 山体、围墙、不可达区域 | MI_DevBrown |
|
||||
| 区域 Zone | 红 `#D04040` | 区域边界、地块划分 | MI_DevRed |
|
||||
| 橡皮擦 Eraser | — | 擦除 | — |
|
||||
| 画笔 | 颜色 | 用途 | 对应 UE 材质 | 默认高度 |
|
||||
|------|------|------|-------------|---------|
|
||||
| 道路 Road | 灰 `#909090` | 道路、马路、小路 | MI_DevGray | — |
|
||||
| 建筑 Building | 橙 `#E07020` | 建筑物 | MI_DevOrange | 3m |
|
||||
| 水域 Water | 天蓝 `#40A0D0` | 河流、水塘、水体 | MI_DevSkyBlue | — |
|
||||
| 植被 Greenery | 绿 `#40B040` | 树木、草地、绿化带 | MI_DevGreen | 2m |
|
||||
| 不可通行 Blocked | 棕 `#A07040` | 山体、围墙、不可达区域 | MI_DevBrown | 3m |
|
||||
| 区域 Zone | 红 `#D04040` | 区域边界、地块划分 | MI_DevRed | 3m |
|
||||
| 橡皮擦 Eraser | — | 擦除 | — | — |
|
||||
|
||||
**L1 层**主要用:区域、道路、水域
|
||||
**L2 层**主要用:建筑、道路、植被、水域、不可通行
|
||||
@@ -89,7 +92,7 @@
|
||||
```json
|
||||
{
|
||||
"scene_name": "城郊小街",
|
||||
"version": "v01",
|
||||
"version": "v02",
|
||||
"origin": "SW corner, X+ East, Y+ North",
|
||||
"brush_legend": { "色值": { "type": "类型", "mi_material": "MI_Dev*" } },
|
||||
"levels": {
|
||||
@@ -98,16 +101,25 @@
|
||||
"canvas_size_px": [1000, 800],
|
||||
"scale_m_per_px": 0.5,
|
||||
"elements": [
|
||||
{ "id": "el_xxx", "type": "zone", "name": "城郊小街", "has_detail": true, "detail_ref": "L2-el_xxx" }
|
||||
{ "id": "el_001", "type": "zone", "name": "家", "height_m": 3,
|
||||
"connections": ["el_003"], "has_detail": false },
|
||||
{ "id": "el_002", "type": "zone", "name": "小卖部", "height_m": 3,
|
||||
"connections": ["el_003"], "has_detail": false },
|
||||
{ "id": "el_003", "type": "zone", "name": "主街道", "height_m": 3,
|
||||
"connections": ["el_001", "el_002", "el_004"], "has_detail": true, "detail_ref": "L2-el_003" }
|
||||
],
|
||||
"regions": [
|
||||
{ "id": "r_001", "type": "zone", "name": "家",
|
||||
"bounds_world_m": [5.0, 5.0, 12.5, 15.0], "area_px": 15000 }
|
||||
]
|
||||
},
|
||||
"L2-el_xxx": {
|
||||
"L2-el_003": {
|
||||
"world_size_m": [80, 60],
|
||||
"elements": [
|
||||
{ "id": "el_yyy", "type": "building", "name": "家", "has_detail": true }
|
||||
{ "id": "el_yyy", "type": "building", "name": "家", "height_m": 3, "connections": [], "has_detail": false }
|
||||
],
|
||||
"parent_level": "L1",
|
||||
"parent_element": "el_xxx"
|
||||
"parent_element": "el_003"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,17 +145,20 @@ AI 布局解读文档 (场景名-layout-vXX.md)
|
||||
|
||||
### AI 解读流程
|
||||
|
||||
1. 用户在工具中画好布局,保存 PNG + JSON
|
||||
2. 将 PNG 提供给 AI(Claude vision)
|
||||
3. AI 分析颜色区域,结合 JSON 中的比例尺和命名信息
|
||||
4. AI 产出结构化布局解读 Markdown(区域表、连通关系、待确认问题)
|
||||
1. 用户在工具中画好布局,命名区域,设置高度和连通关系,保存 ZIP
|
||||
2. AI 读取 JSON 布局数据(elements + regions + connections + height_m)
|
||||
3. AI 交叉参考场景设计案的氛围描述,将 JSON 数据与场景概念建立映射
|
||||
4. AI 产出结构化布局解读 Markdown(区域表、连通关系、高度、待确认问题)
|
||||
5. 用户确认后,策划基于解读文档产出关卡执行案
|
||||
|
||||
> **说明**:PNG 文件保存在 ZIP 中用于画布的保存/加载,AI 分析以 JSON 结构化数据为准,不依赖 PNG 像素分析。
|
||||
|
||||
---
|
||||
|
||||
## 设计原则
|
||||
|
||||
- **强制使用预定义画笔**:无自由拾色器,保证每个像素都映射到已知地形类型
|
||||
- **结构化数据优先**:高度和连通关系由用户直接输入,AI 无需从图像推断
|
||||
- **层次化设计**:L1 定大局,L2 定细节,L3 预留
|
||||
- **人机共读**:PNG 供人和 AI 视觉分析,JSON 供程序化解析
|
||||
- **人机共读**:PNG 供人视觉确认,JSON 供 AI 和程序化解析
|
||||
- **与 Level Builder 一致**:画笔颜色直接映射 MI_Dev* 标识色材质
|
||||
|
||||
@@ -85,6 +85,14 @@ html, body { height: 100%; overflow: hidden; font-family: "Microsoft YaHei", "Se
|
||||
#name-dialog button { padding: 5px 14px; border-radius: 3px; border: 1px solid #555; cursor: pointer; font-size: 12px; }
|
||||
#name-dialog .btn-ok { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
#name-dialog .btn-cancel { background: #4a4a4a; color: #ddd; }
|
||||
#name-height-row { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; font-size: 12px; }
|
||||
#name-height-row input { width: 60px; background: #3a3a3a; border: 1px solid #555; color: #eee; padding: 4px 6px; border-radius: 3px; text-align: right; font-size: 12px; }
|
||||
#name-connections-row { margin-bottom: 8px; font-size: 12px; }
|
||||
#name-connections-row > label { display: block; margin-bottom: 4px; color: var(--text-dim); }
|
||||
#name-connections-list { max-height: 100px; overflow-y: auto; background: #3a3a3a; border-radius: 3px; padding: 4px; }
|
||||
#name-connections-list label { display: flex; align-items: center; gap: 6px; padding: 2px 4px; cursor: pointer; border-radius: 2px; }
|
||||
#name-connections-list label:hover { background: #4a4a4a; }
|
||||
#name-connections-list input { accent-color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -165,6 +173,15 @@ html, body { height: 100%; overflow: hidden; font-family: "Microsoft YaHei", "Se
|
||||
<h4>命名元素</h4>
|
||||
<input id="name-input" type="text" placeholder="输入名称(如"家"、"小卖部")">
|
||||
<label><input type="checkbox" id="name-enterable"> 可进入(有下级详图)</label>
|
||||
<div id="name-height-row" style="display:none;">
|
||||
<label style="color:var(--text-dim);">高度</label>
|
||||
<input id="name-height" type="number" min="0" step="0.5" value="3">
|
||||
<span style="color:var(--text-dim);">m</span>
|
||||
</div>
|
||||
<div id="name-connections-row" style="display:none;">
|
||||
<label>连通到</label>
|
||||
<div id="name-connections-list"></div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn-cancel" id="name-cancel">取消</button>
|
||||
<button class="btn-ok" id="name-ok">确定</button>
|
||||
@@ -642,6 +659,11 @@ const App = {
|
||||
return best;
|
||||
},
|
||||
|
||||
/* 默认高度(米),按画笔类型 */
|
||||
defaultHeightForType(type) {
|
||||
return { building: 3, blocked: 3, zone: 3, greenery: 2, road: 0, water: 0 }[type] || 0;
|
||||
},
|
||||
|
||||
showNameModal(px, py) {
|
||||
const pixel = this.dc.getContext("2d").getImageData(px, py, 1, 1).data;
|
||||
if (pixel[3] < 50) return;
|
||||
@@ -651,10 +673,46 @@ const App = {
|
||||
const modal = document.getElementById("name-modal");
|
||||
const inp = document.getElementById("name-input");
|
||||
const chk = document.getElementById("name-enterable");
|
||||
const heightRow = document.getElementById("name-height-row");
|
||||
const heightInp = document.getElementById("name-height");
|
||||
const connRow = document.getElementById("name-connections-row");
|
||||
const connList = document.getElementById("name-connections-list");
|
||||
|
||||
inp.value = existing ? existing.name : "";
|
||||
chk.checked = existing ? !!existing.hasDetail : false;
|
||||
// L2 层不显示"可进入"复选框(L3 未实现)
|
||||
chk.parentElement.style.display = this.currentLevel === "L1" ? "" : "none";
|
||||
|
||||
// 高度输入:building / blocked / zone 类型显示
|
||||
const showHeight = ["building", "blocked", "zone"].includes(brushType.id);
|
||||
heightRow.style.display = showHeight ? "flex" : "none";
|
||||
if (showHeight) {
|
||||
heightInp.value = existing && existing.height_m != null ? existing.height_m : this.defaultHeightForType(brushType.id);
|
||||
}
|
||||
|
||||
// 连通多选:列出同层级其他已命名元素
|
||||
const d = this.levelData[this.currentLevel];
|
||||
const others = d.elements.filter(e => e !== existing && e.name);
|
||||
if (others.length > 0) {
|
||||
connRow.style.display = "";
|
||||
connList.innerHTML = "";
|
||||
const existingConns = existing ? (existing.connections || []) : [];
|
||||
others.forEach(o => {
|
||||
const lbl = document.createElement("label");
|
||||
const cb = document.createElement("input");
|
||||
cb.type = "checkbox"; cb.value = o.id;
|
||||
cb.checked = existingConns.includes(o.id);
|
||||
const sw = document.createElement("span");
|
||||
sw.className = "el-swatch"; sw.style.background = o.color; sw.style.width = "12px"; sw.style.height = "12px";
|
||||
const txt = document.createElement("span"); txt.textContent = o.name;
|
||||
lbl.append(cb, sw, txt);
|
||||
connList.appendChild(lbl);
|
||||
});
|
||||
} else {
|
||||
connRow.style.display = "none";
|
||||
connList.innerHTML = "";
|
||||
}
|
||||
|
||||
modal.style.display = "block"; inp.focus();
|
||||
|
||||
const ok = document.getElementById("name-ok"), cancel = document.getElementById("name-cancel");
|
||||
@@ -665,19 +723,39 @@ const App = {
|
||||
nOk.addEventListener("click", () => {
|
||||
const name = inp.value.trim(); if (!name) { cleanup(); return; }
|
||||
const hasDetail = chk.checked;
|
||||
if (existing) { existing.name = name; existing.hasDetail = hasDetail; }
|
||||
else {
|
||||
const el = { id: "el_"+Date.now(), type: brushType.id, name, color: brushType.hex, px, py, hasDetail, detailRef: null };
|
||||
const height_m = showHeight ? parseFloat(heightInp.value) || 0 : null;
|
||||
const connIds = Array.from(connList.querySelectorAll("input:checked")).map(cb => cb.value);
|
||||
|
||||
if (existing) {
|
||||
existing.name = name; existing.hasDetail = hasDetail;
|
||||
existing.height_m = height_m;
|
||||
existing.connections = connIds;
|
||||
} else {
|
||||
const el = {
|
||||
id: "el_"+Date.now(), type: brushType.id, name, color: brushType.hex, px, py,
|
||||
hasDetail, detailRef: null,
|
||||
height_m, connections: connIds,
|
||||
};
|
||||
this.levelData[this.currentLevel].elements.push(el);
|
||||
if (hasDetail && this.currentLevel === "L1") {
|
||||
const l2Id = "L2-" + el.id;
|
||||
// L2 尺寸延迟到 enterElement 时计算
|
||||
this.initLevel(l2Id, 80, 60, 800, 600);
|
||||
this.levelData[l2Id].parentLevel = "L1";
|
||||
this.levelData[l2Id].parentElement = el.id;
|
||||
el.detailRef = l2Id;
|
||||
}
|
||||
}
|
||||
|
||||
// 双向连接:同步到被连通的元素
|
||||
connIds.forEach(cId => {
|
||||
const target = d.elements.find(e => e.id === cId);
|
||||
if (target) {
|
||||
if (!target.connections) target.connections = [];
|
||||
const elId = existing ? existing.id : this.levelData[this.currentLevel].elements[this.levelData[this.currentLevel].elements.length - 1].id;
|
||||
if (!target.connections.includes(elId)) target.connections.push(elId);
|
||||
}
|
||||
});
|
||||
|
||||
this.updateElementsList(); this.drawElementLabels(); cleanup();
|
||||
});
|
||||
nCancel.addEventListener("click", cleanup);
|
||||
@@ -815,6 +893,8 @@ const App = {
|
||||
world_x_m: parseFloat((el.px * sx).toFixed(1)),
|
||||
world_y_m: parseFloat(((d.canvasH - el.py) * sy).toFixed(1)),
|
||||
has_detail: !!el.hasDetail, detail_ref: el.detailRef || null,
|
||||
height_m: el.height_m != null ? el.height_m : null,
|
||||
connections: el.connections || [],
|
||||
})),
|
||||
regions,
|
||||
image_file: imageFile,
|
||||
@@ -909,6 +989,8 @@ const App = {
|
||||
elements: (lvl.elements || []).map(el => ({
|
||||
id: el.id, type: el.type, name: el.name, color: el.color,
|
||||
px: el.px, py: el.py, hasDetail: el.has_detail, detailRef: el.detail_ref,
|
||||
height_m: el.height_m != null ? el.height_m : null,
|
||||
connections: el.connections || [],
|
||||
})),
|
||||
imageData: null, l1BaseData: null,
|
||||
parentLevel: lvl.parent_level || null, parentElement: lvl.parent_element || null,
|
||||
|
||||
Reference in New Issue
Block a user