From 6f4a0cc80c587b91cade764a3a58f5dac473b719 Mon Sep 17 00:00:00 2001 From: zcr Date: Mon, 13 Apr 2026 12:11:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9Eimg=20=E8=BD=AC=203D=20api?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 12 +- src/core/config.py | 1 + src/routers/generate_3D.py | 369 ++++++---- src/schemas/generate_3D.py | 55 +- src/server/canvas_generate_3D/server.py | 8 +- src/server/canvas_generate_3D/tasks.py | 7 +- src/server/canvas_generate_3D/triop3d_api.py | 474 +++++++++++++ .../canvas_generate_3D/triop3d_api_server.py | 633 ++++++++++++++++++ 8 files changed, 1383 insertions(+), 176 deletions(-) create mode 100644 src/server/canvas_generate_3D/triop3d_api.py create mode 100644 src/server/canvas_generate_3D/triop3d_api_server.py diff --git a/docker-compose.yml b/docker-compose.yml index 1986a9c..27de333 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,13 +28,7 @@ services: dockerfile: Dockerfile working_dir: /app command: > - celery -A src.server.canvas_generate_3D.celery_app worker - -n celery_worker@%h - -Q img_to_3d_queue,three_d_to_3views_queue - --concurrency=1 - --prefetch-multiplier=1 - --max-tasks-per-child=1 - --loglevel=INFO + celery -A src.server.canvas_generate_3D.celery_app worker -n celery_worker@%h -Q img_to_3d_queue,three_d_to_3views_queue --concurrency=1 --prefetch-multiplier=1 --max-tasks-per-child=1 --loglevel=INFO volumes: - ./:/app - ./.env:/app/.env @@ -67,4 +61,6 @@ networks: # restart: unless-stopped # volumes: -# rabbitmq_data: \ No newline at end of file +# rabbitmq_data: + + diff --git a/src/core/config.py b/src/core/config.py index ef36fae..b47a16e 100755 --- a/src/core/config.py +++ b/src/core/config.py @@ -47,6 +47,7 @@ class Settings(BaseSettings): # --- 外部工具api配置信息 --- TAVILY_API_KEY: str = Field(default="", description="") + TRIPO_API_KEY: str = Field(default="", description="") LOGS_PATH: str = Field(default="/mnt/data/FiDA/logs", description="") diff --git a/src/routers/generate_3D.py b/src/routers/generate_3D.py index a8ad3af..4673fea 100755 --- a/src/routers/generate_3D.py +++ b/src/routers/generate_3D.py @@ -3,15 +3,13 @@ import json import logging import httpx -import requests -import uuid -from fastapi import APIRouter +from fastapi import APIRouter, BackgroundTasks from src.core.config import settings -from src.schemas.generate_3D import ImageTo3DRequest, ToSVGRequest +from src.schemas.generate_3D import ImageTo3DRequest, ToSVGRequest, Tripo3dApiModel from src.schemas.response_template import ResponseModel from src.server.canvas_generate_3D.server import submit_img_to_3d_task, submit_three_d_to_3views_task -from src.server.canvas_generate_3D.tasks import img_to_3d_task +from src.server.canvas_generate_3D.triop3d_api_server import create_single_task, create_multi_task, get_task_result_async, single_img_to_model_async router = APIRouter(prefix="/canvas", tags=["Furniture Canvas"]) logger = logging.getLogger(__name__) @@ -19,145 +17,8 @@ logger = logging.getLogger(__name__) img_to_3d_semaphore = asyncio.Semaphore(1) -@router.post("/img_to_3D") -async def img_to_3D(request_data: ImageTo3DRequest): - """ - ### 参数说明: - - **input_images**:输入图片list,单张或多张 - - **model**: 推理模式,单张或多张 - ### 请求体示例: - ```json - 单张 - { - "input_images": ["test/img_to_3d_data/example_multi_image/character_1.png"], - "model": "single" - } - - 多张 - { - "input_imaes": [ - "test/img_to_3d_data/example_multi_image/character_1.png", - "test/img_to_3d_data/example_multi_image/character_2.png", - "test/img_to_3d_data/example_multi_image/character_3.png" - - ], - "model": "multi" - } - ``` - ### 输出示例: - ```json - { - "glb_path": "test/3d_result/glb/5ebe2fe118c94946bdc379e4d44799d2.glb", - "glb_static_img_path": "test/3d_result/png/19c4b60ab7594e3f84e58d0169739bd1.png", - "glb_info": { - "file_format": ".glb", - "vertex_count": 7312, - "centroid": [ - 0.0010040254158151611, - -0.10831894948487081, - 0.07473365460649548 - ], - "bounding_box_min": [ - -0.23948338627815247, - -0.38543057441711426, - -0.5015472769737244 - ], - "bounding_box_max": [ - 0.228701651096344, - 0.37523990869522095, - 0.49702101945877075 - ], - "size": [ - 0.46818503737449646, - 0.7606704831123352, - 0.9985682964324951 - ], - "size_ratio": [ - 0.21019126841430072, - 0.34150235681882596, - 0.4483063747668733 - ], - "size_ratio_percentage": [ - 21.019126841430072, - 34.1502356818826, - 44.83063747668733 - ] - } - } - ``` - """ - try: - logger.info( - f"img_to_3D request: {json.dumps(request_data.dict(), indent=4)}" - ) - - input_data = { - "image_paths": request_data.input_images, - "model": request_data.model, - } - - async with httpx.AsyncClient(timeout=120) as client: - resp = await client.post( - f"http://{settings.IMAGE_TO_3D_MODEL_URL}/canvas/img_to_3D", - json=input_data - ) - - result = resp.json() - - logger.info(f"img_to_3D response: {json.dumps(result, indent=4)}") - - return ResponseModel(data=result) - - except Exception as e: - logger.warning(f"img_to_3D Run Exception: {e}") - - -@router.post("/3d_to_3views") -async def to_3views(request_data: ToSVGRequest): - """ - ### 参数说明: - - **minio_glb_path**:glb文件路径 - - ### 请求体示例: - ```json - { - "minio_glb_path": "test/3d_result/glb/543570111d344552b080ff6f875e4e83.glb" - } - ``` - ### 输出示例: - ```json - { - "minio_svg_path": "test/3d_result/svg/bbcd534cffa143bba418148a0db80ad0.svg" - } - ``` - """ - try: - logger.info( - f"img_to_3D request: {json.dumps(request_data.dict(), indent=4)}" - ) - - input_data = { - "minio_glb_path": request_data.minio_glb_path, - } - - async with httpx.AsyncClient(timeout=120) as client: - resp = await client.post( - f"http://{settings.IMAGE_TO_3D_MODEL_URL}/canvas/3d_to_3views", - json=input_data - ) - - result = resp.json() - - logger.info(f"img_to_3D response: {json.dumps(result, indent=4)}") - - return ResponseModel(data=result) - - except Exception as e: - logger.warning(f"img_to_3D Run Exception: {e}") - - @router.post("/img_to_3D_v2") -async def img_to_3d_endpoint(request_data: ImageTo3DRequest): +async def img_to_3D_v2(request_data: ImageTo3DRequest): """ ### 接口说明: 将图片转换为3D模型(异步处理)。接口接收请求后立即返回任务ID,后台通过 Celery 处理,处理完成后结果会通过 RabbitMQ 发送。 @@ -173,6 +34,8 @@ async def img_to_3d_endpoint(request_data: ImageTo3DRequest): "input_images": [ "test/img_to_3d_data/example_multi_image/character_1.png" ], + "bucket_name": "test", + "user_id": "123", "model": "single", "task_id": "123", "callback_url": "https://example.com/" @@ -186,6 +49,8 @@ async def img_to_3d_endpoint(request_data: ImageTo3DRequest): "test/img_to_3d_data/example_multi_image/character_2.png", "test/img_to_3d_data/example_multi_image/character_3.png" ], + "bucket_name": "test", + "user_id": "123", "model": "multi", "task_id": "123", "callback_url": "https://example.com/" @@ -231,7 +96,11 @@ async def img_to_3d_endpoint(request_data: ImageTo3DRequest): ``` """ logger.info(f"img_to_3D_v2 request: {json.dumps(request_data.model_dump(), indent=4)}") - result = submit_img_to_3d_task(input_images=request_data.input_images, model=request_data.model, task_id=request_data.task_id, callback_url=request_data.callback_url) + result = submit_img_to_3d_task( + input_images=request_data.input_images, model=request_data.model, + task_id=request_data.task_id, callback_url=request_data.callback_url, + bucket_name=request_data.bucket_name, user_id=request_data.user_id + ) if result.get("state") == "success": state_code = 200 elif result.get("state") == "queue_full": @@ -243,7 +112,7 @@ async def img_to_3d_endpoint(request_data: ImageTo3DRequest): @router.post("/3d_to_3views_v2") -async def to_3views(request_data: ToSVGRequest): +async def model_to_3views_v2(request_data: ToSVGRequest): """ ### 接口说明: 将 GLB 3D 模型文件转换为 3 个视图图片(3-views),异步处理。 @@ -254,7 +123,9 @@ async def to_3views(request_data: ToSVGRequest): ### 请求体示例: ```json { - "minio_glb_path": "test/3d_result/glb/543570111d344552b080ff6f875e4e83.glb" + "minio_glb_path": "test/3d_result/glb/543570111d344552b080ff6f875e4e83.glb", + "bucket_name": "test", + "user_id": "123" } ``` @@ -306,3 +177,209 @@ async def to_3views(request_data: ToSVGRequest): state_code = 500 return ResponseModel(data=result, code=state_code) + + +@router.post("/triop_api_img_to_3D") +async def triop_api_img_to_3D(request_data: Tripo3dApiModel, background_tasks: BackgroundTasks): + """ + ### 接口说明: + 将图片转换为3D模型(异步处理)。接口接收请求后立即返回任务ID,后台通过 Celery 处理,处理完成后结果会通过 RabbitMQ 发送。 + + ### 参数说明: + - **input_images**: 输入图片路径列表(支持单张或多张) + - **model**: 推理模式,`single` 表示单张图片,`multi` 表示多张图片融合 + + ### 请求体示例: + **单张图片模式:** + ```json + { + "input_images": [ + "test/img_to_3d_data/example_multi_image/character_1.png" + ], + "bucket_name": "test", + "user_id": "123", + "model": "single", + "user_id": "123", + "model": "single", + "callback_url": "http://18.167.251.121:10015/api/image/webhook/img-to-3d" + } + ``` + **多张图片模式:** + ```json + { + "input_images": [ + "test/img_to_3d_data/example_multi_image/character_1.png", + "test/img_to_3d_data/example_multi_image/character_2.png", + "test/img_to_3d_data/example_multi_image/character_3.png" + ], + "bucket_name": "test", + "user_id": "123", + "model": "multi", + "task_id": "123", + "callback_url": "http://18.167.251.121:10015/api/image/webhook/img-to-3d" + } + ``` + + ### 输出示例: + ```json + { + "code": 200, + "msg": "OK!", + "data": { + "state": "success", + "task_id": "8cb65855-93de-496f-95a0-d667826ad129", + "message": "任务已成功提交,正在后台处理..." + } + } + ``` + """ + logger.info(f"img_to_3D_v2 request: {json.dumps(request_data.model_dump(), indent=4)}") + if request_data.model == "single": + api_task_id = await create_single_task(input_data=request_data) + else: + api_task_id = await create_multi_task(input_data=request_data) + + background_tasks.add_task(get_task_result_async, request_data, request_data.task_id, api_task_id, request_data.callback_url) + result = { + "state": "success", + "task_id": request_data.task_id, + "message": "任务已成功提交,正在后台处理...", + } + state_code = 200 + return ResponseModel(data=result, code=state_code) + +# @router.post("/img_to_3D") +# async def img_to_3D(request_data: ImageTo3DRequest): +# """ +# ### 参数说明: +# - **input_images**:输入图片list,单张或多张 +# - **model**: 推理模式,单张或多张 +# ### 请求体示例: +# ```json +# 单张 +# { +# "input_images": ["test/img_to_3d_data/example_multi_image/character_1.png"], +# "model": "single" +# } +# +# 多张 +# { +# "input_imaes": [ +# "test/img_to_3d_data/example_multi_image/character_1.png", +# "test/img_to_3d_data/example_multi_image/character_2.png", +# "test/img_to_3d_data/example_multi_image/character_3.png" +# +# ], +# "model": "multi" +# } +# ``` +# ### 输出示例: +# ```json +# { +# "glb_path": "test/3d_result/glb/5ebe2fe118c94946bdc379e4d44799d2.glb", +# "glb_static_img_path": "test/3d_result/png/19c4b60ab7594e3f84e58d0169739bd1.png", +# "glb_info": { +# "file_format": ".glb", +# "vertex_count": 7312, +# "centroid": [ +# 0.0010040254158151611, +# -0.10831894948487081, +# 0.07473365460649548 +# ], +# "bounding_box_min": [ +# -0.23948338627815247, +# -0.38543057441711426, +# -0.5015472769737244 +# ], +# "bounding_box_max": [ +# 0.228701651096344, +# 0.37523990869522095, +# 0.49702101945877075 +# ], +# "size": [ +# 0.46818503737449646, +# 0.7606704831123352, +# 0.9985682964324951 +# ], +# "size_ratio": [ +# 0.21019126841430072, +# 0.34150235681882596, +# 0.4483063747668733 +# ], +# "size_ratio_percentage": [ +# 21.019126841430072, +# 34.1502356818826, +# 44.83063747668733 +# ] +# } +# } +# ``` +# """ +# try: +# logger.info( +# f"img_to_3D request: {json.dumps(request_data.dict(), indent=4)}" +# ) +# +# input_data = { +# "image_paths": request_data.input_images, +# "model": request_data.model, +# } +# +# async with httpx.AsyncClient(timeout=120) as client: +# resp = await client.post( +# f"http://{settings.IMAGE_TO_3D_MODEL_URL}/canvas/img_to_3D", +# json=input_data +# ) +# +# result = resp.json() +# +# logger.info(f"img_to_3D response: {json.dumps(result, indent=4)}") +# +# return ResponseModel(data=result) +# +# except Exception as e: +# logger.warning(f"img_to_3D Run Exception: {e}") +# +# +# @router.post("/3d_to_3views") +# async def to_3views(request_data: ToSVGRequest): +# """ +# ### 参数说明: +# - **minio_glb_path**:glb文件路径 +# +# ### 请求体示例: +# ```json +# { +# "minio_glb_path": "test/3d_result/glb/543570111d344552b080ff6f875e4e83.glb" +# } +# ``` +# ### 输出示例: +# ```json +# { +# "minio_svg_path": "test/3d_result/svg/bbcd534cffa143bba418148a0db80ad0.svg" +# } +# ``` +# """ +# try: +# logger.info( +# f"img_to_3D request: {json.dumps(request_data.dict(), indent=4)}" +# ) +# +# input_data = { +# "minio_glb_path": request_data.minio_glb_path, +# } +# +# async with httpx.AsyncClient(timeout=120) as client: +# resp = await client.post( +# f"http://{settings.IMAGE_TO_3D_MODEL_URL}/canvas/3d_to_3views", +# json=input_data +# ) +# +# result = resp.json() +# +# logger.info(f"img_to_3D response: {json.dumps(result, indent=4)}") +# +# return ResponseModel(data=result) +# +# except Exception as e: +# logger.warning(f"img_to_3D Run Exception: {e}") diff --git a/src/schemas/generate_3D.py b/src/schemas/generate_3D.py index a48def0..98e4950 100755 --- a/src/schemas/generate_3D.py +++ b/src/schemas/generate_3D.py @@ -3,25 +3,48 @@ from typing import Optional, List, Dict, Any class ImageTo3DRequest(BaseModel): - input_images: List[str] = Field( - ..., - description="输入图片路径列表" - ) - - model: str = Field( - default="single", - description="模型类型: single 或 multi" - ) - task_id: str = Field( - ... - ) + input_images: List[str] = Field(..., description="输入图片路径列表") + model: str = Field(default="single", description="模型类型: single 或 multi") + bucket_name: str = Field(..., description="输入图片路径列表") + user_id: str = Field(..., description="用户id") + task_id: str = Field(...) callback_url: str # 必填,客户端提供的回调地址 class ToSVGRequest(BaseModel): - minio_glb_path: str = Field( - ..., - description="输入图片路径列表" - ) + minio_glb_path: str = Field(..., description="输入图片路径列表") + bucket_name: str = Field(..., description="输入图片路径列表") + user_id: str = Field(..., description="用户id") task_id: str = Field(...) callback_url: str # 必填 + + +class Tripo3dApiModel(BaseModel): + input_images: List[str] = Field(..., description="输入图片路径列表") + bucket_name: str = Field(..., description="输入图片路径列表") + user_id: str = Field(..., description="用户id") + callback_url: str # 必填,客户端提供的回调地址 + task_id: str = Field() + model: str = Field(default="single", description="模型类型: single 或 multi") + + model_version: Optional[str] = Field(default="v3.1-20260211", description="Model version, e.g. v3.1-20260211 / v3.0-20250812 / v2.5-20250123") + poll_interval: Optional[float] = Field(default=2.0, description="Polling interval (seconds)") + poll_timeout: Optional[float] = Field(default=1800.0, description="Max polling time (seconds)") + request_timeout: Optional[float] = Field(default=120.0, description="HTTP request timeout (seconds)") + texture: Optional[bool] = Field(default=True, description="是否生成纹理") + pbr: Optional[bool] = Field(default=True, description="是否生成 PBR 材质") + texture_quality: Optional[str] = Field(default="standard", description="Texture quality: standard / detailed") + texture_alignment: Optional[str] = Field(default="original_image", description="Texture alignment mode: original_image / geometry") + orientation: Optional[str] = Field(default="default", description="Orientation mode: default / align_image") + face_limit: Optional[int] = Field(default=None, description="限制输出模型的面数") + model_seed: Optional[int] = Field(default=None, description="模型生成随机种子") + texture_seed: Optional[int] = Field(default=None, description="纹理生成随机种子") + auto_size: Optional[str] = Field(default=None, description="Auto size option") + quad: Optional[str] = Field(default=None, description="Enable quad remeshing") + compress: Optional[str] = Field(default=None, description="Compress option") + generate_parts: Optional[str] = Field(default=None, description="Generate segmented parts") + smart_low_poly: Optional[str] = Field(default=None, description="Smart low poly optimization") + download_outputs: Optional[bool] = Field(default=True, description="是否下载输出文件(现在改为上传到 MinIO)") + save_task_json: Optional[bool] = Field(default=True, description="是否保存 task JSON") + print_payload: Optional[bool] = Field(default=False, description="是否打印请求 payload") + print_output: Optional[bool] = Field(default=True, description="是否打印输出结果") diff --git a/src/server/canvas_generate_3D/server.py b/src/server/canvas_generate_3D/server.py index 6303648..73c3f28 100755 --- a/src/server/canvas_generate_3D/server.py +++ b/src/server/canvas_generate_3D/server.py @@ -24,7 +24,7 @@ def get_queue_length(queue_name: str) -> int: return 0 # 失败时默认不拒绝提交,防止误判 -def submit_img_to_3d_task(input_images: list, model: str = "single", task_id: str = "", callback_url: str = ""): +def submit_img_to_3d_task(input_images: list, model: str = "single", task_id: str = "", callback_url: str = "", bucket_name: str = "test", user_id: str = "123"): """提交 img_to_3D 任务(带队列长度限制)""" queue_name = "img_to_3d_queue" max_queue_length = 10 @@ -42,7 +42,7 @@ def submit_img_to_3d_task(input_images: list, model: str = "single", task_id: st # 提交任务 task = img_to_3d_task.apply_async( - args=(input_images, model, callback_url), + args=(input_images, model, callback_url, bucket_name, user_id), task_id=task_id, queue="img_to_3d_queue") @@ -63,7 +63,7 @@ def submit_img_to_3d_task(input_images: list, model: str = "single", task_id: st } -def submit_three_d_to_3views_task(minio_glb_path: str, task_id: str = "", callback_url: str = ""): +def submit_three_d_to_3views_task(minio_glb_path: str, task_id: str = "", callback_url: str = "", bucket_name: str = "test", user_id: str = "123"): """提交 3D转 3 视图 任务(带队列长度限制)""" queue_name = "three_d_to_3views_task" # ← 必须和 @shared_task 中的 queue 完全一致! max_queue_length = 3 @@ -80,7 +80,7 @@ def submit_three_d_to_3views_task(minio_glb_path: str, task_id: str = "", callba } task = three_d_to_3views_task.apply_async( - args=(minio_glb_path, callback_url), + args=(minio_glb_path, callback_url, bucket_name, user_id), task_id=task_id, queue="three_d_to_3views_queue") diff --git a/src/server/canvas_generate_3D/tasks.py b/src/server/canvas_generate_3D/tasks.py index 384f8a9..3f03174 100755 --- a/src/server/canvas_generate_3D/tasks.py +++ b/src/server/canvas_generate_3D/tasks.py @@ -11,7 +11,7 @@ logger = logging.getLogger(__name__) @shared_task(bind=True, queue="img_to_3d_queue", max_retries=3, name='src.server.canvas_generate_3D.tasks.img_to_3d_task') -def img_to_3d_task(self, input_images: list, model: str = "single", callback_url: str = None): +def img_to_3d_task(self, input_images: list, model: str = "single", callback_url: str = None, bucket_name: str = "test", user_id: str = "123"): """img_to_3D 主任务""" task_id = self.request.id logger.info(f"开始处理 img_to_3D 任务 | task_id: {task_id}") @@ -19,6 +19,8 @@ def img_to_3d_task(self, input_images: list, model: str = "single", callback_url input_data = { "image_paths": input_images, "model": model, + "bucket_name": bucket_name, + "user_id": user_id } with httpx.Client(timeout=300.0) as client: resp = client.post( @@ -63,13 +65,14 @@ def img_to_3d_task(self, input_images: list, model: str = "single", callback_url @shared_task(bind=True, queue="three_d_to_3views_queue", max_retries=3, name='src.server.canvas_generate_3D.tasks.three_d_to_3views_task') -def three_d_to_3views_task(self, minio_glb_path: str, callback_url: str): +def three_d_to_3views_task(self, minio_glb_path: str, callback_url: str, bucket_name: str = "test", user_id: str = "123"): """3D to 3views 主任务""" task_id = self.request.id logger.info(f"开始处理 three_d_to_3views_task | task_id: {task_id}") try: input_data = { "minio_glb_path": minio_glb_path, + "bucket_name": bucket_name, "user_id": user_id } with httpx.Client(timeout=1200) as client: resp = client.post( diff --git a/src/server/canvas_generate_3D/triop3d_api.py b/src/server/canvas_generate_3D/triop3d_api.py new file mode 100644 index 0000000..cea768c --- /dev/null +++ b/src/server/canvas_generate_3D/triop3d_api.py @@ -0,0 +1,474 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import re +import sys +import json +import time +import argparse +import mimetypes +from pathlib import Path +from typing import Any, Dict, Iterator, Tuple +from urllib.parse import urlparse + +import requests + +BASE_URL = "https://api.tripo3d.ai/v2/openapi" + + +class TripoAPIError(RuntimeError): + pass + + +def build_parser(): + p = argparse.ArgumentParser("Tripo3D CLI: single image -> 3D") + + # I/O + p.add_argument("-i", "--image", required=True, help="Input image path") + p.add_argument("-o", "--out_dir", default="tripo_outputs", help="Output directory") + + # Auth + p.add_argument( + "--api_key", + default=os.getenv("TRIPO_API_KEY", "tcli_50ecbff125084d4db958b1863ec082e6"), + help="Tripo API key, or set env TRIPO_API_KEY", + ) + + # Model + p.add_argument( + "--model_version", + type=str, + default="v3.1-20260211", + help="Model version, e.g. P1-20260311 / v3.1-20260211 / v3.0-20250812 / v2.5-20250123", + ) + + # Network / polling + p.add_argument("--poll_interval", type=float, default=2.0, help="Polling interval (seconds)") + p.add_argument("--poll_timeout", type=float, default=1800.0, help="Max polling time (seconds)") + p.add_argument("--request_timeout", type=float, default=120.0, help="HTTP request timeout (seconds)") + + # Generation options + p.add_argument("--texture", dest="texture", action="store_true", default=True) + p.add_argument("--no-texture", dest="texture", action="store_false") + + p.add_argument("--pbr", dest="pbr", action="store_true", default=True) + p.add_argument("--no-pbr", dest="pbr", action="store_false") + + p.add_argument( + "--texture_quality", + type=str, + default="standard", + choices=["standard", "detailed"], + help="Texture quality", + ) + p.add_argument( + "--texture_alignment", + type=str, + default="original_image", + choices=["original_image", "geometry"], + help="Texture alignment mode", + ) + p.add_argument( + "--orientation", + type=str, + default="default", + choices=["default", "align_image"], + help="Orientation mode", + ) + + # Optional params + p.add_argument("--face_limit", type=int, default=None) + p.add_argument("--model_seed", type=int, default=None) + p.add_argument("--texture_seed", type=int, default=None) + p.add_argument("--auto_size", type=str, default=None) + p.add_argument("--quad", type=str, default=None) + p.add_argument("--compress", type=str, default=None) + p.add_argument("--generate_parts", type=str, default=None) + p.add_argument("--smart_low_poly", type=str, default=None) + + # Save / download toggles + p.add_argument("--download_outputs", dest="download_outputs", action="store_true", default=True) + p.add_argument("--no-download_outputs", dest="download_outputs", action="store_false") + + p.add_argument("--save_task_json", dest="save_task_json", action="store_true", default=True) + p.add_argument("--no-save_task_json", dest="save_task_json", action="store_false") + + p.add_argument("--print_payload", dest="print_payload", action="store_true", default=False) + p.add_argument("--print_output", dest="print_output", action="store_true", default=True) + p.add_argument("--no-print_output", dest="print_output", action="store_false") + + return p + + +def guess_mime_type(file_path: Path) -> str: + mime, _ = mimetypes.guess_type(str(file_path)) + return mime or "application/octet-stream" + + +def safe_filename(name: str) -> str: + name = re.sub(r'[\\/:*?"<>|]+', "_", name) + name = re.sub(r"\s+", "_", name).strip("._") + return name or "file" + + +def extract_error_message(payload: Any) -> str: + if isinstance(payload, dict): + for key in ("message", "error", "error_message", "detail"): + if payload.get(key): + return str(payload[key]) + + data = payload.get("data") + if isinstance(data, dict): + for key in ("message", "error", "error_message", "detail"): + if data.get(key): + return str(data[key]) + + return json.dumps(payload, ensure_ascii=False)[:800] + + return str(payload)[:800] + + +def request_json( + session: requests.Session, + method: str, + endpoint: str, + request_timeout: float, + **kwargs, +) -> Dict[str, Any]: + url = f"{BASE_URL}{endpoint}" + + try: + resp = session.request(method=method, url=url, timeout=request_timeout, **kwargs) + except requests.RequestException as e: + raise TripoAPIError(f"请求失败: {method} {url} | {e}") from e + + if not resp.ok: + try: + err_payload = resp.json() + except Exception: + err_payload = resp.text + raise TripoAPIError( + f"HTTP {resp.status_code} | {method} {url} | {extract_error_message(err_payload)}" + ) + + try: + payload = resp.json() + except Exception as e: + raise TripoAPIError( + f"响应不是合法 JSON: {method} {url}\n原始响应前 500 字符:\n{resp.text[:500]}" + ) from e + + return payload + + +def create_session(api_key: str) -> requests.Session: + session = requests.Session() + session.headers.update({ + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + }) + return session + + +def upload_image(session: requests.Session, image_path: Path, request_timeout: float) -> str: + if not image_path.exists(): + raise FileNotFoundError(f"找不到图片: {image_path}") + + with image_path.open("rb") as f: + files = { + "file": (image_path.name, f, guess_mime_type(image_path)) + } + payload = request_json( + session, + "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)}") + + return file_token + + +def build_generation_payload(args, file_token: str, image_path: Path) -> Dict[str, Any]: + file_ext = image_path.suffix.lower().lstrip(".") or "png" + + payload: Dict[str, Any] = { + "type": "image_to_model", + "model_version": args.model_version, + "file": { + "type": file_ext, + "file_token": file_token, + }, + "texture": args.texture, + "pbr": args.pbr, + "texture_quality": args.texture_quality, + "texture_alignment": args.texture_alignment, + "orientation": args.orientation, + } + + optional_fields = [ + "face_limit", + "model_seed", + "texture_seed", + "auto_size", + "quad", + "compress", + "generate_parts", + "smart_low_poly", + ] + + for key in optional_fields: + value = getattr(args, key) + if value is not None: + payload[key] = value + + return payload + + +def create_task(session: requests.Session, payload: Dict[str, Any], request_timeout: float) -> str: + resp = request_json( + session, + "POST", + "/task", + request_timeout=request_timeout, + json=payload, + ) + data = resp.get("data") or {} + task_id = data.get("task_id") + + if not task_id: + raise TripoAPIError(f"提交任务成功但未返回 task_id: {json.dumps(resp, ensure_ascii=False)}") + + return task_id + + +def get_task(session: requests.Session, task_id: str, request_timeout: float) -> Dict[str, Any]: + return request_json( + session, + "GET", + f"/task/{task_id}", + request_timeout=request_timeout, + ) + + +def poll_task( + session: requests.Session, + task_id: str, + poll_interval: float, + poll_timeout: float, + request_timeout: float, +) -> Dict[str, Any]: + start = time.perf_counter() + last_line = "" + + while True: + resp = get_task(session, task_id, request_timeout=request_timeout) + data = resp.get("data") or {} + + status = str(data.get("status", "unknown")).lower() + progress = data.get("progress", 0) + elapsed = time.perf_counter() - start + + line = f"\r[状态] {status:<10} | [进度] {progress:>3}% | [已等待] {elapsed:>7.1f}s" + if line != last_line: + sys.stdout.write(line) + sys.stdout.flush() + last_line = line + + if status == "success": + sys.stdout.write("\n") + sys.stdout.flush() + return resp + + if status == "failed": + sys.stdout.write("\n") + sys.stdout.flush() + error_message = data.get("error_message") or extract_error_message(resp) + raise TripoAPIError(f"任务失败 | task_id={task_id} | {error_message}") + + if elapsed > poll_timeout: + sys.stdout.write("\n") + sys.stdout.flush() + raise TimeoutError(f"轮询超时: 已等待 {elapsed:.1f}s,task_id={task_id}") + + time.sleep(poll_interval) + + +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 infer_extension_from_url(url: str) -> str: + path = urlparse(url).path + ext = Path(path).suffix + return ext if ext else ".bin" + + +def unique_path(path: Path) -> Path: + if not path.exists(): + return path + + stem = path.stem + suffix = path.suffix + parent = path.parent + i = 1 + while True: + candidate = parent / f"{stem}_{i}{suffix}" + if not candidate.exists(): + return candidate + i += 1 + + +def download_file(session: requests.Session, url: str, save_path: Path, request_timeout: float) -> None: + try: + with session.get(url, stream=True, timeout=request_timeout) as resp: + resp.raise_for_status() + with save_path.open("wb") as f: + for chunk in resp.iter_content(chunk_size=1024 * 1024): + if chunk: + f.write(chunk) + except requests.RequestException as e: + raise TripoAPIError(f"下载失败: {url} | {e}") from e + + +def save_outputs( + session: requests.Session, + task_resp: Dict[str, Any], + out_dir: Path, + request_timeout: float, + save_task_json: bool = True, + download_outputs: bool = True, +) -> None: + out_dir.mkdir(parents=True, exist_ok=True) + + data = task_resp.get("data") or {} + task_id = data.get("task_id", "unknown_task") + output = data.get("output") or {} + + if save_task_json: + meta_path = out_dir / f"{safe_filename(task_id)}.json" + with meta_path.open("w", encoding="utf-8") as f: + json.dump(task_resp, f, ensure_ascii=False, indent=2) + + if not output: + print("⚠️ 任务成功,但 output 为空。") + return + + if not download_outputs: + print("ℹ️ 已跳过下载,仅保存任务响应。") + return + + url_items = list(iter_urls(output)) + if not url_items: + print("⚠️ output 中没有找到可下载 URL。") + return + + print("\n📥 开始下载输出文件...") + for logical_key, url in url_items: + short_key = logical_key.replace("output.", "") + ext = infer_extension_from_url(url) + filename = safe_filename(short_key) + ext + save_path = unique_path(out_dir / filename) + + print(f" - {short_key} -> {save_path}") + download_file(session, url, save_path, request_timeout=request_timeout) + + +def main(): + args = build_parser().parse_args() + + if not args.api_key: + raise ValueError("请提供 --api_key 或设置环境变量 TRIPO_API_KEY") + + image_path = Path(args.image) + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + session = create_session(args.api_key) + + print(f"🚀 启动测试 | 模型: {args.model_version}") + print(f"🖼️ 输入图片: {image_path}") + print(f"📁 输出目录: {out_dir.resolve()}") + + start_wall_time = time.perf_counter() + + # 1) 上传 + print("\n[1/4] 上传图片...") + upload_start = time.perf_counter() + file_token = upload_image(session, image_path, request_timeout=args.request_timeout) + upload_end = time.perf_counter() + print(f"✅ 上传完成 | file_token: {file_token}") + print(f"⏱️ 上传耗时: {upload_end - upload_start:.2f}s") + + # 2) 提交任务 + print("\n[2/4] 提交 image_to_model 任务...") + payload = build_generation_payload(args, file_token, image_path) + + if args.print_payload: + print(json.dumps(payload, ensure_ascii=False, indent=2)) + + task_id = create_task(session, payload, request_timeout=args.request_timeout) + print(f"✅ 任务提交成功 | task_id: {task_id}") + + # 3) 轮询任务 + print("\n[3/4] 轮询任务状态...") + gen_start = time.perf_counter() + task_resp = poll_task( + session, + task_id, + poll_interval=args.poll_interval, + poll_timeout=args.poll_timeout, + request_timeout=args.request_timeout, + ) + gen_end = time.perf_counter() + + data = task_resp.get("data") or {} + output = data.get("output") or {} + total_end = time.perf_counter() + + print("\n🎉 生成成功") + print("=" * 60) + print(f"task_id : {task_id}") + print(f"纯生成耗时 : {gen_end - gen_start:.2f}s") + print(f"总流程耗时 : {total_end - start_wall_time:.2f}s") + print(f"最终 status : {data.get('status')}") + print(f"output keys : {list(output.keys()) if isinstance(output, dict) else type(output)}") + print("=" * 60) + + if args.print_output: + print(json.dumps(output, ensure_ascii=False, indent=2)) + + # 4) 下载输出 + print("\n[4/4] 保存结果...") + save_outputs( + session, + task_resp, + out_dir=out_dir, + request_timeout=args.request_timeout, + save_task_json=args.save_task_json, + download_outputs=args.download_outputs, + ) + + print("\n✅ 全部完成。") + + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(f"\n❌ 程序终止: {e}") + sys.exit(1) diff --git a/src/server/canvas_generate_3D/triop3d_api_server.py b/src/server/canvas_generate_3D/triop3d_api_server.py new file mode 100644 index 0000000..c428bc0 --- /dev/null +++ b/src/server/canvas_generate_3D/triop3d_api_server.py @@ -0,0 +1,633 @@ +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]: + """异步请求核心方法""" + 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: + raise TripoAPIError(f"请求失败: {method} {url} | {e}") from e + + if not resp.is_success: + try: + err_payload = resp.json() + except Exception: + err_payload = resp.text + raise TripoAPIError(f"HTTP {resp.status_code} | {method} {url} | {extract_error_message(err_payload)}") + + try: + return resp.json() + except Exception as e: + raise TripoAPIError(f"响应不是合法 JSON: {method} {url}") from e + + 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) -> str: + resp = await self.request_json("POST", "/task", request_timeout=request_timeout, json=payload) + data = resp.get("data") or {} + task_id = data.get("task_id") + if not task_id: + raise TripoAPIError(f"未返回 task_id: {json.dumps(resp, ensure_ascii=False)}") + return task_id + + # 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) -> str: + """ + 异步版本:创建单个图片转 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: 提交任务(异步) + print("正在提交 Tripo3D 任务...") + logger.info("正在提交 Tripo3D 任务...") + task_id = await server.create_task( + payload=payload, + request_timeout=input_data.request_timeout + ) + print(f"✅ 任务创建成功 | task_id: {task_id}") + logger.info(f"✅ 任务创建成功 | task_id: {task_id}") + + return task_id + + +async def create_multi_task(input_data: Tripo3dApiModel) -> str: + """ + 异步版本:创建多图转 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}") + task_id = await server.create_task(payload=payload, request_timeout=input_data.request_timeout) + + logger.info(f"✅ 多图任务创建成功 | task_id: {task_id}") + print(f"✅ 多图任务创建成功 | task_id: {task_id}") + + return task_id + + +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))