引入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,189 @@
"""3D Mesh 生成服务 — 阶段 1单图 → textured .glb + 360° 预渲染视频。
当前支持:
- Trellis (firtoz/trellis)~30s$0.041/次,自带 color_video + normal_video + .glb
- Hunyuan3D-2 (tencent/hunyuan3d-2)~127s$0.12/次,仅 .glb 无预渲染视频(二期启用)
"""
import logging
import os
import uuid
from pathlib import Path
from typing import Any
import httpx
from app.config import get_view_transform_model_config
from app.services.image_gen import _make_replicate_client, GENERATED_DIR, to_data_uri
logger = logging.getLogger(__name__)
_replicate_client = _make_replicate_client()
SUPPORTED_EXTS = {
"model_file": ".glb",
"color_video": ".mp4",
"normal_video": ".mp4",
"combined_video": ".mp4",
"gaussian_ply": ".ply",
"mesh": ".glb",
}
async def _download_asset(url: str, ext: str, prefix: str) -> Path:
"""下载远程资源到 generated/ 目录,返回本地路径。"""
filename = f"{prefix}_{uuid.uuid4().hex[:12]}{ext}"
filepath = GENERATED_DIR / filename
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
async with httpx.AsyncClient(proxy=proxy, timeout=httpx.Timeout(300.0)) as client:
resp = await client.get(url, follow_redirects=True)
resp.raise_for_status()
filepath.write_bytes(resp.content)
return filepath
def _to_url(value: Any) -> str | None:
"""把 Replicate FileOutput / str / None 统一转为 URL 字符串。"""
if value is None:
return None
s = str(value)
if s.startswith("http://") or s.startswith("https://"):
return s
return None
async def generate_with_trellis(
image_path: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""调用 Trellis 做单图 3D 重建,下载所有输出资产到本地。
Args:
image_path: 输入图(本地路径 / data URI / URL
params: 覆盖 default_params 的可选参数字典
Returns:
{
"success": bool,
"glb_path": "/generated/trellis_xxx.glb" | None,
"color_video_path": "/generated/trellis_xxx_color.mp4" | None,
"normal_video_path": "/generated/trellis_xxx_normal.mp4" | None,
"combined_video_path": ...,
"raw_output_keys": [...], # 调试用
"error": str | None,
}
"""
config = get_view_transform_model_config("trellis")
replicate_model_id = config["model_id"]
default_params = dict(config.get("default_params", {}))
if params:
default_params.update(params)
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
try:
image_uri = to_data_uri(image_path)
# firtoz/trellis:e8f6c452... 这个部署版的 schema
# - 输入必须用 `images` 数组(传 `image` 单数会 422
# - 输出字段 `color_video` 和 `normal_video` 实测恒为 null
# - 实际可用的视频只有 `combined_video`color+normal 左右并排)
# 因此 Phase 2 抽帧时从 combined_video 切左半得 color、切右半得 normal。
input_params: dict[str, Any] = {
"images": [image_uri],
"randomize_seed": False,
"seed": 0,
**default_params,
}
logger.info(
"调用 Trellis 进行 3D 重建model=%s, params=%s",
replicate_model_id,
{k: v for k, v in input_params.items() if k != "images"},
)
output = await _replicate_client.async_run(
replicate_model_id, input=input_params, wait=False
)
if not isinstance(output, dict):
return {
"success": False,
"error": f"Trellis 返回非字典输出:{type(output).__name__} / {str(output)[:200]}",
"raw_output_keys": [],
}
raw_keys = list(output.keys())
logger.info("Trellis 返回字段:%s", raw_keys)
# 按已知字段挨个下载
result: dict[str, Any] = {
"success": True,
"raw_output_keys": raw_keys,
"error": None,
}
download_map = [
("model_file", "glb_path", ".glb", "trellis"),
("color_video", "color_video_path", ".mp4", "trellis_color"),
("normal_video", "normal_video_path", ".mp4", "trellis_normal"),
("combined_video", "combined_video_path", ".mp4", "trellis_combined"),
]
for src_key, dst_key, ext, prefix in download_map:
if src_key not in output:
result[dst_key] = None
continue
url = _to_url(output[src_key])
if not url:
logger.warning("Trellis 字段 %s 不是有效 URL%s", src_key, output[src_key])
result[dst_key] = None
continue
try:
local_path = await _download_asset(url, ext, prefix)
result[dst_key] = f"/generated/{local_path.name}"
logger.info("已下载 %s%s", src_key, local_path.name)
except Exception as e:
logger.error("下载 %s 失败:%s", src_key, e)
result[dst_key] = None
# 阶段 2 抽帧的优先级color_video > combined_video切左半
# 只要其中一个可用即算成功
has_color_source = bool(result.get("color_video_path")) or bool(
result.get("combined_video_path")
)
if not has_color_source:
result["success"] = False
result["error"] = (
"Trellis 未返回 color_video 或 combined_video无法进入阶段 2 抽帧;"
f"实际返回字段:{raw_keys}"
)
return result
except Exception as e:
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
logger.error("Trellis 调用失败:%s", detail, exc_info=True)
return {
"success": False,
"error": detail,
"raw_output_keys": [],
}
async def generate_mesh(
image_path: str,
model_id: str = "trellis",
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""统一入口:按 model_id 分发到具体实现。"""
if model_id == "trellis":
return await generate_with_trellis(image_path, params)
if model_id == "hunyuan3d":
return {
"success": False,
"error": "Hunyuan3D-2 (一期未启用,待二期接入自定义渲染)",
"raw_output_keys": [],
}
return {
"success": False,
"error": f"未知 mesh 模型:{model_id}",
"raw_output_keys": [],
}

View File

@@ -0,0 +1,180 @@
"""阶段 3对 Trellis 抽帧结果做 IP-Adapter + ControlNet 重绘,还原原画风。
输入:
- original_image用户上传的原图提供风格 / IP-Adapter 参考)
- structure_imageStage 2 抽出的 normal 帧(提供几何 / ControlNet 结构约束)
输出:
- 单张保留原画风且视角正确的 PNG落盘到 GENERATED_DIR/ 下
模型chigozienri/ip_adapter-sdxl-controlnet-depth配置见 config.IMAGE_MODELS
- $0.07/张,~72s硬件 L40S
- `image` 字段 = IP-Adapter 参考图(风格源)
- `controlnet_input` 字段 = 结构参考图(当前一期传 normal 帧作为 depth 近似)
调参建议(可在 IMAGE_MODELS 默认值基础上覆盖):
- scale (IP-Adapter 权重): 0.7-0.8,越高越贴近原图风格
- controlnet_conditioning_scale (结构权重): 0.7-0.9,越高越保几何
"""
from __future__ import annotations
import asyncio
import logging
import os
import uuid
from pathlib import Path
from typing import Any
import httpx
from app.config import get_image_model_config, get_style_restore_model_id
from app.services.image_gen import GENERATED_DIR, _make_replicate_client, to_data_uri
logger = logging.getLogger(__name__)
_replicate_client = _make_replicate_client()
async def _download_image(url: str, prefix: str) -> Path:
"""把 Replicate 返回的图片下载到 generated/。"""
filename = f"{prefix}_{uuid.uuid4().hex[:12]}.png"
filepath = GENERATED_DIR / filename
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
async with httpx.AsyncClient(proxy=proxy, timeout=httpx.Timeout(180.0)) as client:
resp = await client.get(url, follow_redirects=True)
resp.raise_for_status()
filepath.write_bytes(resp.content)
return filepath
def _first_url(output: Any) -> str | None:
"""Replicate 输出有时是 list[FileOutput]、有时是单个;统一取第一张。"""
if output is None:
return None
if isinstance(output, list):
if not output:
return None
return str(output[0])
return str(output)
async def restore(
original_image: str,
structure_image: str,
azimuth: int = 0,
elevation: int = 0,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""对单张 normal 帧做 IP-Adapter + ControlNet 重绘。
Args:
original_image: 用户原图,本地路径 / data URI / URL 均可
structure_image: Stage 2 抽出的 normal 帧,同上
azimuth / elevation: 仅用于日志与返回元数据,不参与模型调用
params: 覆盖默认参数的字典scale / controlnet_conditioning_scale / prompt 等)
Returns:
{
"success": bool,
"url": "/generated/restyled_xxx.png" | None,
"azimuth": int,
"elevation": int,
"error": str | None,
}
"""
config = get_image_model_config(get_style_restore_model_id())
replicate_model_id = config["model_id"]
merged_params = dict(config.get("default_params", {}))
if params:
merged_params.update(params)
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
try:
image_uri = to_data_uri(original_image)
structure_uri = to_data_uri(structure_image)
input_params: dict[str, Any] = {
"image": image_uri, # IP-Adapter 参考图 = 用户原图
"controlnet_input": structure_uri, # 结构参考 = normal 帧
**merged_params,
}
logger.info(
"Stage 3 重绘 az=%d el=%d: scale=%s, cn_scale=%s",
azimuth, elevation,
merged_params.get("scale"),
merged_params.get("controlnet_conditioning_scale"),
)
output = await _replicate_client.async_run(
replicate_model_id, input=input_params, wait=False
)
url = _first_url(output)
if not url or not (url.startswith("http://") or url.startswith("https://")):
return {
"success": False,
"url": None,
"azimuth": azimuth,
"elevation": elevation,
"error": f"模型未返回有效图片 URL: {str(output)[:200]}",
}
local = await _download_image(url, prefix=f"restyled_az{azimuth}_el{elevation}")
return {
"success": True,
"url": f"/generated/{local.name}",
"azimuth": azimuth,
"elevation": elevation,
"error": None,
}
except Exception as e:
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
logger.error(
"Stage 3 重绘失败 az=%d el=%d: %s", azimuth, elevation, detail, exc_info=True
)
return {
"success": False,
"url": None,
"azimuth": azimuth,
"elevation": elevation,
"error": detail,
}
async def restore_batch(
original_image: str,
frames: list[dict[str, Any]],
params: dict[str, Any] | None = None,
concurrency: int = 3,
) -> list[dict[str, Any]]:
"""对一批 frames 做 Stage 3 重绘,带并发控制。
Args:
original_image: 用户原图(所有帧共用同一张风格参考)
frames: [{"az", "el", "normal_png", ...}, ...]normal_png 作为结构参考
params: 透传给 restore() 的覆盖参数
concurrency: 最大并发数Replicate 并发打太猛容易撞 rate limit3 比较稳
Returns:
列表,顺序与 frames 对齐,元素与 restore() 的返回一致。
"""
if not frames:
return []
sem = asyncio.Semaphore(max(1, concurrency))
async def _one(f: dict[str, Any]) -> dict[str, Any]:
async with sem:
return await restore(
original_image=original_image,
structure_image=f["normal_png"],
azimuth=int(f.get("az", 0)),
elevation=int(f.get("el", 0)),
params=params,
)
return await asyncio.gather(*[_one(f) for f in frames])

View File

@@ -0,0 +1,191 @@
"""阶段 2从 Trellis combined_video 抽帧 + 切左右半得到 color / normal 帧。
Trellis (firtoz/trellis) 部署版 `e8f6c452...` 实测只返回 `combined_video`
color 和 normal 左右并排 mp4约 5 秒 360° 水平环绕),不再单独返回
`color_video` / `normal_video`(详见 PF-20260420-1600
本模块职责:
1. 对给定 combined_video 做一次全帧抽取imageio-ffmpeg 自带二进制,无需系统 ffmpeg
2. 按 azimuth / total_rotation_deg 映射到帧索引并挑出对应帧
3. 用 PIL 切左半 → color png、右半 → normal png落盘到 GENERATED_DIR
4. 返回 [{az, el, frame_idx, color_png, normal_png}, ...]
一期仅支持 elevation=0Trellis 的 combined_video 就是平视一圈,本身没有仰俯)。
需要自由 elevation 时走 Phase 5 的 mesh_renderer 路径pyrender / Blender headless
"""
from __future__ import annotations
import asyncio
import logging
import subprocess
import uuid
from pathlib import Path
from typing import Any
import imageio_ffmpeg # 提供静态 ffmpeg 二进制,避免依赖系统 ffmpeg
from PIL import Image
from app.services.image_gen import GENERATED_DIR
logger = logging.getLogger(__name__)
FFMPEG_EXE = imageio_ffmpeg.get_ffmpeg_exe()
def _resolve_local_path(path_or_url: str) -> Path:
"""把 mesh_generator 返回的 '/generated/xxx.mp4' 解析为真实磁盘路径。
兼容三种输入:
- '/generated/xxx.mp4' → GENERATED_DIR / xxx.mp4
- 'D:/abs/path/xxx.mp4' → Path 原样
- 'xxx.mp4' → Path 原样(调用方自行保证)
"""
if not path_or_url:
raise ValueError("combined_video_path 为空")
if path_or_url.startswith("/generated/"):
return GENERATED_DIR / Path(path_or_url).name
return Path(path_or_url)
def _extract_all_frames(video_path: Path, temp_dir: Path) -> list[Path]:
"""把 video 的所有帧抽到 temp_dir返回按文件名排序的帧列表。
对 Trellis ~5s 的 combined_video~100-150 帧)这种量级是 OK 的,
比起"用 -ss 逐帧抽取"更省心、角度映射也更稳。
"""
temp_dir.mkdir(parents=True, exist_ok=True)
out_pattern = str(temp_dir / "f%05d.png")
cmd = [
FFMPEG_EXE,
"-y",
"-hide_banner",
"-loglevel", "error",
"-i", str(video_path),
"-vsync", "0",
out_pattern,
]
logger.info("ffmpeg extract-all: %s", " ".join(cmd))
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError(
f"ffmpeg 抽帧失败 (code {proc.returncode}): stderr={proc.stderr[-500:]}"
)
frames = sorted(temp_dir.glob("f*.png"))
if not frames:
raise RuntimeError(
f"ffmpeg 执行成功但没产出帧:{video_path}stderr={proc.stderr[-500:]}"
)
return frames
def _split_combined_frame(
frame_path: Path,
out_prefix: str,
az: int,
el: int,
) -> tuple[Path, Path]:
"""把 combined 帧按中线切成 color+ normal两张图。"""
img = Image.open(frame_path)
w, h = img.size
mid = w // 2
color = img.crop((0, 0, mid, h))
normal = img.crop((mid, 0, w, h))
color_name = f"view_{out_prefix}_az{az}_el{el}_color.png"
normal_name = f"view_{out_prefix}_az{az}_el{el}_normal.png"
color_path = GENERATED_DIR / color_name
normal_path = GENERATED_DIR / normal_name
color.save(color_path, "PNG")
normal.save(normal_path, "PNG")
return color_path, normal_path
def _cleanup_dir(dir_path: Path) -> None:
"""静默删除临时目录及其所有文件,失败不抛。"""
if not dir_path.exists():
return
for f in dir_path.glob("*"):
try:
f.unlink()
except OSError:
pass
try:
dir_path.rmdir()
except OSError:
pass
def _do_extract(
video: Path,
azimuths: list[int],
elevations: list[int],
total_rotation_deg: int,
) -> list[dict[str, Any]]:
"""同步版抽帧逻辑,供 async 版本用 run_in_executor 包起来跑。"""
batch_id = uuid.uuid4().hex[:8]
temp_dir = GENERATED_DIR / f"_frames_{batch_id}"
try:
frames = _extract_all_frames(video, temp_dir)
total = len(frames)
logger.info("combined_video %s 抽出 %d 帧,总时长~%.2fs (按 24fps 估)",
video.name, total, total / 24.0)
results: list[dict[str, Any]] = []
for az, el in zip(azimuths, elevations):
# az 归一化到 [0, total_rotation_deg),再映射到 [0, total) 整数索引
az_mod = az % total_rotation_deg if total_rotation_deg > 0 else 0
frame_idx = int(round(az_mod / total_rotation_deg * total)) % total if total > 0 else 0
src_frame = frames[frame_idx]
color_path, normal_path = _split_combined_frame(src_frame, batch_id, az, el)
results.append({
"az": az,
"el": el,
"frame_idx": frame_idx,
"color_png": f"/generated/{color_path.name}",
"normal_png": f"/generated/{normal_path.name}",
})
return results
finally:
_cleanup_dir(temp_dir)
async def extract_by_azimuth(
combined_video_path: str,
azimuths: list[int],
elevations: list[int] | None = None,
total_rotation_deg: int = 360,
) -> list[dict[str, Any]]:
"""按 azimuth 列表从 combined_video 抽帧 + 切左右半。
Args:
combined_video_path: '/generated/trellis_combined_xxx.mp4' 或本地磁盘路径
azimuths: 方位角数组单位度0-360允许大于 360 会自动取模)
elevations: 仰角数组;长度须等于 azimuths一期所有值被忽略保留字段以便后续扩展
total_rotation_deg: combined_video 覆盖的水平旋转范围Trellis 默认 360
Returns:
[{"az": 60, "el": 0, "frame_idx": 20,
"color_png": "/generated/view_xxx_az60_el0_color.png",
"normal_png": "/generated/view_xxx_az60_el0_normal.png"}, ...]
"""
video = _resolve_local_path(combined_video_path)
if not video.exists():
raise FileNotFoundError(f"combined_video 文件不存在:{video}")
if not azimuths:
return []
if elevations is None:
elevations = [0] * len(azimuths)
if len(elevations) != len(azimuths):
raise ValueError(
f"azimuths / elevations 长度不一致:{len(azimuths)} vs {len(elevations)}"
)
# ffmpeg + PIL 是 CPU/IO 阻塞的,丢到线程池里跑避免堵死事件循环
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None,
_do_extract,
video, azimuths, elevations, total_rotation_deg,
)

View File

@@ -1,4 +1,8 @@
"""视角变换服务 — 调用 Zero123++ 等模型,将单张图片转为多视角图片。"""
"""视角变换服务 — 调度单图→多视角生成,支持两种管道:
- grid 管道Zero123++ 直接输出 6 宫格拼接图,切分后得到固定 6 视角。
- mesh 管道Trellis/Hunyuan3D → .glb + 预渲染视频 → 抽帧 → 可选风格还原。
"""
import base64
import logging
@@ -13,6 +17,9 @@ from PIL import Image
from app.config import get_view_transform_model_config
from app.services.image_gen import _make_replicate_client, GENERATED_DIR, to_data_uri
from app.services import mesh_generator, style_restorer, video_frame_extractor
DEFAULT_MESH_AZIMUTHS = [0, 60, 120, 180, 240, 300]
logger = logging.getLogger(__name__)
@@ -29,9 +36,30 @@ def _split_grid_image(
返回格式:[{"url": "/generated/xxx.png", "azimuth": 30, "elevation": 30}, ...]
Grid 布局从左到右、从上到下依次对应 view_labels 中的视角。
由于不同 Replicate 部署版本输出的拼接图布局可能不同(有的是 3列×2行 的
宽图,有的是 2列×3行 的高图),这里会根据实际图像长宽比自动纠正 cols/rows——
假定每个单视角为正方形,按 aspect ratio 推断真实布局,防止切图错位。
"""
img = Image.open(image_path)
w, h = img.size
expected_cells = cols * rows
if expected_cells > 0 and len(view_labels) == expected_cells:
# 假定每个视角为正方形:真实 aspect ratio = cols / rows
configured_ratio = cols / rows
actual_ratio = w / h if h else configured_ratio
# 如果实际 ratio 与配置差异较大(超过 20%),判定为布局反了
if actual_ratio > 0 and (
max(configured_ratio, actual_ratio) / min(configured_ratio, actual_ratio) > 1.2
):
logger.warning(
"Grid 布局与实际图像不匹配:配置 cols=%d rows=%dratio=%.2f"
"实际图像 %dx%dratio=%.2f),自动交换 cols/rows。",
cols, rows, configured_ratio, w, h, actual_ratio,
)
cols, rows = rows, cols
cell_w = w // cols
cell_h = h // rows
@@ -73,30 +101,60 @@ async def _download_to_local(url: str) -> Path:
async def transform_view(
image_path: str,
model_id: str | None = None,
azimuths: list[int] | None = None,
elevations: list[int] | None = None,
preserve_style: bool = True,
) -> dict[str, Any]:
"""调用视角变换模型,返回多视角图片列表。
Args:
image_path: 输入图片路径本地路径、data URI 或 URL
model_id: 视角变换模型 ID,默认 zero123plus
model_id: 视角变换模型 IDzero123plus / trellis / hunyuan3d
azimuths: mesh 管道专用,方位角数组
elevations: mesh 管道专用,仰角数组(当前一期仅 0 有效)
preserve_style: mesh 管道专用,是否走阶段 3 风格还原
Returns:
统一字段(不同管道可能缺省):
{
"success": bool,
"images": [{"url": str, "azimuth": int, "elevation": int}, ...],
"grid_image": str, # 原始拼接图的本地 URL
"grid_image": str | None, # grid 管道:原始拼接图 URL
"mesh_url": str | None, # mesh 管道:.glb URL
"color_video": str | None, # mesh 管道360° color video URL
"normal_video": str | None, # mesh 管道:法线 video URL
"pipeline": "grid" | "mesh",
"model_name": str,
"error": str | None,
}
"""
config = get_view_transform_model_config(model_id)
pipeline = config.get("pipeline", "grid")
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
if pipeline == "mesh":
return await _transform_view_mesh(
image_path=image_path,
config=config,
azimuths=azimuths,
elevations=elevations,
preserve_style=preserve_style,
)
return await _transform_view_grid(image_path=image_path, config=config)
async def _transform_view_grid(
image_path: str,
config: dict[str, Any],
) -> dict[str, Any]:
"""原 Zero123++ 逻辑:单次调用 → grid 图 → 切 6 张。"""
model_name = config["name"]
replicate_model_id = config["model_id"]
grid_layout = config["grid_layout"]
output_views = config["output_views"]
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
try:
image_uri = to_data_uri(image_path)
input_params = {"image": image_uri}
@@ -141,6 +199,10 @@ async def transform_view(
"success": True,
"images": view_images,
"grid_image": grid_local_url,
"mesh_url": None,
"color_video": None,
"normal_video": None,
"pipeline": "grid",
"model_name": model_name,
"error": None,
}
@@ -152,6 +214,144 @@ async def transform_view(
"success": False,
"images": [],
"grid_image": None,
"mesh_url": None,
"color_video": None,
"normal_video": None,
"pipeline": "grid",
"model_name": model_name,
"error": detail,
}
async def _transform_view_mesh(
image_path: str,
config: dict[str, Any],
azimuths: list[int] | None,
elevations: list[int] | None,
preserve_style: bool,
) -> dict[str, Any]:
"""Mesh 管道调度器:阶段 1 生成 mesh + video阶段 2/3 后续 Phase 接入。
Phase 1 验收状态:只跑阶段 1返回 .glb / color_video / normal_video 本地 URL
images 列表为空。Phase 2 完成后会填充 imagesraw 抽帧结果),
Phase 3 完成后 images 会是经 IP-Adapter+ControlNet 重绘的最终图。
"""
model_name = config["name"]
model_id = config["id"]
# === Stage 1生成 mesh + 预渲染视频 ===
stage1 = await mesh_generator.generate_mesh(image_path, model_id=model_id)
if not stage1.get("success"):
return {
"success": False,
"images": [],
"grid_image": None,
"mesh_url": None,
"color_video": None,
"normal_video": None,
"combined_video": None,
"pipeline": "mesh",
"model_name": model_name,
"error": stage1.get("error") or "阶段 1 失败",
}
# === Stage 2抽帧 + 切左右半color + normal===
# azimuths / elevations 缺省时用默认 6 视角;长度对齐
if not azimuths:
azimuths = list(DEFAULT_MESH_AZIMUTHS)
if elevations is None or not elevations:
elevations = [0] * len(azimuths)
elif len(elevations) < len(azimuths):
elevations = list(elevations) + [0] * (len(azimuths) - len(elevations))
elif len(elevations) > len(azimuths):
elevations = list(elevations[: len(azimuths)])
# 优先 color_video当前 Trellis 部署版恒为 None缺则 fallback 到 combined_video
combined_video_path = stage1.get("combined_video_path")
frames: list[dict[str, Any]] = []
stage2_error: str | None = None
if combined_video_path:
try:
frames = await video_frame_extractor.extract_by_azimuth(
combined_video_path=combined_video_path,
azimuths=azimuths,
elevations=elevations,
total_rotation_deg=360,
)
except Exception as e:
stage2_error = f"Stage 2 抽帧失败:{e}"
logger.error(stage2_error, exc_info=True)
else:
stage2_error = "Trellis 未返回 combined_video无法执行 Stage 2 抽帧"
logger.error(stage2_error)
# === Stage 3风格还原可选===
# preserve_style=True 时对每一帧的 normal_png 做 IP-Adapter+ControlNet 重绘,
# 用户原图做风格参考;失败的单帧会回退为 Stage 2 的 color 帧,不阻塞其它帧。
stage3_error: str | None = None
stage3_results: list[dict[str, Any]] = []
did_stage3 = bool(preserve_style) and bool(frames)
if did_stage3:
try:
stage3_results = await style_restorer.restore_batch(
original_image=image_path,
frames=frames,
concurrency=3,
)
except Exception as e:
stage3_error = f"Stage 3 风格还原失败:{e}"
logger.error(stage3_error, exc_info=True)
# 组装最终 images
# - Stage 3 成功的帧 → 用重绘后的 URL
# - Stage 3 失败 / 未跑 → 回退到 Stage 2 的 color 帧(保证前端至少能看到点东西)
images: list[dict[str, Any]] = []
for i, f in enumerate(frames):
restyled = stage3_results[i] if i < len(stage3_results) else None
if restyled and restyled.get("success") and restyled.get("url"):
final_url = restyled["url"]
frame_error = None
else:
final_url = f["color_png"]
frame_error = (restyled or {}).get("error") if did_stage3 else None
images.append({
"url": final_url,
"azimuth": f["az"],
"elevation": f["el"],
"normal_url": f["normal_png"],
"color_url": f["color_png"],
"frame_idx": f["frame_idx"],
"restyled": bool(restyled and restyled.get("success")),
**({"restyle_error": frame_error} if frame_error else {}),
})
# Stage 2 失败不把整体置为失败——Stage 1 的 mesh / video 产物仍有交付价值
# Stage 3 部分失败也不置为整体失败(每帧都有 Stage 2 回退)
success = bool(stage1.get("success")) and (stage2_error is None)
# 合并错误信息Stage 2 和 Stage 3 错误都要暴露给前端)
error_parts = [e for e in (stage2_error, stage3_error) if e]
error_out = "; ".join(error_parts) if error_parts else None
if did_stage3:
restyled_ok = sum(1 for img in images if img.get("restyled"))
stage_label = f"stage3 ({restyled_ok}/{len(images)} restyled)"
elif frames:
stage_label = "stage2"
else:
stage_label = "stage1"
return {
"success": success,
"images": images,
"grid_image": None,
"mesh_url": stage1.get("glb_path"),
"color_video": stage1.get("color_video_path"),
"normal_video": stage1.get("normal_video_path"),
"combined_video": combined_video_path,
"pipeline": "mesh",
"model_name": model_name,
"error": error_out,
"_stage": stage_label,
"_preserve_style_requested": bool(preserve_style),
}