引入3d模型
This commit is contained in:
107
art-agent/backend/scripts/test_frame_extract.py
Normal file
107
art-agent/backend/scripts/test_frame_extract.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Phase 2 验收脚本:独立验证 combined_video 抽帧 + 左右切分逻辑。
|
||||
|
||||
不需要再跑一次 Trellis(省钱),直接用 generated/ 下已有的
|
||||
`trellis_combined_*.mp4` 做验证。
|
||||
|
||||
用法(在 backend 目录激活 venv 之后):
|
||||
python scripts/test_frame_extract.py
|
||||
python scripts/test_frame_extract.py generated/trellis_combined_211176d3940b.mp4
|
||||
|
||||
成功标准:
|
||||
- 退出码 0
|
||||
- 打印的 color_png / normal_png 均为 /generated/view_xxx.png
|
||||
- 到 backend/generated/ 能看到 6 对 color + normal 文件(12 张)且尺寸接近正方形
|
||||
- 目视检查:每组 color 图按 0/60/120/180/240/300° 依次环绕建筑一圈
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BACKEND_ROOT))
|
||||
|
||||
from dotenv import load_dotenv # noqa: E402
|
||||
|
||||
load_dotenv(_BACKEND_ROOT / ".env", override=True)
|
||||
|
||||
from app.services import video_frame_extractor # noqa: E402
|
||||
from app.services.image_gen import GENERATED_DIR # noqa: E402
|
||||
|
||||
|
||||
def _pick_default_video() -> str:
|
||||
candidates = sorted(
|
||||
GENERATED_DIR.glob("trellis_combined_*.mp4"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
if not candidates:
|
||||
raise FileNotFoundError(
|
||||
"generated/ 下找不到 trellis_combined_*.mp4;请先跑一次 Phase 1 "
|
||||
"(scripts/test_trellis.py)或显式传入视频路径"
|
||||
)
|
||||
return f"/generated/{candidates[0].name}"
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
video_arg = sys.argv[1] if len(sys.argv) > 1 else _pick_default_video()
|
||||
# 允许传相对路径或纯文件名
|
||||
if not video_arg.startswith("/generated/"):
|
||||
if video_arg.startswith("generated/"):
|
||||
video_arg = "/" + video_arg.replace("\\", "/")
|
||||
elif not video_arg.startswith("/") and not Path(video_arg).is_absolute():
|
||||
# 如果只给了个文件名,假定在 generated/ 下
|
||||
if (GENERATED_DIR / video_arg).exists():
|
||||
video_arg = f"/generated/{video_arg}"
|
||||
|
||||
print(f"[test_frame_extract] 输入视频:{video_arg}")
|
||||
print("[test_frame_extract] 抽取 6 视角:az=[0,60,120,180,240,300],el 全 0")
|
||||
|
||||
azimuths = [0, 60, 120, 180, 240, 300]
|
||||
elevations = [0] * len(azimuths)
|
||||
|
||||
frames = await video_frame_extractor.extract_by_azimuth(
|
||||
combined_video_path=video_arg,
|
||||
azimuths=azimuths,
|
||||
elevations=elevations,
|
||||
total_rotation_deg=360,
|
||||
)
|
||||
|
||||
print("[test_frame_extract] 抽帧结果:")
|
||||
print(json.dumps(frames, ensure_ascii=False, indent=2))
|
||||
|
||||
# 简单完整性校验
|
||||
all_exist = True
|
||||
for f in frames:
|
||||
for key in ("color_png", "normal_png"):
|
||||
rel = f.get(key)
|
||||
if not rel:
|
||||
print(f"[test_frame_extract] 警告:帧 az={f['az']} 缺少 {key}")
|
||||
all_exist = False
|
||||
continue
|
||||
local = GENERATED_DIR / Path(rel).name
|
||||
size = local.stat().st_size if local.exists() else -1
|
||||
status = "OK" if size > 0 else "MISSING"
|
||||
print(f" [{status}] az={f['az']:3d} {key}: {rel} ({size} bytes)")
|
||||
if size <= 0:
|
||||
all_exist = False
|
||||
|
||||
if not all_exist:
|
||||
print("[test_frame_extract] 部分文件缺失,判定失败")
|
||||
return 1
|
||||
|
||||
print(
|
||||
f"[test_frame_extract] 成功:共 {len(frames)} 个视角,"
|
||||
f"color+normal 合计 {len(frames) * 2} 张已落盘到 {GENERATED_DIR}"
|
||||
)
|
||||
print(
|
||||
"[test_frame_extract] 请目视检查:打开 generated/ 下任意 view_*_color.png,"
|
||||
"确认 az=0 正面、az=180 背面、其余按顺序环绕;normal 帧应为蓝紫调法线图。"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
167
art-agent/backend/scripts/test_style_restore.py
Normal file
167
art-agent/backend/scripts/test_style_restore.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Phase 3 / Checkpoint 3 验收脚本:独立验证 Stage 3 风格还原链路。
|
||||
|
||||
复用 Phase 2 已抽出的 normal 帧,无需再跑 Trellis(省 $0.041)。
|
||||
默认会挑最新一组 view_*_el0_normal.png 作为结构参考,并让用户指定原图。
|
||||
|
||||
用法(在 backend 目录激活 venv 后):
|
||||
# 1. 全 6 帧批量重绘(~6 × $0.07 ≈ $0.42)
|
||||
python scripts/test_style_restore.py <原图路径>
|
||||
|
||||
# 2. 只跑一帧做快速冒烟(便宜 ~$0.07)
|
||||
python scripts/test_style_restore.py <原图路径> --single
|
||||
|
||||
# 3. 手动指定 normal 帧前缀(不指定时用最新一组)
|
||||
python scripts/test_style_restore.py <原图路径> --prefix view_xxxxxxxx
|
||||
|
||||
成功标准:
|
||||
- 退出码 0
|
||||
- 打印的每一帧都返回 success=True 且 url 以 /generated/restyled_ 开头
|
||||
- 打开 generated/restyled_* 图片,目视检查:风格与原图一致、视角符合 az 标注
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BACKEND_ROOT))
|
||||
|
||||
from dotenv import load_dotenv # noqa: E402
|
||||
|
||||
load_dotenv(_BACKEND_ROOT / ".env", override=True)
|
||||
|
||||
from app.services import style_restorer # noqa: E402
|
||||
from app.services.image_gen import GENERATED_DIR # noqa: E402
|
||||
|
||||
|
||||
def _pick_latest_normal_group() -> tuple[str, list[dict]]:
|
||||
"""在 generated/ 下挑一组最新的 view_<prefix>_az*_el0_normal.png,按 az 排好序。
|
||||
|
||||
返回 (prefix, frames),frames = [{az, el, normal_png, color_png}, ...]
|
||||
"""
|
||||
all_normals = sorted(
|
||||
GENERATED_DIR.glob("view_*_el0_normal.png"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
if not all_normals:
|
||||
raise FileNotFoundError(
|
||||
"generated/ 下找不到 view_*_el0_normal.png;请先跑 scripts/test_frame_extract.py"
|
||||
)
|
||||
# 最新文件的 prefix:view_<8hex>_az...
|
||||
latest_name = all_normals[0].name
|
||||
# 形如 view_63731b77_az0_el0_normal.png → prefix = view_63731b77
|
||||
parts = latest_name.split("_")
|
||||
prefix = "_".join(parts[:2])
|
||||
return prefix, _load_group_by_prefix(prefix)
|
||||
|
||||
|
||||
def _load_group_by_prefix(prefix: str) -> list[dict]:
|
||||
normals = sorted(GENERATED_DIR.glob(f"{prefix}_az*_el0_normal.png"))
|
||||
if not normals:
|
||||
raise FileNotFoundError(f"找不到前缀为 {prefix} 的 normal 帧")
|
||||
frames = []
|
||||
for n in normals:
|
||||
# 从文件名解析 az:view_63731b77_az120_el0_normal.png → az=120
|
||||
tokens = n.stem.split("_")
|
||||
az = 0
|
||||
el = 0
|
||||
for t in tokens:
|
||||
if t.startswith("az"):
|
||||
try:
|
||||
az = int(t[2:])
|
||||
except ValueError:
|
||||
pass
|
||||
elif t.startswith("el"):
|
||||
try:
|
||||
el = int(t[2:])
|
||||
except ValueError:
|
||||
pass
|
||||
color = n.with_name(n.name.replace("_normal.png", "_color.png"))
|
||||
frames.append({
|
||||
"az": az,
|
||||
"el": el,
|
||||
"normal_png": f"/generated/{n.name}",
|
||||
"color_png": f"/generated/{color.name}" if color.exists() else None,
|
||||
"frame_idx": -1,
|
||||
})
|
||||
frames.sort(key=lambda f: f["az"])
|
||||
return frames
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("original", help="用户原图路径(本地相对/绝对路径均可)")
|
||||
parser.add_argument("--prefix", default=None, help="normal 帧的文件名前缀(如 view_63731b77)")
|
||||
parser.add_argument("--single", action="store_true", help="只跑第一帧做快速冒烟")
|
||||
parser.add_argument("--scale", type=float, default=None, help="覆盖 IP-Adapter 权重(0-1)")
|
||||
parser.add_argument("--cn-scale", type=float, default=None, help="覆盖 ControlNet 权重(0-1)")
|
||||
args = parser.parse_args()
|
||||
|
||||
original = args.original
|
||||
original_path = Path(original)
|
||||
if not original_path.is_absolute():
|
||||
original_path = (_BACKEND_ROOT / original).resolve()
|
||||
if not original_path.exists():
|
||||
print(f"[test_style_restore] 原图不存在:{original_path}")
|
||||
return 2
|
||||
|
||||
if args.prefix:
|
||||
frames = _load_group_by_prefix(args.prefix)
|
||||
prefix = args.prefix
|
||||
else:
|
||||
prefix, frames = _pick_latest_normal_group()
|
||||
|
||||
if args.single:
|
||||
frames = frames[:1]
|
||||
|
||||
override_params = {}
|
||||
if args.scale is not None:
|
||||
override_params["scale"] = args.scale
|
||||
if args.cn_scale is not None:
|
||||
override_params["controlnet_conditioning_scale"] = args.cn_scale
|
||||
|
||||
print(f"[test_style_restore] 原图:{original_path}")
|
||||
print(f"[test_style_restore] normal 帧组 prefix={prefix},共 {len(frames)} 帧")
|
||||
print(f"[test_style_restore] 角度列表:{[f['az'] for f in frames]}")
|
||||
if override_params:
|
||||
print(f"[test_style_restore] 覆盖参数:{override_params}")
|
||||
print(
|
||||
f"[test_style_restore] 预计耗时 ~{72}s × {len(frames)},"
|
||||
f"成本 ~${0.07 * len(frames):.2f}(并发 3 实际会更快)"
|
||||
)
|
||||
|
||||
results = await style_restorer.restore_batch(
|
||||
original_image=str(original_path),
|
||||
frames=frames,
|
||||
params=override_params or None,
|
||||
concurrency=3,
|
||||
)
|
||||
|
||||
print("[test_style_restore] 重绘结果:")
|
||||
print(json.dumps(results, ensure_ascii=False, indent=2))
|
||||
|
||||
ok = sum(1 for r in results if r.get("success"))
|
||||
print(f"[test_style_restore] 成功 {ok}/{len(results)} 帧")
|
||||
|
||||
if ok == 0:
|
||||
print("[test_style_restore] 全军覆没,判定失败")
|
||||
return 1
|
||||
if ok < len(results):
|
||||
print("[test_style_restore] 部分帧失败,视为部分通过;请检查上方错误信息")
|
||||
return 0
|
||||
|
||||
print(
|
||||
"[test_style_restore] 全部成功;请目视检查 generated/restyled_* 图片:"
|
||||
"风格应贴近原图、视角按 az 顺序环绕。"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
72
art-agent/backend/scripts/test_trellis.py
Normal file
72
art-agent/backend/scripts/test_trellis.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Phase 1 验收脚本:直接调用 Trellis,验证 .glb + color_video + normal_video 能下到本地。
|
||||
|
||||
用法(在 backend 目录激活 venv 之后):
|
||||
python scripts/test_trellis.py uploads/0e6fd7e47a524bb9ad18de0b72495e0e.png
|
||||
|
||||
不带参数时会尝试使用 uploads/ 下最新的 png 作为测试图。
|
||||
|
||||
成功标准:
|
||||
- 退出码 0
|
||||
- 终端打印 glb_path / color_video_path / normal_video_path 均为 /generated/ 开头
|
||||
- 实际到 backend/generated/ 目录能看到 3 个文件且 size > 0
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 允许直接 `python scripts/test_trellis.py` 跑
|
||||
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_BACKEND_ROOT))
|
||||
|
||||
# .env 加载(复用项目 main.py 的习惯)
|
||||
from dotenv import load_dotenv # noqa: E402
|
||||
|
||||
load_dotenv(_BACKEND_ROOT / ".env", override=True)
|
||||
|
||||
from app.services import mesh_generator # noqa: E402
|
||||
|
||||
|
||||
def _pick_default_image() -> str:
|
||||
uploads = _BACKEND_ROOT / "uploads"
|
||||
pngs = sorted(uploads.glob("*.png"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
if not pngs:
|
||||
raise FileNotFoundError(f"uploads/ 下没有 png 文件,请显式传入图片路径")
|
||||
return str(pngs[0].relative_to(_BACKEND_ROOT)).replace("\\", "/")
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
image_arg = sys.argv[1] if len(sys.argv) > 1 else _pick_default_image()
|
||||
# 规范化成 / 开头的 "本地相对 URL" 以便 to_data_uri 识别
|
||||
if not image_arg.startswith("/") and not image_arg.startswith("data:") and not image_arg.startswith("http"):
|
||||
image_arg = "/" + image_arg.lstrip("./").replace("\\", "/")
|
||||
|
||||
print(f"[test_trellis] 输入图:{image_arg}")
|
||||
print("[test_trellis] 开始调用 Trellis(预计 ~30s 起)...")
|
||||
|
||||
result = await mesh_generator.generate_with_trellis(image_arg)
|
||||
|
||||
print("[test_trellis] Trellis 返回:")
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
if not result.get("success"):
|
||||
print("[test_trellis] 失败:", result.get("error"))
|
||||
return 1
|
||||
|
||||
generated_dir = _BACKEND_ROOT / "generated"
|
||||
for key in ("glb_path", "color_video_path", "normal_video_path"):
|
||||
rel = result.get(key)
|
||||
if not rel:
|
||||
print(f"[test_trellis] 警告:缺少 {key}")
|
||||
continue
|
||||
local = generated_dir / Path(rel).name
|
||||
size = local.stat().st_size if local.exists() else -1
|
||||
print(f"[test_trellis] {key}: {rel} (本地大小: {size} bytes)")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
Reference in New Issue
Block a user