Files
FiDA_Python/src/server/canvas_generate_3D/triop3d_api_server.py
2026-04-14 17:23:30 +08:00

652 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import io
import json
import logging
import mimetypes
import os
import time
from pathlib import Path
from typing import Any, Dict, Iterator, Tuple, List
from urllib.parse import urlparse
import httpx
import numpy as np
import requests
import trimesh
from minio import Minio, S3Error
from src.core.config import settings
from src.schemas.generate_3D import Tripo3dApiModel
from src.server.canvas_generate_3D.callback import notify_callback
logger = logging.getLogger(__name__)
minio_client = Minio(settings.MINIO_URL, access_key=settings.MINIO_ACCESS, secret_key=settings.MINIO_SECRET, secure=settings.MINIO_SECURE)
class TripoAPIError(RuntimeError):
pass
class Triop3dApiServer:
def __init__(self):
self.base_url = "https://api.tripo3d.ai/v2/openapi"
async def _get_client(self) -> httpx.AsyncClient:
"""获取或创建异步客户端(懒加载)"""
self.async_client = httpx.AsyncClient(
timeout=httpx.Timeout(120.0), # 可根据需要调整
headers={
"Authorization": f"Bearer {settings.TRIPO_API_KEY}",
"Accept": "application/json"
},
limits=httpx.Limits(max_connections=20, max_keepalive_connections=10)
)
return self.async_client
async def request_json(self, method: str, endpoint: str, request_timeout: float, **kwargs) -> Dict[str, Any]:
"""异步请求核心方法 - 直接返回原始 resp成功或失败都不抛异常"""
url = f"{self.base_url}{endpoint}"
client = await self._get_client()
try:
resp = await client.request(method=method, url=url, timeout=request_timeout, **kwargs)
except httpx.RequestError as e:
# 网络层错误也包装成类似 API 的格式返回
return {
"code": -1,
"message": f"网络请求失败: {method} {url}",
"detail": str(e)
}
try:
return resp.json()
except Exception:
# 非 JSON 返回也包装返回
return {
"code": -2,
"message": f"响应不是合法 JSON: HTTP {resp.status_code}",
"raw": resp.text[:500] # 截取一部分避免过长
}
async def upload_image(self, image_path: str, request_timeout: float) -> str:
"""
从 MinIO 读取图片 → 直接上传到 Tripo3D
Args:
image_path: MinIO 中的完整路径,例如 "fida-public-bucket/furniture/sketches/xxx.png"
"user_123/images/test.png"
request_timeout: 请求超时时间
Returns:
str: Tripo3D 返回的 image_token
"""
try:
# 解析 bucket 和 object_name
bucket_name, object_name = image_path.split('/', 1)
print(f"从 MinIO 下载图片: {bucket_name}/{object_name}")
logger.info(f"从 MinIO 下载图片: {bucket_name}/{object_name}")
# 1. 从 MinIO 获取文件
response = minio_client.get_object(bucket_name=bucket_name, object_name=object_name)
# 2. 读取为 bytes关键修复点
data = response.read()
file_name = Path(object_name).name
content_type = get_mime_type(file_name)
# 3. 用 BytesIO 包装httpx 处理更稳定)
file_obj = io.BytesIO(data)
files = {
"file": (
file_name, # 文件名
file_obj, # BytesIO 对象
content_type
)
}
# 4. 异步上传
payload = await self.request_json(
"POST",
"/upload",
request_timeout=request_timeout,
files=files
)
data = payload.get("data") or {}
file_token = data.get("image_token")
if not file_token:
raise TripoAPIError(f"上传成功但未返回 image_token: {json.dumps(payload, ensure_ascii=False)}")
print(f"✅ 图片上传成功 | image_token: {file_token} | 文件: {file_name}")
logger.info(f"✅ 图片上传成功 | image_token: {file_token} | 文件: {file_name}")
return file_token
except Exception as e:
logger.error(f"上传图片失败 {image_path}: {e}")
raise
# ====================== 异步上传多张图片 ======================
async def upload_images(self, image_paths: List[str], request_timeout: float) -> List[str]:
"""
批量从 MinIO 上传多张图片到 Tripo3D
Args:
image_paths: MinIO 对象路径列表
request_timeout: 请求超时时间
Returns:
List[str]: Tripo3D 返回的 image_token 列表
"""
file_tokens = []
for idx, image_path in enumerate(image_paths, 1):
print(f" - 上传第 {idx}/{len(image_paths)} 张图片: {image_path}")
logger.info(f" - 上传第 {idx}/{len(image_paths)} 张图片: {image_path}")
token = await self.upload_image(
image_path=image_path,
request_timeout=request_timeout
)
file_tokens.append(token)
print(f"✅ 所有图片上传完成,共 {len(file_tokens)}")
logger.info(f"✅ 所有图片上传完成,共 {len(file_tokens)}")
return file_tokens
async def create_task(self, payload: Dict[str, Any], request_timeout: float) -> Dict[str, Any]:
"""
创建任务
- 成功时返回原始响应(包含 code: 0 和 data.task_id
- 失败时也返回原始响应并确保错误码code被带上
"""
resp = await self.request_json("POST", "/task", request_timeout=request_timeout, json=payload)
# 如果是成功响应(通常 code == 0直接返回
if isinstance(resp, dict) and resp.get("code") == 0:
return resp
# 失败情况:确保错误码存在,并返回完整响应(不抛异常)
if not isinstance(resp, dict):
resp = {"code": -3, "message": "未知错误", "raw_response": str(resp)}
# 如果响应中没有 code 字段,补充一个
if "code" not in resp:
resp["code"] = resp.get("error", {}).get("code") or -999
# 可选:统一加上一个更明显的错误标识(方便上层判断)
if resp.get("code") != 0:
resp.setdefault("success", False)
# 如果有 suggestion可以保留
if "suggestion" not in resp and isinstance(resp.get("error"), dict):
resp["suggestion"] = resp["error"].get("suggestion")
return resp
# step 3 查询任务状态
async def poll_task(self, task_id: str, poll_interval: float, poll_timeout: float, request_timeout: float, callback_url: str) -> Dict[str, Any]:
start = asyncio.get_running_loop().time()
last_line = ""
while True:
resp = await self.request_json("GET", f"/task/{task_id}", request_timeout=request_timeout)
data = resp.get("data") or {}
status = str(data.get("status", "unknown")).lower()
progress = data.get("progress", 0)
elapsed = asyncio.get_running_loop().time() - start
line = f"[状态] {status:<10} | [进度] {progress:>3}% | [已等待] {elapsed:>7.1f}s"
if line != last_line:
logger.info(line)
print(line)
last_line = line
if status == "success":
return resp
if status == "failed":
await notify_callback(callback_url=callback_url, task_id=task_id, status="failed", result={})
error_message = data.get("error_message") or extract_error_message(resp)
raise TripoAPIError(f"任务失败 | task_id={task_id} | {error_message}")
if elapsed > poll_timeout:
await notify_callback(callback_url=callback_url, task_id=task_id, status="failed", result={})
raise TimeoutError(f"轮询超时: 已等待 {elapsed:.1f}stask_id={task_id}")
await asyncio.sleep(poll_interval)
# step 4 上传结果
async def save_outputs(self, task_resp: Dict[str, Any], request_timeout: float, bucket_name: str, user_id: str):
data = task_resp.get("data") or {}
task_id = data.get("task_id", "unknown_task")
result = data.get("result") or {}
print("\n📥 开始异步处理并上传输出文件...")
logger.info("\n📥 开始异步处理并上传输出文件...")
outputs = {}
for key, value in result.items():
if not isinstance(value, dict) or 'url' not in value:
continue
url = value['url']
parsed = urlparse(url)
path = Path(parsed.path.split('?')[0])
ext = path.suffix.lower() or ".bin"
object_name = f"{user_id}/3d_result/{task_id}{ext}"
# 异步上传到 MinIO
await upload_file_to_minio_from_url_async(
url=url,
bucket_name=bucket_name,
object_name=object_name,
request_timeout=request_timeout
)
if value.get('type') == "glb":
outputs['glb_path'] = f"{bucket_name}/{object_name}"
elif value.get('type') == "webp":
outputs['glb_static_img_path'] = f"{bucket_name}/{object_name}"
else:
outputs[value.get('type', key)] = f"{bucket_name}/{object_name}"
# 异步分析 GLB 模型CPU密集型任务
if 'glb_path' in outputs:
glb_info = await analyze_mesh_async(outputs['glb_path'])
outputs['glb_info'] = glb_info
# outputs = {
# 'glb_path': 'test/3d_result/glb/aea689fd4ee14f53ac9ab0922f9fe5b3.glb',
# 'glb_static_img_path': 'test/3d_result/png/26a7fa7ca48641348847c1f4bca353db.png',
# 'glb_info': {'file_format': '.glb', 'vertex_count': 5275, 'centroid': [0.0044253334706297175, -0.01139796154609474, -0.06385942913980143], 'bounding_box_min': [-0.500163733959198, -0.18078294396400452, -0.29821905493736267], 'bounding_box_max': [0.49963313341140747, 0.17052923142910004, 0.3003925383090973], 'size': [0.9997968673706055, 0.35131217539310455, 0.59861159324646], 'size_ratio': [0.5127898063471029, 0.1801859040236737, 0.30702428962922335],
# 'size_ratio_percentage': [51.278980634710294, 18.01859040236737, 30.702428962922333]}}
return outputs
async def call_back_result(self, callback_url: str, result: Dict, task_id: str):
await notify_callback(
callback_url=callback_url,
task_id=task_id,
status="completed",
result=result,
)
return "ok"
async def upload_file_to_minio_from_url_async(url: str, bucket_name: str, object_name: str, request_timeout: float = 60.0, content_type: str = None):
"""
异步从 Tripo URL 下载文件并上传到 MinIO最终修复版
"""
try:
async with httpx.AsyncClient(timeout=request_timeout) as client:
async with client.stream("GET", url) as resp:
resp.raise_for_status()
# 正确方式:先读取所有内容为 bytes
data_bytes = await resp.aread()
if content_type is None:
content_type = get_mime_type(object_name)
# 关键修复:用 BytesIO 包装 bytes让它拥有 .read() 方法
file_obj = io.BytesIO(data_bytes)
logger.info(f"开始上传到 MinIO → {bucket_name}/{object_name} | 大小: {len(data_bytes):,} bytes")
# 上传到 MinIO
result = minio_client.put_object(
bucket_name=bucket_name,
object_name=object_name,
data=file_obj, # ← 必须传 BytesIO 或有 .read() 的对象
length=len(data_bytes),
content_type=content_type,
part_size=0 # 自动分片
)
logger.info(f"✅ 成功上传到 MinIO: {bucket_name}/{object_name}")
return result
except httpx.HTTPError as e:
raise TripoAPIError(f"下载 Tripo 文件失败: {url} | {e}") from e
except S3Error as e:
raise TripoAPIError(f"上传到 MinIO 失败: {bucket_name}/{object_name} | {e}") from e
except Exception as e:
raise TripoAPIError(f"上传过程异常 {url}: {e}") from e
async def analyze_mesh_async(image_path: str) -> Dict:
"""异步包装 analyze_meshCPU密集型"""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, analyze_mesh_sync, image_path)
def analyze_mesh_sync(image_path: str):
"""同步版本(供 executor 调用)"""
bucket_name, object_name = image_path.split('/', 1)
vertices = load_mesh_from_minio(bucket_name=bucket_name, object_name=object_name)
min_coords = vertices.min(axis=0)
max_coords = vertices.max(axis=0)
centroid = vertices.mean(axis=0)
size = max_coords - min_coords
total_size = np.sum(size)
size_ratio = size / total_size if total_size != 0 else np.zeros(3)
return {
"file_format": os.path.splitext(image_path)[1].lower(),
"vertex_count": len(vertices),
"centroid": centroid.tolist(),
"bounding_box_min": min_coords.tolist(),
"bounding_box_max": max_coords.tolist(),
"size": size.tolist(),
"size_ratio": size_ratio.tolist(),
"size_ratio_percentage": (size_ratio * 100).tolist()
}
def get_mime_type(path):
mime, _ = mimetypes.guess_type(str(path))
return mime or "application/octet-stream"
def extract_error_message(payload: Any) -> str:
if isinstance(payload, dict):
for key in ("message", "error", "error_message", "detail", "suggestion"):
if payload.get(key):
return str(payload[key])
data = payload.get("data")
if isinstance(data, dict):
for key in ("message", "error", "error_message", "detail", "suggestion"):
if data.get(key):
return str(data[key])
return json.dumps(payload, ensure_ascii=False)[:800]
return str(payload)[:800]
def iter_urls(obj: Any, prefix: str = "output") -> Iterator[Tuple[str, str]]:
if isinstance(obj, dict):
for k, v in obj.items():
yield from iter_urls(v, f"{prefix}.{k}")
elif isinstance(obj, list):
for i, v in enumerate(obj):
yield from iter_urls(v, f"{prefix}[{i}]")
elif isinstance(obj, str) and obj.startswith(("http://", "https://")):
yield prefix, obj
def upload_file_to_minio_from_url(session: requests.Session, url: str, bucket_name: str, object_name: str, request_timeout: float = 30.0, content_type: str = "application/octet-stream"):
"""
从 URL 下载文件流,直接上传到 MinIO不落地本地
"""
try:
with session.get(url, stream=True, timeout=request_timeout) as resp:
resp.raise_for_status()
# 获取文件大小(如果服务器返回 Content-Length
content_length = int(resp.headers.get('Content-Length', 0))
# 如果无法获取长度,可以设为 -1MinIO 会自动处理分块上传)
length = content_length if content_length > 0 else -1
# 直接把 response.raw 传给 put_object最推荐的流式方式
result = minio_client.put_object( # 假设你的 MinIO 客户端是 self.minio_client
bucket_name=bucket_name,
object_name=object_name,
data=resp.raw, # 关键:直接传 raw stream
length=length,
content_type=content_type,
part_size=0 # 0 表示让 MinIO 自动选择合适的分片大小
)
except requests.RequestException as e:
raise TripoAPIError(f"下载失败: {url} | {e}") from e
except S3Error as e:
raise TripoAPIError(f"上传到 MinIO 失败: {bucket_name}/{object_name} | {e}") from e
return result
def analyze_mesh(image_path: str):
# 加载模型顶点(直接从 MinIO
bucket_name, object_name = image_path.split('/', 1)
vertices = load_mesh_from_minio(bucket_name=bucket_name, object_name=object_name)
# 计算各项指标
min_coords = vertices.min(axis=0)
max_coords = vertices.max(axis=0)
centroid = vertices.mean(axis=0)
size = max_coords - min_coords
total_size = np.sum(size)
size_ratio = size / total_size if total_size != 0 else np.zeros(3)
info = {
"file_format": os.path.splitext(image_path)[1].lower(),
"vertex_count": len(vertices),
"centroid": centroid.tolist(),
"bounding_box_min": min_coords.tolist(),
"bounding_box_max": max_coords.tolist(),
"size": size.tolist(),
"size_ratio": size_ratio.tolist(),
"size_ratio_percentage": (size_ratio * 100).tolist()
}
return info
def load_mesh_from_minio(object_name: str, bucket_name: str = "fida-user"):
"""
从 MinIO 直接加载 .glb / .gltf / .obj 文件,返回顶点数组
"""
try:
# 从 MinIO 获取文件流
response = minio_client.get_object(bucket_name, object_name)
# 读取为 bytes 并包装成 BytesIO
data = response.read()
file_obj = io.BytesIO(data)
file_ext = os.path.splitext(object_name)[1].lower()
# 根据后缀加载模型
if file_ext in ('.glb', '.gltf'):
mesh = trimesh.load(file_obj, file_type='glb')
elif file_ext == '.obj':
mesh = trimesh.load(file_obj, file_type='obj')
else:
raise ValueError(f"不支持的文件格式: {file_ext},仅支持 .obj 和 .glb/.gltf")
except S3Error as e:
raise RuntimeError(f"从 MinIO 获取模型失败: {object_name} | {e}") from e
except Exception as e:
raise RuntimeError(f"加载模型失败: {object_name} | {e}") from e
# 处理 Scene 或单个 Mesh
if isinstance(mesh, trimesh.Scene):
vertices = np.vstack([geom.vertices for geom in mesh.geometry.values()])
else:
vertices = mesh.vertices
if len(vertices) == 0:
raise ValueError(f"模型中未找到顶点数据: {object_name}")
return vertices
async def create_single_task(input_data: Tripo3dApiModel):
"""
异步版本:创建单个图片转 3D 的任务
"""
server = Triop3dApiServer()
# Step 1: 上传图片(异步)
print(f"开始上传图片: {input_data.input_images[0]}")
logger.info(f"开始上传图片: {input_data.input_images[0]}")
file_token = await server.upload_image(
image_path=input_data.input_images[0],
request_timeout=input_data.request_timeout
)
print(f"✅ 图片上传成功file_token: {file_token}")
logger.info(f"✅ 图片上传成功file_token: {file_token}")
# Step 2: 构建请求 payload
file_ext = Path(input_data.input_images[0]).suffix.lower().lstrip('.') or "png"
if file_ext == "jpeg":
file_ext = "jpg"
input_payload = {
"type": "image_to_model",
"file": {
"type": file_ext,
"file_token": file_token,
}
}
# 合并用户传入的参数Pydantic Model 转 dict
payload = input_payload | input_data.model_dump(exclude_unset=True)
# Step 3: 提交任务(异步)
logger.info("正在提交 Tripo3D 任务...")
resp = await server.create_task(
payload=payload,
request_timeout=input_data.request_timeout
)
return resp
async def create_multi_task(input_data: Tripo3dApiModel):
"""
异步版本:创建多图转 3D 的任务
"""
server = Triop3dApiServer()
# Step 1: 上传多张图片(异步)
logger.info(f"开始上传 {len(input_data.input_images)} 张图片...")
print(f"开始上传 {len(input_data.input_images)} 张图片...")
file_tokens = await server.upload_images(
image_paths=input_data.input_images,
request_timeout=input_data.request_timeout
)
logger.info(f"✅ 图片上传完成,共 {len(file_tokens)} 个 token")
print(f"✅ 图片上传完成,共 {len(file_tokens)} 个 token")
# Step 2: 构建多图 payload
files = []
for image_path, file_token in zip(input_data.input_images, file_tokens):
file_ext = Path(image_path).suffix.lower().lstrip('.') or "png"
if file_ext == "jpeg":
file_ext = "jpg"
files.append({
"type": file_ext,
"file_token": file_token,
})
while len(files) < 4:
files.append({})
if len(files) > 4:
files = files[:4]
payload: Dict[str, Any] = {
"type": "multiview_to_model",
"model_version": input_data.model_version,
"files": files,
"face_limit": 2000,
"texture": input_data.texture,
"pbr": input_data.pbr,
}
# Step 3: 提交任务(异步)
logger.info(f"正在提交多图 Tripo3D 任务...{payload}")
print(f"正在提交多图 Tripo3D 任务...{payload}")
resp = await server.create_task(payload=payload, request_timeout=input_data.request_timeout)
return resp
async def get_task_result_async(input_data: Tripo3dApiModel, task_id: str, api_task_id: str, callback_url: str):
server = Triop3dApiServer()
task_resp = await server.poll_task(
task_id=api_task_id,
poll_interval=input_data.poll_interval,
poll_timeout=input_data.poll_timeout,
request_timeout=input_data.request_timeout,
callback_url=callback_url
)
outputs = await server.save_outputs(
task_resp=task_resp,
request_timeout=input_data.request_timeout,
bucket_name=input_data.bucket_name,
user_id=input_data.user_id
)
print(f"tripo3d 任务处理完成 | api_task_id: {api_task_id} | status: success")
logger.info(f"tripo3d 任务处理完成 | api_task_id: {api_task_id} | status: success")
await server.call_back_result(callback_url, outputs, task_id)
async def single_img_to_model_async(input_data: Tripo3dApiModel):
"""
完整的单图转 3D 异步流程
"""
try:
# Step 1: 创建任务
task_id = await create_single_task(input_data)
# Step 2: 轮询任务状态 + 处理输出 + 回调
await get_task_result_async(input_data, task_id, input_data.callback_url)
return task_id
except Exception as e:
logger.error(f"单图转 3D 任务失败 | error: {e}", exc_info=True)
# 可在此处调用失败回调
await notify_callback(
callback_url=input_data.callback_url,
task_id="unknown",
status="failed",
result={"error": str(e)}
)
raise
async def multi_img_to_model_async(input_data: Tripo3dApiModel):
"""
完整的多图转 3D 异步流程
"""
try:
# Step 1: 创建多图任务
task_id = await create_multi_task(input_data)
# Step 2: 轮询任务 + 处理输出 + 回调
await get_task_result_async(input_data, task_id, input_data.callback_url)
except Exception as e:
logger.error(f"多图转 3D 任务失败 | error: {e}", exc_info=True)
# 失败回调
await notify_callback(
callback_url=input_data.callback_url,
task_id="unknown",
status="failed",
result={"error": str(e)}
)
raise
if __name__ == '__main__':
# input_data = Tripo3dApiModel(input_images=['test/img_to_3d_data/example_multi_image/mushroom_1.png'], bucket_name='test', user_id='test', callback_url="http://18.167.251.121:10015/api/image/webhook/img-to-3d")
# asyncio.run(single_img_to_model_async(input_data))
input_data = Tripo3dApiModel(
input_images=['test/img_to_3d_data/example_multi_image/mushroom_3.png', 'test/img_to_3d_data/example_multi_image/mushroom_2.png', 'test/img_to_3d_data/example_multi_image/mushroom_1.png'],
bucket_name='test', user_id='test', callback_url="http://18.167.251.121:10015/api/image/webhook/img-to-3d",
face_limit=4000
)
asyncio.run(multi_img_to_model_async(input_data))