`reach character {logo, strip-glb, qa, qa-analyze}` replaces make_logo.py,
glb_strip_utility_nodes.py, analyze_captures.py and the run-garment-qa bash
driver. The QA configs and method doc move beside the domain (qa_configs/,
GARMENT_QA.md), and `qa` takes a config name (`reach character qa hoodie_modern`)
or a path.
Parity, from baselines taken before anything moved:
- the logo PNG is byte-identical
- a synthetic GLB with three real utility nodes strips to identical bytes
(the committed bodies strip 0 nodes, so they proved nothing)
- re-analyzing a cached capture set gives a byte-identical report.json and
summary
run-garment-qa is rewritten, not wrapped (D-263). Its decisions — which config,
which Godot ($GODOT, then ~/bin/godot4, then PATH), and whether xvfb-run is
needed — are capture_plan(), pinned by tooling/test_character.py without
launching Godot. The bash exit codes are kept: 2 for a missing config, 3 for
no Godot.
The T-1271 domain map was wrong about this domain. Six of its ten files import
bpy: convert_outfit, inspect_glb, check_hair_symmetry, check_icosphere,
render_quaternius_test and test_quaternius_raw. They are Blender payloads and
joined the carve-out as blender_* (41 payloads now). The 22 existing payloads'
docstrings still cited tooling/garment-fit/ from before T-1273; fixed.
Archived, with reasons in tooling/archive/README.md:
- setup_clothing_metadata.py wrote coverage data for five garments that no
longer exist in the 24-garment wardrobe
- wipe-bodies.sh ran raw DELETEs on systems.db
segment_reference_distribution.md moved to docs/assets/visual/.
Behaviour changes:
- The QA analyzer exited 0 whatever it found, though its own README says
clip-through "is the real defect and it gates". qa and qa-analyze now exit 1
on clip-through, and the remedy names --min-pixels (Wave 1/2 were accepted
at 150). The cached peasant set has 33 failures at the default 8 px.
- glb strip re-reported the same nodes as stripped on every re-run and
rewrote an unchanged file: it left them as orphans and then found them
again. Only nodes still linked into the graph count now, and a first pass
writes the same bytes as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
316 lines
12 KiB
Python
316 lines
12 KiB
Python
"""Trellis 3D model generator connector — Gradio API wrapper.
|
|
|
|
Talks to the Trellis Gradio app on tower-of-joy :11510 (URL from config.json).
|
|
Pipeline: start session → upload + preprocess → seed → image_to_3d →
|
|
extract_glb → download .glb.
|
|
|
|
Formerly tooling/db/trellis_connector.py plus the tooling/trellis-batch.sh loop
|
|
(T-1290). `batch` is that loop in Python, and no longer hardcoded to the 16
|
|
character bodies — it takes a directory, or explicit names.
|
|
|
|
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
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import random
|
|
import string
|
|
import time
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
from tooling.core import config, console
|
|
from tooling.core.errors import ReachError
|
|
from tooling.domains.assets import endpoints
|
|
|
|
SERVICE = "trellis"
|
|
|
|
# The batch defaults are the character-body run the bash script was written for.
|
|
BODIES_INPUT = ".tmp/image-gen/characters/bodies"
|
|
BODIES_OUTPUT = ".tmp/glb-gen/characters/bodies"
|
|
|
|
|
|
def get_base_url() -> str:
|
|
return endpoints.base_url(SERVICE)
|
|
|
|
|
|
def health() -> dict:
|
|
"""Is the Trellis API reachable, and what endpoints does it name?"""
|
|
base = get_base_url()
|
|
data = endpoints.call_json(
|
|
urllib.request.Request(f"{base}/info", method="GET"),
|
|
service=SERVICE,
|
|
what="the health check",
|
|
timeout=10,
|
|
)
|
|
return {"ok": True, "url": base, "endpoints": list(data.get("named_endpoints", {}).keys())}
|
|
|
|
|
|
def _call_api(base: str, endpoint: str, data: list, timeout: float = 600, session_hash: str | None = None):
|
|
"""POST one Gradio endpoint, sharing a session so gr.State survives between calls.
|
|
|
|
image_to_3d stores its output in server-side State keyed by session_hash;
|
|
extract_glb reads it back. Without a shared session the state is lost.
|
|
"""
|
|
body: dict = {"data": data}
|
|
if session_hash:
|
|
body["session_hash"] = session_hash
|
|
console.event(f"Calling {endpoint}...")
|
|
result = endpoints.call_json(
|
|
urllib.request.Request(
|
|
f"{base}/api{endpoint}",
|
|
data=json.dumps(body).encode(),
|
|
headers={"Content-Type": "application/json"},
|
|
method="POST",
|
|
),
|
|
service=SERVICE,
|
|
what=endpoint,
|
|
timeout=timeout,
|
|
)
|
|
if isinstance(result, dict) and "data" in result:
|
|
return result["data"]
|
|
return result
|
|
|
|
|
|
def multipart_body(filename: str, payload: bytes, boundary: str = "----TrellisConnectorBoundary") -> bytes:
|
|
"""The multipart/form-data body Gradio's /upload expects — a 'files' field."""
|
|
head = (
|
|
f"--{boundary}\r\n"
|
|
f'Content-Disposition: form-data; name="files"; filename="{filename}"\r\n'
|
|
"Content-Type: image/png\r\n"
|
|
"\r\n"
|
|
).encode()
|
|
return head + payload + f"\r\n--{boundary}--\r\n".encode()
|
|
|
|
|
|
def _upload_image(base: str, image_path: str):
|
|
filename = os.path.basename(image_path)
|
|
with open(image_path, "rb") as f:
|
|
body = multipart_body(filename, f.read())
|
|
console.event(f"Uploading {filename}...")
|
|
result = endpoints.call_json(
|
|
urllib.request.Request(
|
|
f"{base}/upload",
|
|
data=body,
|
|
headers={"Content-Type": "multipart/form-data; boundary=----TrellisConnectorBoundary"},
|
|
method="POST",
|
|
),
|
|
service=SERVICE,
|
|
what="the image upload",
|
|
timeout=30,
|
|
)
|
|
if isinstance(result, list) and result:
|
|
return result[0]
|
|
raise ReachError(
|
|
f"Trellis accepted the upload but returned no file reference: {result}",
|
|
fix="the Gradio app's upload API may have changed — check `reach assets trellis health`",
|
|
)
|
|
|
|
|
|
def _download_file(url: str, output_path: str, base: str) -> int:
|
|
if url.startswith("/"):
|
|
url = f"{base}{url}"
|
|
elif not url.startswith("http"):
|
|
url = f"{base}/file={url}"
|
|
console.event(f"Downloading to {output_path}...")
|
|
body = endpoints.call(
|
|
urllib.request.Request(url, method="GET"), service=SERVICE, what="the GLB download", timeout=120
|
|
)
|
|
with open(output_path, "wb") as f:
|
|
f.write(body)
|
|
return os.path.getsize(output_path)
|
|
|
|
|
|
def glb_url_from(glb_result) -> str | None:
|
|
"""extract_glb returns [model_viewer_data, download_button_data]; take the first URL."""
|
|
if isinstance(glb_result, list):
|
|
for item in glb_result:
|
|
if isinstance(item, dict):
|
|
url = item.get("url") or item.get("path")
|
|
if url:
|
|
return url
|
|
return None
|
|
|
|
|
|
def generate(
|
|
image_path: str,
|
|
output: str | None = None,
|
|
simplify: float = 0.95,
|
|
texture_size: int = 1024,
|
|
seed: int = 0,
|
|
timeout: int = 600,
|
|
) -> dict:
|
|
"""Image → .glb. Returns the result dict."""
|
|
# The input is checked before the service, so a typo does not read as an
|
|
# outage. (The old check also crashed: it passed indent= to print().)
|
|
if not os.path.isfile(image_path):
|
|
raise ReachError(f"image not found: {image_path}", fix="pass an existing .png")
|
|
|
|
base = get_base_url()
|
|
endpoints.call(
|
|
urllib.request.Request(f"{base}/info", method="GET"),
|
|
service=SERVICE,
|
|
what="the availability check",
|
|
timeout=5,
|
|
)
|
|
start_time = time.time()
|
|
output = output or f"{os.path.splitext(os.path.basename(image_path))[0]}.glb"
|
|
|
|
session = "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
|
|
console.event(f"Session: {session}")
|
|
|
|
console.event("Starting session...", phase="1/5")
|
|
_call_api(base, "/start_session", [], timeout=30, session_hash=session)
|
|
|
|
console.event("Uploading and preprocessing image...", phase="2/5")
|
|
uploaded = _upload_image(base, image_path)
|
|
file_ref = {"path": uploaded, "meta": {"_type": "gradio.FileData"}}
|
|
preprocessed = _call_api(base, "/preprocess_image_1", [file_ref], timeout=60, session_hash=session)
|
|
preprocessed_ref = preprocessed[0] if isinstance(preprocessed, list) and preprocessed else preprocessed
|
|
|
|
console.event("Generating 3D model...", phase="3/5")
|
|
seed_result = _call_api(base, "/get_seed", [True, seed], timeout=10, session_hash=session)
|
|
actual_seed = seed_result[0] if isinstance(seed_result, list) and seed_result else seed
|
|
|
|
# Nine inputs, positions per the reference above. The server needs
|
|
# flow_euler.py patched to cast steps to int (numpy >= 2).
|
|
_call_api(
|
|
base,
|
|
"/image_to_3d",
|
|
[preprocessed_ref, [], False, actual_seed, 7.5, 12, 3.0, 12, "stochastic"],
|
|
timeout=timeout,
|
|
session_hash=session,
|
|
)
|
|
|
|
console.event("Extracting GLB...", phase="4/5")
|
|
glb_result = _call_api(base, "/extract_glb", [None, simplify, texture_size], timeout=120, session_hash=session)
|
|
glb_url = glb_url_from(glb_result)
|
|
if not glb_url:
|
|
raise ReachError(
|
|
f"could not find the GLB URL in the extract_glb response: {str(glb_result)[:300]}",
|
|
fix="the Gradio app's response shape may have changed — see trellis.glb_url_from",
|
|
)
|
|
|
|
console.event("Downloading GLB...", phase="5/5")
|
|
os.makedirs(os.path.dirname(os.path.abspath(output)), exist_ok=True)
|
|
size = _download_file(glb_url, output, base)
|
|
|
|
return {
|
|
"ok": True,
|
|
"file": output,
|
|
"size_bytes": size,
|
|
"simplify": simplify,
|
|
"texture_size": texture_size,
|
|
"seed": actual_seed,
|
|
"generation_time_s": round(time.time() - start_time, 1),
|
|
"source_image": image_path,
|
|
}
|
|
|
|
|
|
def batch_plan(input_dir: Path, output_dir: Path, names: list[str] | None) -> list[tuple[str, Path, Path]]:
|
|
"""What a batch would do: (name, input png, output glb) per item — pure."""
|
|
if names:
|
|
chosen = names
|
|
else:
|
|
chosen = sorted(p.stem for p in input_dir.glob("*.png"))
|
|
return [(n, input_dir / f"{n}.png", output_dir / f"{n}.glb") for n in chosen]
|
|
|
|
|
|
def batch(
|
|
input_dir: str = BODIES_INPUT,
|
|
output_dir: str = BODIES_OUTPUT,
|
|
names: list[str] | None = None,
|
|
simplify: float = 0.95,
|
|
texture_size: int = 1024,
|
|
cooldown: float = 15,
|
|
max_retries: int = 3,
|
|
retry_delay: float = 60,
|
|
) -> dict:
|
|
"""Generate one .glb per input image, one at a time, gently.
|
|
|
|
Skips outputs that already exist. Retries each failure, and cools the GPU
|
|
down between successful jobs — the Trellis box is shared (VRAM is scarce).
|
|
Returns the summary; the router prints it and then fails the command if
|
|
anything failed. The bash original exited 0 regardless.
|
|
"""
|
|
root = config.repo_root()
|
|
in_dir = (root / input_dir) if not Path(input_dir).is_absolute() else Path(input_dir)
|
|
out_dir = (root / output_dir) if not Path(output_dir).is_absolute() else Path(output_dir)
|
|
plan = batch_plan(in_dir, out_dir, names)
|
|
if not plan:
|
|
raise ReachError(f"no .png inputs in {in_dir}", fix="pass --input-dir with images, or --names")
|
|
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
total = len(plan)
|
|
results = []
|
|
console.event(f"Trellis batch: {total} item(s), cooldown {cooldown}s, {max_retries} tries each")
|
|
|
|
for i, (name, src, dst) in enumerate(plan, 1):
|
|
phase = f"{i}/{total}"
|
|
if dst.exists():
|
|
console.event(f"{name} — already exists, skipping", phase=phase)
|
|
results.append({"name": name, "status": "skipped"})
|
|
continue
|
|
if not src.exists():
|
|
console.event(f"{name} — input not found: {src}", phase=phase, level="warn")
|
|
results.append({"name": name, "status": "failed", "error": f"input not found: {src}"})
|
|
continue
|
|
|
|
error = None
|
|
for attempt in range(1, max_retries + 1):
|
|
console.event(f"{name} (attempt {attempt}/{max_retries})...", phase=phase, progress=i / total)
|
|
try:
|
|
generate(str(src), output=str(dst), simplify=simplify, texture_size=texture_size)
|
|
error = None
|
|
break
|
|
except ReachError as exc:
|
|
error = exc.message
|
|
console.event(f"{name} failed: {exc.message}", phase=phase, level="warn")
|
|
if attempt < max_retries:
|
|
console.event(f"waiting {retry_delay}s before retry...", phase=phase)
|
|
time.sleep(retry_delay)
|
|
|
|
if error is None:
|
|
results.append({"name": name, "status": "success", "file": str(dst)})
|
|
if i < total:
|
|
console.event(f"cooling down {cooldown}s...", phase=phase)
|
|
time.sleep(cooldown)
|
|
else:
|
|
results.append({"name": name, "status": "failed", "error": error})
|
|
|
|
summary = {
|
|
"ok": not any(r["status"] == "failed" for r in results),
|
|
"total": total,
|
|
"success": sum(r["status"] == "success" for r in results),
|
|
"skipped": sum(r["status"] == "skipped" for r in results),
|
|
"failed": sum(r["status"] == "failed" for r in results),
|
|
"results": results,
|
|
}
|
|
return summary
|