引入3d模型

This commit is contained in:
2026-04-20 21:52:35 +08:00
parent ee5cec6de4
commit d6e1797f08
23 changed files with 1890 additions and 288 deletions

View 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()))

View 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"
)
# 最新文件的 prefixview_<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:
# 从文件名解析 azview_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()))

View 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()))