import_economics.py 2,620 → 309 lines — a thin orchestrator keeping the exact CLI, single-transaction/rollback contract, and exit codes. The 16 import steps, MIGRATION_SQL, brands shell-out, validators, and stamp write now live in tooling/economy-db/economy_import/ (db, migration, economy, corporations, brands, bodies, atlas, specialization, traits, validators, stamp, paths, errors). Full type hints throughout. tooling/generator_sources.py replaces the triplicated source registry (importer / stamp checker / pr-process watch list — the skill now derives its list via --list). The registry stamps itself, and economy_import/ modules are globbed fail-closed, so a future module is stamped the moment it exists — closing the silently-weakened-stamp failure mode. Rider: connector config helpers centralized in tooling/db/common.py. Byte-identical behavior proven: full-import table dump diff EMPTY over 107,843 lines / 37 tables (volatile timestamp fields excluded); dry-run output parity; generated_brands.toml sha unchanged. make test-tooling PASS; ruff clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
361 lines
13 KiB
Python
Executable File
361 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
Trellis 3D model generator connector — Gradio API wrapper.
|
||
|
||
Talks to the Trellis Gradio app at tower-of-joy:11510.
|
||
Pipeline: upload image → start session → image_to_3d → extract_glb → download .glb
|
||
|
||
Usage:
|
||
python3 trellis_connector.py health
|
||
python3 trellis_connector.py generate image.png [--output model.glb] [--simplify 0.95] [--texture-size 1024] [--seed 42] [--timeout 600]
|
||
|
||
Gradio API Parameter Reference (TRELLIS v1, microsoft/TRELLIS):
|
||
|
||
/image_to_3d — 9 inputs:
|
||
0: image (Image) preprocessed image from /preprocess_image_1
|
||
1: multiimages (Gallery) [] for single-image mode
|
||
2: is_multiimage (State) False for single-image, True for multi-image
|
||
3: seed (Slider) int, 0-2147483647
|
||
4: ss_guidance (Slider) float, sparse structure guidance strength (default 7.5)
|
||
5: ss_steps (Slider) int, sparse structure sampling steps (default 12)
|
||
6: slat_guidance (Slider) float, structured latent guidance strength (default 3.0)
|
||
7: slat_steps (Slider) int, structured latent sampling steps (default 12)
|
||
8: multiimage_algo (Radio) "stochastic" or "multidiffusion"
|
||
|
||
/extract_glb — 3 inputs:
|
||
0: output_buf (State) None — server uses internal state from image_to_3d
|
||
1: simplify (Slider) float, mesh simplification ratio (default 0.95)
|
||
2: texture_size (Slider) int, texture resolution (default 1024)
|
||
|
||
Common failure modes:
|
||
- "needed 9, got 8": missing is_multiimage (position 2) — must pass False
|
||
- "needed 3, got 2": missing output_buf (position 0) — must pass None
|
||
- "'float' cannot be interpreted as int": numpy version issue on server,
|
||
Gradio Sliders send all values as float. Fix: patch flow_euler.py on
|
||
the server to cast steps to int, or pin numpy < 2.0
|
||
- CUDA device mismatch after crash: restart the container to clear GPU state
|
||
"""
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
|
||
from common import ensure_venv, get_base_url as _get_base_url # noqa: E402
|
||
|
||
ensure_venv()
|
||
|
||
import json
|
||
import os
|
||
import shutil
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
|
||
|
||
def get_base_url() -> str:
|
||
return _get_base_url("trellis_url", "http://tower-of-joy:11510")
|
||
|
||
|
||
def health():
|
||
"""Check if the Trellis API is reachable."""
|
||
base = get_base_url()
|
||
try:
|
||
req = urllib.request.Request(f"{base}/info", method="GET")
|
||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||
data = json.loads(resp.read())
|
||
endpoints = list(data.get("named_endpoints", {}).keys())
|
||
print(json.dumps({
|
||
"ok": True,
|
||
"url": base,
|
||
"endpoints": endpoints
|
||
}, indent=2))
|
||
except Exception as e:
|
||
print(json.dumps({
|
||
"ok": False,
|
||
"url": base,
|
||
"error": str(e)
|
||
}, indent=2))
|
||
sys.exit(1)
|
||
|
||
|
||
def _call_api(base, endpoint, data, timeout=600, session_hash=None):
|
||
"""Call a Gradio API endpoint with optional session tracking.
|
||
|
||
Gradio gr.State components are stored per session_hash on the server.
|
||
All calls in a pipeline (image_to_3d → extract_glb) must share the same
|
||
session_hash so the server can pass state between them.
|
||
"""
|
||
api_url = f"{base}/api{endpoint}"
|
||
|
||
body_dict = {"data": data}
|
||
if session_hash:
|
||
body_dict["session_hash"] = session_hash
|
||
|
||
payload = json.dumps(body_dict)
|
||
req = urllib.request.Request(
|
||
api_url,
|
||
data=payload.encode(),
|
||
headers={"Content-Type": "application/json"},
|
||
method="POST"
|
||
)
|
||
|
||
print(f" Calling {endpoint}...", file=sys.stderr)
|
||
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||
result = json.loads(resp.read())
|
||
if isinstance(result, dict) and "data" in result:
|
||
return result["data"]
|
||
return result
|
||
except urllib.error.HTTPError as e:
|
||
body = e.read().decode("utf-8", errors="replace")
|
||
raise RuntimeError(f"{endpoint} failed ({e.code}): {body[:300]}")
|
||
|
||
|
||
def _upload_image(base, image_path):
|
||
"""Upload an image file to the Gradio server and return the file reference."""
|
||
upload_url = f"{base}/upload"
|
||
|
||
with open(image_path, "rb") as f:
|
||
image_data = f.read()
|
||
|
||
filename = os.path.basename(image_path)
|
||
|
||
# Gradio upload expects multipart/form-data with a 'files' field
|
||
boundary = "----TrellisConnectorBoundary"
|
||
body = (
|
||
f"--{boundary}\r\n"
|
||
f'Content-Disposition: form-data; name="files"; filename="{filename}"\r\n'
|
||
f"Content-Type: image/png\r\n"
|
||
f"\r\n"
|
||
).encode() + image_data + f"\r\n--{boundary}--\r\n".encode()
|
||
|
||
req = urllib.request.Request(
|
||
upload_url,
|
||
data=body,
|
||
headers={
|
||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||
},
|
||
method="POST"
|
||
)
|
||
|
||
print(f" Uploading {filename}...", file=sys.stderr)
|
||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||
result = json.loads(resp.read())
|
||
# Gradio returns a list of uploaded file paths
|
||
if isinstance(result, list) and len(result) > 0:
|
||
return result[0]
|
||
raise RuntimeError(f"Upload failed: {result}")
|
||
|
||
|
||
def _download_file(url, output_path, base):
|
||
"""Download a file from the Gradio server."""
|
||
if url.startswith("/"):
|
||
url = f"{base}{url}"
|
||
elif not url.startswith("http"):
|
||
url = f"{base}/file={url}"
|
||
|
||
print(f" Downloading to {output_path}...", file=sys.stderr)
|
||
req = urllib.request.Request(url, method="GET")
|
||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||
with open(output_path, "wb") as f:
|
||
shutil.copyfileobj(resp, f)
|
||
|
||
return os.path.getsize(output_path)
|
||
|
||
|
||
def _check_available(base):
|
||
"""Quick check if Trellis is reachable. Fail fast with a clear message."""
|
||
try:
|
||
req = urllib.request.Request(f"{base}/info", method="GET")
|
||
urllib.request.urlopen(req, timeout=5)
|
||
except Exception:
|
||
print(json.dumps({
|
||
"ok": False,
|
||
"error": f"Trellis is not available at {base}. The service may be switched off to save system resources. Start it before generating 3D models."
|
||
}, indent=2))
|
||
sys.exit(1)
|
||
|
||
|
||
def generate(image_path, output=None, simplify=0.95, texture_size=1024,
|
||
seed=0, timeout=600):
|
||
"""
|
||
Generate a 3D model from an image.
|
||
|
||
Pipeline:
|
||
1. Start session
|
||
2. Upload and preprocess image
|
||
3. Generate 3D from image
|
||
4. Extract GLB
|
||
5. Download GLB file
|
||
|
||
Args:
|
||
image_path: Path to the input image (PNG recommended)
|
||
output: Output .glb file path (default: auto-named)
|
||
simplify: Mesh simplification factor (0.9-0.98, default 0.95)
|
||
texture_size: Texture resolution (512-2048, default 1024)
|
||
seed: Random seed (default 0)
|
||
timeout: Max wait time per step in seconds
|
||
"""
|
||
base = get_base_url()
|
||
_check_available(base)
|
||
start_time = time.time()
|
||
|
||
if not os.path.isfile(image_path):
|
||
print(json.dumps({"ok": False, "error": f"Image not found: {image_path}"}), indent=2)
|
||
sys.exit(1)
|
||
|
||
if output is None:
|
||
name = os.path.splitext(os.path.basename(image_path))[0]
|
||
output = f"{name}.glb"
|
||
|
||
# Generate a session hash — Gradio uses this to maintain gr.State between
|
||
# separate API calls. Without it, image_to_3d's output state is lost before
|
||
# extract_glb can read it.
|
||
import random
|
||
import string
|
||
session = ''.join(random.choices(string.ascii_lowercase + string.digits, k=12))
|
||
print(f" Session: {session}", file=sys.stderr)
|
||
|
||
# Step 1: Start session
|
||
print("Step 1/5: Starting session...", file=sys.stderr)
|
||
session_result = _call_api(base, "/start_session", [], timeout=30, session_hash=session)
|
||
|
||
# Step 2: Upload and preprocess image
|
||
print("Step 2/5: Uploading and preprocessing image...", file=sys.stderr)
|
||
uploaded_path = _upload_image(base, image_path)
|
||
file_ref = {
|
||
"path": uploaded_path,
|
||
"meta": {"_type": "gradio.FileData"}
|
||
}
|
||
preprocess_result = _call_api(base, "/preprocess_image_1", [file_ref], timeout=60, session_hash=session)
|
||
|
||
# _call_api returns the "data" array directly
|
||
if isinstance(preprocess_result, list) and len(preprocess_result) > 0:
|
||
preprocessed_ref = preprocess_result[0]
|
||
else:
|
||
preprocessed_ref = preprocess_result
|
||
|
||
# Step 3: Get seed
|
||
print("Step 3/5: Generating 3D model...", file=sys.stderr)
|
||
seed_result = _call_api(base, "/get_seed", [True, seed], timeout=10, session_hash=session)
|
||
if isinstance(seed_result, list) and seed_result:
|
||
actual_seed = seed_result[0]
|
||
else:
|
||
actual_seed = seed
|
||
|
||
# Step 4: Image to 3D
|
||
# Gradio app has 9 inputs: Image, Gallery, State, Slider×5, Radio
|
||
# State is a hidden session component at position 3 — pass None.
|
||
# Slider order (from /info): seed, ss_guidance, ss_steps, slat_guidance, slat_steps
|
||
# Note: server-side numpy bug requires patching flow_euler.py to cast steps to int.
|
||
gen_result = _call_api(base, "/image_to_3d", [
|
||
preprocessed_ref, # image
|
||
[], # multiimages (empty)
|
||
False, # is_multiimage (boolean flag, not session state)
|
||
actual_seed, # seed (Slider, 0-2147483647)
|
||
7.5, # ss_guidance_strength
|
||
12, # ss_sampling_steps
|
||
3.0, # slat_guidance_strength
|
||
12, # slat_sampling_steps
|
||
"stochastic", # multiimage_algo
|
||
], timeout=timeout, session_hash=session)
|
||
|
||
# Step 5: Extract GLB
|
||
# State (output_buf) is maintained server-side via session_hash.
|
||
# extract_glb reads it automatically. Pass None as placeholder for the State component.
|
||
print("Step 4/5: Extracting GLB...", file=sys.stderr)
|
||
glb_result = _call_api(base, "/extract_glb", [None, simplify, texture_size], timeout=120, session_hash=session)
|
||
|
||
# _call_api returns the "data" array: [model_viewer_data, download_button_data]
|
||
glb_url = None
|
||
if isinstance(glb_result, list):
|
||
for item in glb_result:
|
||
if isinstance(item, dict):
|
||
url = item.get("url") or item.get("path")
|
||
if url:
|
||
glb_url = url
|
||
break
|
||
|
||
if not glb_url:
|
||
print(json.dumps({
|
||
"ok": False,
|
||
"error": "Could not extract GLB URL from response",
|
||
"response": glb_result
|
||
}, indent=2))
|
||
sys.exit(1)
|
||
|
||
# Step 6: Download
|
||
print("Step 5/5: Downloading GLB...", file=sys.stderr)
|
||
os.makedirs(os.path.dirname(os.path.abspath(output)), exist_ok=True)
|
||
file_size = _download_file(glb_url, output, base)
|
||
|
||
elapsed = round(time.time() - start_time, 1)
|
||
print(json.dumps({
|
||
"ok": True,
|
||
"file": output,
|
||
"size_bytes": file_size,
|
||
"simplify": simplify,
|
||
"texture_size": texture_size,
|
||
"seed": actual_seed,
|
||
"generation_time_s": elapsed,
|
||
"source_image": image_path
|
||
}, indent=2))
|
||
|
||
|
||
def main():
|
||
if len(sys.argv) < 2:
|
||
print("Usage:")
|
||
print(" trellis_connector.py health")
|
||
print(" trellis_connector.py generate image.png [--output model.glb] [--simplify 0.95] [--texture-size 1024] [--seed N] [--timeout N]")
|
||
sys.exit(1)
|
||
|
||
cmd = sys.argv[1]
|
||
|
||
if cmd == "health":
|
||
health()
|
||
elif cmd == "generate":
|
||
if len(sys.argv) < 3:
|
||
print("Error: image path required", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
image_path = sys.argv[2]
|
||
output = None
|
||
simplify = 0.95
|
||
texture_size = 1024
|
||
seed = 0
|
||
timeout = 600
|
||
|
||
i = 3
|
||
while i < len(sys.argv):
|
||
if sys.argv[i] == "--output" and i + 1 < len(sys.argv):
|
||
output = sys.argv[i + 1]
|
||
i += 2
|
||
elif sys.argv[i] == "--simplify" and i + 1 < len(sys.argv):
|
||
simplify = float(sys.argv[i + 1])
|
||
i += 2
|
||
elif sys.argv[i] == "--texture-size" and i + 1 < len(sys.argv):
|
||
texture_size = int(sys.argv[i + 1])
|
||
i += 2
|
||
elif sys.argv[i] == "--seed" and i + 1 < len(sys.argv):
|
||
seed = int(sys.argv[i + 1])
|
||
i += 2
|
||
elif sys.argv[i] == "--timeout" and i + 1 < len(sys.argv):
|
||
timeout = int(sys.argv[i + 1])
|
||
i += 2
|
||
else:
|
||
print(f"Unknown argument: {sys.argv[i]}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
generate(image_path, output=output, simplify=simplify,
|
||
texture_size=texture_size, seed=seed, timeout=timeout)
|
||
else:
|
||
print(f"Unknown command: {cmd}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|