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}s,task_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_mesh(CPU密集型)""" 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)) # 如果无法获取长度,可以设为 -1(MinIO 会自动处理分块上传) 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))