Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,15 @@ You can choose between two backends for the GPT agent:
<img src="docs/assets/image_to_3d.jpg" alt="Image to 3D" width="700">

### ☁️ Service
Run the image-to-3D generation service locally.
Run the image-to-3D generation service locally or using hunyuan3D API.
Models downloaded automatically on first run, please be patient.
```sh
# Run in foreground
python apps/image_to_3d.py
# Or run in the background
CUDA_VISIBLE_DEVICES=0 nohup python apps/image_to_3d.py > /dev/null 2>&1 &
# using hunyuan3D API.
python apps/image_to_3d_api.py
```

### ⚡ API
Expand All @@ -115,12 +117,16 @@ Support the use of [SAM3D](https://github.com/facebookresearch/sam-3d-objects) o
<img src="docs/assets/text_to_3d.jpg" alt="Text to 3D" width="700">

### ☁️ Service
Deploy the text-to-3D generation service locally.
Deploy the text-to-3D generation service locally or using hunyuan3D API..

Text-to-image model based on the Kolors model, supporting Chinese and English prompts.
Models downloaded automatically on first run, please be patient.
```sh
# Run in foreground
python apps/text_to_3d.py
# using hunyuan3D API.
python apps/text_to_3d_api.py

```

### ⚡ API
Expand Down
177 changes: 177 additions & 0 deletions apps/hunyuan_image3d_save.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import hashlib
import hmac
import json
import time
import sys
import io
from datetime import datetime
from http.client import HTTPSConnection
from pathlib import Path
import urllib.request
import zipfile
import requests,os

def sign(key, msg):
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()


def download_and_extract_obj(
url: str,
save_dir: str,
ext: str,
zip_name: str = "model.zip"
) -> str:
"""
下载 zip → 解压 → 返回 mesh 文件路径

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

统一使用英文

"""

save_dir = Path(save_dir)
save_dir.mkdir(parents=True, exist_ok=True)

# 1️⃣ 下载
resp = requests.get(url, stream=True)
resp.raise_for_status()
content = resp.content

normalized_ext = ext.lower().lstrip(".")
obj_like = normalized_ext == "obj"

# 2️⃣ 如果是 zip,解压并返回目标模型文件
if zipfile.is_zipfile(io.BytesIO(content)):
zip_path = save_dir / zip_name
with zip_path.open("wb") as f:
f.write(content)
with zipfile.ZipFile(zip_path, "r") as z:
z.extractall(save_dir)

pattern_ext = ".obj" if obj_like else f".{normalized_ext}"
mesh_files = list(save_dir.rglob(f"*{pattern_ext}"))
if not mesh_files:
raise FileNotFoundError(f"未找到 {pattern_ext} 文件")
return str(mesh_files[0].resolve())

# 3️⃣ 非 zip:按单文件落盘
out_ext = "obj" if obj_like else normalized_ext
model_path = save_dir / f"result.{out_ext}"
with model_path.open("wb") as f:
f.write(content)
return str(model_path.resolve())


def query_and_download_hunyuan_job(job_id: str, save_dir: str, secret_id: str, secret_key: str):
token = ""
service = "ai3d"
host = "ai3d.tencentcloudapi.com"
region = "ap-guangzhou"
version = "2025-05-13"
action = "QueryHunyuanTo3DRapidJob"

payload = json.dumps({"JobId": job_id}, ensure_ascii=False)

algorithm = "TC3-HMAC-SHA256"
timestamp = int(time.time())
date = datetime.utcfromtimestamp(timestamp).strftime("%Y-%m-%d")

# ===== Step 1: Canonical request =====
canonical_headers = (
"content-type:application/json; charset=utf-8\n"
f"host:{host}\n"
f"x-tc-action:{action.lower()}\n"
)
signed_headers = "content-type;host;x-tc-action"
hashed_payload = hashlib.sha256(payload.encode("utf-8")).hexdigest()

canonical_request = (
"POST\n/\n\n"
+ canonical_headers
+ "\n"
+ signed_headers
+ "\n"
+ hashed_payload
)

# ===== Step 2: String to sign =====
credential_scope = f"{date}/{service}/tc3_request"
hashed_canonical_request = hashlib.sha256(
canonical_request.encode("utf-8")
).hexdigest()

string_to_sign = (
f"{algorithm}\n{timestamp}\n{credential_scope}\n{hashed_canonical_request}"
)

# ===== Step 3: Signature =====
secret_date = sign(("TC3" + secret_key).encode("utf-8"), date)
secret_service = sign(secret_date, service)
secret_signing = sign(secret_service, "tc3_request")
signature = hmac.new(
secret_signing, string_to_sign.encode("utf-8"), hashlib.sha256
).hexdigest()

authorization = (
f"{algorithm} "
f"Credential={secret_id}/{credential_scope}, "
f"SignedHeaders={signed_headers}, "
f"Signature={signature}"
)

headers = {
"Authorization": authorization,
"Content-Type": "application/json; charset=utf-8",
"Host": host,
"X-TC-Action": action,
"X-TC-Timestamp": str(timestamp),
"X-TC-Version": version,
"X-TC-Region": region,
}

# ===== Request =====
MAX_WAIT_SECONDS = 600 # 最多等 10 分钟
POLL_INTERVAL = 20 # 每 20 秒查一次
start_time = time.time()
while True:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

是否不安全?

req = HTTPSConnection(host)
req.request("POST", "/", body=payload.encode("utf-8"), headers=headers)
resp = req.getresponse()

body_str = resp.read().decode("utf-8")
result = json.loads(body_str)

response = result.get("Response", {})
status = response.get("Status")

print(f"[AI3D] Job status = {status}")

if status == "DONE":
break

if time.time() - start_time > MAX_WAIT_SECONDS:
raise TimeoutError("Job polling timeout")

time.sleep(POLL_INTERVAL)

files = response.get("ResultFile3Ds", [])
model_paths = []
preview_paths = []

for i, item in enumerate(files):

model_url = item.get("Url")
preview_url = item.get("PreviewImageUrl")
file_type = item.get("Type", "UNKNOWN").lower() # obj / glb / fbx ...

# ---------- 1. 下载 ZIP ----------

if model_url:
obj_path = download_and_extract_obj(model_url, save_dir, file_type)
model_paths.append(obj_path)

save_dir = Path(save_dir)

# ---------- 4. 下载预览图 ----------
if preview_url:
preview_path = save_dir / f"preview_{i}.png"
urllib.request.urlretrieve(preview_url, preview_path)
preview_paths.append(str(preview_path))

return model_paths[0], preview_paths[0]
153 changes: 153 additions & 0 deletions apps/hunyuan_image3d_submit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import argparse
import base64
import hashlib
import hmac
import json
import os
import time
from datetime import datetime
from http.client import HTTPSConnection
from typing import Any, Dict, Optional


def _sign(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()


def _build_tc3_headers(
payload: str,
action: str,
secret_id: str,
secret_key: str,
service: str = "ai3d",
host: str = "ai3d.tencentcloudapi.com",
region: str = "ap-guangzhou",
version: str = "2025-05-13",
token: str = "",
) -> Dict[str, str]:
timestamp = int(time.time())
date = datetime.utcfromtimestamp(timestamp).strftime("%Y-%m-%d")

canonical_headers = (
"content-type:application/json; charset=utf-8\n"
f"host:{host}\n"
f"x-tc-action:{action.lower()}\n"
)
signed_headers = "content-type;host;x-tc-action"
hashed_request_payload = hashlib.sha256(payload.encode("utf-8")).hexdigest()

canonical_request = (
"POST\n/\n\n"
f"{canonical_headers}\n"
f"{signed_headers}\n"
f"{hashed_request_payload}"
)

credential_scope = f"{date}/{service}/tc3_request"
string_to_sign = (
"TC3-HMAC-SHA256\n"
f"{timestamp}\n"
f"{credential_scope}\n"
f"{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
)

secret_date = _sign(("TC3" + secret_key).encode("utf-8"), date)
secret_service = _sign(secret_date, service)
secret_signing = _sign(secret_service, "tc3_request")
signature = hmac.new(secret_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()

authorization = (
"TC3-HMAC-SHA256 "
f"Credential={secret_id}/{credential_scope}, "
f"SignedHeaders={signed_headers}, "
f"Signature={signature}"
)

headers: Dict[str, str] = {
"Authorization": authorization,
"Content-Type": "application/json; charset=utf-8",
"Host": host,
"X-TC-Action": action,
"X-TC-Timestamp": str(timestamp),
"X-TC-Version": version,
"X-TC-Region": region,
}
if token:
headers["X-TC-Token"] = token
return headers


def _post_ai3d(payload_obj: Dict[str, Any], action: str, secret_id: str, secret_key: str) -> Dict[str, Any]:
host = "ai3d.tencentcloudapi.com"
payload = json.dumps(payload_obj, separators=(",", ":"), ensure_ascii=False)
headers = _build_tc3_headers(payload, action, secret_id, secret_key, host=host)

conn = HTTPSConnection(host)
conn.request("POST", "/", body=payload.encode("utf-8"), headers=headers)
resp = conn.getresponse()
body = resp.read().decode("utf-8")
result = json.loads(body)

if "Error" in result.get("Response", {}):
raise RuntimeError(f"Tencent API Error: {result['Response']['Error']}")
return result


def hunyuan_image3d_submit(
image_path: str,
secret_id: Optional[str] = None,
secret_key: Optional[str] = None,
result_format: str = "GLB",
enable_pbr: bool = True,
action: Optional[str] = None,
) -> str:
"""Submit image-to-3d job and return JobId."""
secret_id = secret_id or os.getenv("TENCENT_SECRET_ID") or os.getenv("TENCENTCLOUD_SECRET_ID")
secret_key = secret_key or os.getenv("TENCENT_SECRET_KEY") or os.getenv("TENCENTCLOUD_SECRET_KEY")
if secret_id:
secret_id = secret_id.strip().strip("'\"")
if secret_key:
secret_key = secret_key.strip().strip("'\"")
if not secret_id or not secret_key:
raise ValueError("Missing credential: set secret_id/secret_key or env TENCENT_SECRET_ID/TENCENT_SECRET_KEY")
if not os.path.isfile(image_path):
raise FileNotFoundError(f"image_path not found: {image_path}")

with open(image_path, "rb") as f:
image_base64 = base64.b64encode(f.read()).decode("utf-8")

payload = {
"ImageBase64": image_base64,
"ResultFormat": result_format,
"EnablePBR": enable_pbr,
}
submit_action = action or os.getenv("HUNYUAN_IMAGE3D_ACTION") or "SubmitHunyuanTo3DProJob"
result = _post_ai3d(payload, action=submit_action, secret_id=secret_id, secret_key=secret_key)
return result["Response"]["JobId"]


# Backward compatible alias
submit_hunyuan_image3D = hunyuan_image3d_submit


def main() -> None:
parser = argparse.ArgumentParser(description="Submit Hunyuan Image->3D job")
parser.add_argument("--image-path", required=True, help="Input image local path")
parser.add_argument("--secret-id", default=None, help="Tencent Cloud SecretId (or env TENCENT_SECRET_ID)")
parser.add_argument("--secret-key", default=None, help="Tencent Cloud SecretKey (or env TENCENT_SECRET_KEY)")
parser.add_argument("--result-format", default="GLB", choices=["GLB", "OBJ", "FBX"], help="Output format")
parser.add_argument("--disable-pbr", action="store_true", help="Disable PBR material generation")
args = parser.parse_args()

job_id = hunyuan_image3d_submit(
image_path=args.image_path,
secret_id=args.secret_id,
secret_key=args.secret_key,
result_format=args.result_format,
enable_pbr=not args.disable_pbr,
)
print(job_id)


if __name__ == "__main__":
main()
Loading