feat: add native Apple Silicon asset pipeline

This commit is contained in:
Jourloy 2026-07-17 09:21:34 +03:00
parent 9c34570ffc
commit f59e6e5981
34 changed files with 2161 additions and 557 deletions

View File

@ -17,7 +17,99 @@ https://github.com/user-attachments/assets/63b43a7e-acc7-4c81-a900-6da450527d8f
## 🍎 Apple Silicon Fork
This fork adds an **MLX backend** for native Apple Silicon (M-series) inference, with Metal GPU acceleration for mesh postprocessing via `mtldiffrast`, `cumesh`, and `flex_gemm`. The original CUDA pipeline is fully preserved. See `mlx_backend/` for details and `requirements_macos.txt` for macOS dependencies.
This fork makes Apple Silicon a source-native backend instead of cloning and
patching a second TRELLIS checkout. The supported end-to-end path is PyTorch
MPS with capability-probed Metal extensions. MLX is included only as an
experimental parity backend; the upstream CUDA/Linux path remains available.
The implementation incorporates the source-native backend and parity work from
[`trellis2-apple@6055b86`](https://github.com/pedronaugusto/trellis2-apple/commit/6055b868734af6e12769d229d90580e775fae9f0)
and the MPS CLI/fallback lessons from
[`trellis-mac@d58628f`](https://github.com/shivampkumar/trellis-mac/commit/d58628f4f5b9c3de8274cb110074154f4b31cef2).
### macOS setup
Requirements: Apple Silicon, Xcode, and Python 3.11. The setup script creates
`.venv`, installs the matching Xcode Metal Toolchain when necessary, tries
`torch==2.13.0` / `torchvision==0.28.0`, builds the pinned Metal extensions,
then runs `pip check`, MPS, SDPA, MLX, KDTree, and Metal probes. If the primary
pair fails its ABI probe, it rebuilds once with the prescribed
`torch==2.11.0` / `torchvision==0.26.0` fallback.
```sh
git clone https://github.com/Jourloy/TRELLIS.2.git
cd TRELLIS.2
bash scripts/setup_macos.sh
source .venv/bin/activate
```
The Metal sources are fixed to these commits:
- `mtlgemm`: `867aec8234299a7fe1ede7f802c8debe5a939a82`
- `mtldiffrast`: `4668cd91cb6d27f5e264731f94a06841fbf7aab8`
- `mtlbvh`: `23f441c470ce1f537e1fd836f3ffb5b8245f7975`
- `mtlmesh`: `212079e55772cff3d648a21372392c37e0643f3b`
`SKIP_METAL=1 bash scripts/setup_macos.sh` installs the slower controlled
fallback environment. No API or Gradio server is needed for the supported CLI.
### Hugging Face access and offline cache
TRELLIS.2-4B is public. DINOv3 and RMBG-2.0 are gated, so accept their terms
and authenticate once before downloading all pinned inputs:
```sh
hf auth login
python scripts/download_weights.py \
--cache-dir ~/.cache/trellis2/huggingface
python scripts/download_weights.py \
--cache-dir ~/.cache/trellis2/huggingface --offline
```
Runtime revisions are fixed to TRELLIS.2-4B `af44b45`, DINOv3 `ea8dc28`,
RMBG-2.0 `5df4c9c`, and the external TRELLIS image decoder `25e0d31`. Pass the
same cache with `--cache-dir` and use `--offline` after the first download.
### Reproducible generation
```sh
python scripts/generate_asset.py input.png \
--output-dir outputs/sample \
--backend auto \
--baker auto \
--pipeline-type 512 \
--seed 42 \
--texture-size 1024 \
--background auto \
--pbr-decimation-target none \
--cache-dir ~/.cache/trellis2/huggingface
```
`background=auto` runs official RMBG-2.0 for opaque images, while a prepared
RGBA image with non-opaque alpha bypasses background removal. `background=keep`
passes the image through unchanged.
Every run writes:
- `raw_full.glb`: untouched full-resolution geometry before PBR processing;
- `candidate_pbr.glb`: UV0 and native base-color, metallic, roughness, and
alpha data;
- `meta.json`: exact revisions, backend capabilities, seed, timings, hashes,
bounds, sizes, triangle counts, and every fallback attempt.
There is no default triangle limit. PBR export first tries the complete mesh
with Metal, then full-resolution KDTree baking. If both fail on a very large
mesh, a separately recorded technical candidate near 200k faces is tried;
`raw_full.glb` is never simplified. Set `TRELLIS_DISABLE_METAL=1` to exercise
the pure-PyTorch sparse-convolution, SDPA, CPU mesh extraction, and KDTree
fallback path explicitly.
### Dependency licenses
The repository code is MIT, but model dependencies have their own terms. In
particular, review the DINOv3 license before use and note that RMBG-2.0 is
distributed under CC BY-NC 4.0. These terms are documented here and are not
enforced by an automatic runtime block.
## ✨ Features

View File

@ -1,35 +0,0 @@
"""Pydantic request/response models for the Trellis2 API server."""
from typing import Optional
from pydantic import BaseModel, Field
class GenerateRequest(BaseModel):
"""Request to generate a 3D model from an image."""
image: str = Field(..., description="Base64-encoded image (PNG/JPEG)")
seed: int = Field(default=42, description="Random seed")
pipeline_type: str = Field(
default="1024_cascade",
description="Pipeline type: 512, 1024, 1024_cascade, 1536_cascade",
)
output_path: Optional[str] = Field(default=None, description="Save GLB to this path (in addition to returning base64)")
decimation_target: int = Field(default=1000000, description="Target face count for simplification")
texture_size: int = Field(default=2048, description="Texture resolution")
remesh: bool = Field(default=False, description="Whether to remesh the output")
steps: Optional[int] = Field(default=None, description="Number of sampler steps (default: 12, lower = faster)")
guidance_strength: Optional[float] = Field(default=None, description="Guidance strength for structure/shape samplers (default: 7.5)")
texture_guidance: Optional[float] = Field(default=None, description="Guidance strength for texture sampler (default: 1.0 = OFF)")
class GenerateResponse(BaseModel):
"""Response containing the generated 3D model."""
glb: str = Field(..., description="Base64-encoded GLB file")
vertices: int = Field(..., description="Number of vertices in the output mesh")
faces: int = Field(..., description="Number of faces in the output mesh")
generation_time: float = Field(..., description="Generation time in seconds")
class HealthResponse(BaseModel):
"""Health check response."""
status: str = "ok"
backend: str = ""
weights_loaded: bool = False

View File

@ -1,168 +0,0 @@
"""
FastAPI server for Trellis2 image-to-3D generation (MLX backend).
- POST /generate base64 image GLB
- GET /health server status
Usage:
python api_server.py --weights weights/TRELLIS.2-4B --port 8082
"""
import os
import sys
# Environment defaults — set before any torch/trellis imports
os.environ.setdefault("SPARSE_CONV_BACKEND", "flex_gemm")
os.environ.setdefault("ATTN_BACKEND", "sdpa")
os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1") # deform_conv2d in RMBG
# Add o-voxel to path if present
_ovoxel = os.path.join(os.path.dirname(os.path.abspath(__file__)), "o-voxel")
if os.path.isdir(_ovoxel) and _ovoxel not in sys.path:
sys.path.insert(0, _ovoxel)
import io
import base64
import time
import tempfile
import argparse
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from PIL import Image
from api_models import GenerateRequest, GenerateResponse, HealthResponse
from mlx_backend import setup_logging
# Global pipeline instance
pipeline = None
@asynccontextmanager
async def lifespan(app):
global pipeline
from mlx_backend.pipeline import create_mlx_pipeline
weights = os.environ.get("TRELLIS2_WEIGHTS", "weights/TRELLIS.2-4B")
pipeline = create_mlx_pipeline(weights_path=weights)
yield
pipeline = None
app = FastAPI(title="Trellis2 MLX API", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health", response_model=HealthResponse)
async def health():
if pipeline is None:
return HealthResponse(status="loading")
return HealthResponse(
status="ok",
backend="mlx",
weights_loaded=True,
)
@app.post("/generate", response_model=GenerateResponse)
async def generate(request: GenerateRequest):
if pipeline is None:
raise HTTPException(status_code=503, detail="Pipeline not ready")
t_start = time.time()
# Decode input image
try:
image_bytes = base64.b64decode(request.image)
image = Image.open(io.BytesIO(image_bytes))
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid image: {e}")
# Build per-sampler overrides (upstream defaults: structure/shape=7.5, texture=1.0)
structure_shape_overrides = {}
if request.steps is not None:
structure_shape_overrides['steps'] = request.steps
if request.guidance_strength is not None:
structure_shape_overrides['guidance_strength'] = request.guidance_strength
texture_overrides = {}
if request.steps is not None:
texture_overrides['steps'] = request.steps
if request.texture_guidance is not None:
texture_overrides['guidance_strength'] = request.texture_guidance
# Generate mesh
try:
meshes = pipeline.run(
image,
seed=request.seed,
pipeline_type=request.pipeline_type,
sparse_structure_sampler_params=structure_shape_overrides,
shape_slat_sampler_params=structure_shape_overrides,
tex_slat_sampler_params=texture_overrides,
)
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"Generation failed: {e}")
mesh = meshes[0]
# Export to GLB
try:
if request.output_path:
out_dir = os.path.dirname(request.output_path)
if out_dir:
os.makedirs(out_dir, exist_ok=True)
glb_path = request.output_path
else:
tmp = tempfile.NamedTemporaryFile(suffix=".glb", delete=False)
glb_path = tmp.name
tmp.close()
from mlx_backend.pipeline import to_glb
to_glb(
mesh, glb_path,
decimation_target=request.decimation_target,
texture_size=request.texture_size,
remesh=request.remesh,
verbose=True,
)
with open(glb_path, "rb") as f:
glb_bytes = f.read()
if not request.output_path:
os.unlink(glb_path)
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"GLB export failed: {e}")
t_end = time.time()
return GenerateResponse(
glb=base64.b64encode(glb_bytes).decode(),
vertices=int(mesh.vertices.shape[0]),
faces=int(mesh.faces.shape[0]),
generation_time=round(t_end - t_start, 2),
)
def main():
parser = argparse.ArgumentParser(description="Trellis2 MLX API Server")
parser.add_argument("--host", type=str, default="0.0.0.0")
parser.add_argument("--port", type=int, default=8082)
parser.add_argument("--weights", type=str, default="weights/TRELLIS.2-4B")
args = parser.parse_args()
os.environ["TRELLIS2_WEIGHTS"] = args.weights
setup_logging()
uvicorn.run(app, host=args.host, port=args.port)
if __name__ == "__main__":
main()

View File

@ -1,174 +0,0 @@
"""
Gradio app for Trellis2 with MLX backend (macOS).
Simplified from upstream app.py no CUDA render preview, direct GLB export.
"""
import gradio as gr
import os
import time
from datetime import datetime
import shutil
import numpy as np
from PIL import Image
import torch
import o_voxel
MAX_SEED = np.iinfo(np.int32).max
TMP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'tmp')
def get_seed(randomize_seed: bool, seed: int) -> int:
return np.random.randint(0, MAX_SEED) if randomize_seed else seed
def preprocess_image(image: Image.Image) -> Image.Image:
return pipeline.preprocess_image(image)
def image_to_3d(
image: Image.Image,
seed: int,
resolution: str,
ss_guidance_strength: float,
ss_sampling_steps: int,
shape_slat_guidance_strength: float,
shape_slat_sampling_steps: int,
tex_slat_guidance_strength: float,
tex_slat_sampling_steps: int,
decimation_target: int,
texture_size: int,
req: gr.Request,
progress=gr.Progress(track_tqdm=True),
):
user_dir = os.path.join(TMP_DIR, str(req.session_hash))
os.makedirs(user_dir, exist_ok=True)
pipeline_type = {"512": "512", "1024": "1024_cascade", "1536": "1536_cascade"}[resolution]
t0 = time.time()
meshes = pipeline.run(
image,
seed=seed,
sparse_structure_sampler_params={
"steps": ss_sampling_steps,
"guidance_strength": ss_guidance_strength,
},
shape_slat_sampler_params={
"steps": shape_slat_sampling_steps,
"guidance_strength": shape_slat_guidance_strength,
},
tex_slat_sampler_params={
"steps": tex_slat_sampling_steps,
"guidance_strength": tex_slat_guidance_strength,
},
pipeline_type=pipeline_type,
)
dt_gen = time.time() - t0
mesh = meshes[0]
t0 = time.time()
glb = o_voxel.postprocess.to_glb(
vertices=mesh.vertices,
faces=mesh.faces,
attr_volume=mesh.attrs,
coords=mesh.coords,
attr_layout=mesh.layout,
voxel_size=mesh.voxel_size,
aabb=[[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]],
decimation_target=decimation_target,
texture_size=texture_size,
verbose=True,
)
dt_post = time.time() - t0
now = datetime.now()
timestamp = now.strftime("%Y-%m-%dT%H%M%S") + f".{now.microsecond // 1000:03d}"
glb_path = os.path.join(user_dir, f'sample_{timestamp}.glb')
glb.export(glb_path)
info = (f"Generation: {dt_gen:.0f}s | Post-processing: {dt_post:.0f}s | "
f"Verts: {mesh.vertices.shape[0]:,} | Faces: {mesh.faces.shape[0]:,}")
return glb_path, glb_path, info
def start_session(req: gr.Request):
user_dir = os.path.join(TMP_DIR, str(req.session_hash))
os.makedirs(user_dir, exist_ok=True)
def end_session(req: gr.Request):
user_dir = os.path.join(TMP_DIR, str(req.session_hash))
if os.path.exists(user_dir):
shutil.rmtree(user_dir)
with gr.Blocks(title="Trellis2 MLX") as demo:
gr.Markdown("""
## Trellis2 (MLX Backend)
Upload an image and click Generate to create a 3D asset.
""")
with gr.Row():
with gr.Column(scale=1, min_width=360):
image_prompt = gr.Image(label="Image Prompt", format="png", image_mode="RGBA", type="pil", height=400)
resolution = gr.Radio(["512", "1024"], label="Resolution", value="1024")
seed = gr.Slider(0, MAX_SEED, label="Seed", value=42, step=1)
randomize_seed = gr.Checkbox(label="Randomize Seed", value=False)
decimation_target = gr.Slider(100000, 1000000, label="Decimation Target", value=1000000, step=10000)
texture_size = gr.Slider(1024, 4096, label="Texture Size", value=2048, step=1024)
generate_btn = gr.Button("Generate", variant="primary")
with gr.Accordion(label="Advanced Settings", open=False):
gr.Markdown("### Stage 1: Sparse Structure")
with gr.Row():
ss_guidance_strength = gr.Slider(1.0, 10.0, label="Guidance", value=7.5, step=0.1)
ss_sampling_steps = gr.Slider(1, 50, label="Steps", value=12, step=1)
gr.Markdown("### Stage 2: Shape")
with gr.Row():
shape_slat_guidance_strength = gr.Slider(1.0, 10.0, label="Guidance", value=7.5, step=0.1)
shape_slat_sampling_steps = gr.Slider(1, 50, label="Steps", value=12, step=1)
gr.Markdown("### Stage 3: Texture")
with gr.Row():
tex_slat_guidance_strength = gr.Slider(0.1, 10.0, label="Guidance", value=1.0, step=0.1)
tex_slat_sampling_steps = gr.Slider(1, 50, label="Steps", value=12, step=1)
with gr.Column(scale=2):
glb_output = gr.Model3D(label="Generated 3D Model", height=700, clear_color=(0.25, 0.25, 0.25, 1.0))
download_btn = gr.DownloadButton(label="Download GLB")
info_text = gr.Textbox(label="Info", interactive=False)
# Handlers
demo.load(start_session)
demo.unload(end_session)
image_prompt.upload(
preprocess_image,
inputs=[image_prompt],
outputs=[image_prompt],
)
generate_btn.click(
get_seed,
inputs=[randomize_seed, seed],
outputs=[seed],
).then(
image_to_3d,
inputs=[
image_prompt, seed, resolution,
ss_guidance_strength, ss_sampling_steps,
shape_slat_guidance_strength, shape_slat_sampling_steps,
tex_slat_guidance_strength, tex_slat_sampling_steps,
decimation_target, texture_size,
],
outputs=[glb_output, download_btn, info_text],
)
if __name__ == "__main__":
os.makedirs(TMP_DIR, exist_ok=True)
from mlx_backend.pipeline import create_mlx_pipeline
pipeline = create_mlx_pipeline(weights_path="weights/TRELLIS.2-4B")
demo.launch()

View File

@ -7,6 +7,7 @@ import mlx.core as mx
import mlx.nn as nn
import numpy as np
from PIL import Image
from typing import Optional
class MlxDINOv3PatchEmbed(nn.Module):
@ -226,14 +227,25 @@ class MlxDINOv3FeatureExtractor(nn.Module):
return mx.array(np.stack(processed))
def load_dinov3_from_hf(model_name: str = "facebook/dinov3-vitl16-pretrain-lvd1689m",
image_size: int = 512) -> MlxDINOv3FeatureExtractor:
def load_dinov3_from_hf(
model_name: str = "facebook/dinov3-vitl16-pretrain-lvd1689m",
image_size: int = 512,
*,
revision: Optional[str] = None,
cache_dir: Optional[str] = None,
local_files_only: bool = False,
) -> MlxDINOv3FeatureExtractor:
"""Load DINOv3 weights from HuggingFace into MLX model."""
from huggingface_hub import hf_hub_download
import json
config_path = hf_hub_download(model_name, "config.json")
weight_path = hf_hub_download(model_name, "model.safetensors")
hub_kwargs = {
"revision": revision,
"cache_dir": cache_dir,
"local_files_only": local_files_only,
}
config_path = hf_hub_download(model_name, "config.json", **hub_kwargs)
weight_path = hf_hub_download(model_name, "model.safetensors", **hub_kwargs)
with open(config_path) as f:
config = json.load(f)

View File

@ -12,6 +12,12 @@ import json
import time
import logging
from trellis2.model_revisions import (
DINOV3_REVISION,
RMBG_REVISION,
revision_for_repo,
)
import mlx.core as mx
logger = logging.getLogger(__name__)
@ -30,23 +36,43 @@ from .adapters import (
)
def _resolve_hf_path(rel_path: str) -> str:
def _resolve_hf_path(
rel_path: str,
*,
cache_dir: str = None,
local_files_only: bool = False,
) -> str:
"""Resolve 'org/repo/path/to/file' to local HF cache path."""
from huggingface_hub import hf_hub_download
parts = rel_path.split('/')
repo_id = f"{parts[0]}/{parts[1]}"
file_base = '/'.join(parts[2:])
json_path = hf_hub_download(repo_id, f"{file_base}.json")
hf_hub_download(repo_id, f"{file_base}.safetensors")
hub_kwargs = {
"revision": revision_for_repo(repo_id),
"cache_dir": cache_dir,
"local_files_only": local_files_only,
}
json_path = hf_hub_download(repo_id, f"{file_base}.json", **hub_kwargs)
hf_hub_download(repo_id, f"{file_base}.safetensors", **hub_kwargs)
return json_path.rsplit('.json', 1)[0]
def _resolve_model_path(weights_path: str, rel_path: str) -> str:
def _resolve_model_path(
weights_path: str,
rel_path: str,
*,
cache_dir: str = None,
local_files_only: bool = False,
) -> str:
"""Resolve model path — local first, then HF Hub."""
full = os.path.join(weights_path, rel_path)
if os.path.exists(f"{full}.json"):
return full
return _resolve_hf_path(rel_path)
return _resolve_hf_path(
rel_path,
cache_dir=cache_dir,
local_files_only=local_files_only,
)
def _load_mlx_flow_model(path: str, config: dict):
@ -164,7 +190,12 @@ def _get_loader(name: str, config: dict):
raise ValueError(f"No loader for model '{name}' (type: {config['name']})")
def create_mlx_pipeline(weights_path: str = "weights/TRELLIS.2-4B"):
def create_mlx_pipeline(
weights_path: str = "weights/TRELLIS.2-4B",
*,
cache_dir: str = None,
local_files_only: bool = False,
):
"""Create upstream Trellis2ImageTo3DPipeline with MLX-backed models.
All model compute runs in MLX. The upstream PT pipeline handles
@ -183,7 +214,12 @@ def create_mlx_pipeline(weights_path: str = "weights/TRELLIS.2-4B"):
# Load all models with MLX adapters
models = {}
for name, rel_path in args['models'].items():
path = _resolve_model_path(weights_path, rel_path)
path = _resolve_model_path(
weights_path,
rel_path,
cache_dir=cache_dir,
local_files_only=local_files_only,
)
with open(f"{path}.json") as f:
model_config = json.load(f)
@ -219,11 +255,21 @@ def create_mlx_pipeline(weights_path: str = "weights/TRELLIS.2-4B"):
# Image conditioning (MLX DINOv3)
pipeline.image_cond_model = MlxImageCondAdapter(
load_dinov3_from_hf(args['image_cond_model']['args']['model_name'])
load_dinov3_from_hf(
args['image_cond_model']['args']['model_name'],
revision=DINOV3_REVISION,
cache_dir=cache_dir,
local_files_only=local_files_only,
)
)
# Background removal (PT — lightweight, used once)
pipeline.rembg_model = BiRefNet(**args['rembg_model']['args'])
pipeline.rembg_model = BiRefNet(
**args['rembg_model']['args'],
revision=RMBG_REVISION,
cache_dir=cache_dir,
local_files_only=local_files_only,
)
pipeline.low_vram = True
pipeline._device = torch.device('cpu')

View File

@ -8,27 +8,35 @@ import trimesh
import trimesh.visual
import platform
import os
_HAS_DR = False
_HAS_MESH = False
_BACKEND = None
dr = None
_METAL_DISABLED = os.environ.get('TRELLIS_DISABLE_METAL', '0') == '1'
_BACKEND_ERRORS = {}
# Differentiable rasterizer — mtldiffrast (Metal) or nvdiffrast (CUDA)
try:
if _METAL_DISABLED:
raise ImportError('Metal disabled by TRELLIS_DISABLE_METAL=1')
import mtldiffrast.torch as dr
_HAS_DR = True
_BACKEND = 'metal'
except ImportError:
except (ImportError, RuntimeError, OSError) as exc:
_BACKEND_ERRORS['mtldiffrast'] = str(exc)
try:
import nvdiffrast.torch as dr
_HAS_DR = True
_BACKEND = 'cuda'
except ImportError:
pass
except (ImportError, RuntimeError, OSError) as cuda_exc:
_BACKEND_ERRORS['nvdiffrast'] = str(cuda_exc)
# Mesh processing — cumesh auto-selects Metal/CUDA
try:
if _METAL_DISABLED and platform.system() == 'Darwin':
raise ImportError('Metal disabled by TRELLIS_DISABLE_METAL=1')
import cumesh
_MeshBackend = cumesh.CuMesh
_BVH = cumesh.cuBVH
@ -36,15 +44,18 @@ try:
_HAS_MESH = True
if _BACKEND is None:
_BACKEND = 'metal' if platform.system() == 'Darwin' else 'cuda'
except ImportError:
pass
except (ImportError, RuntimeError, OSError) as exc:
_BACKEND_ERRORS['cumesh'] = str(exc)
_HAS_GPU_DEPS = _HAS_DR and _HAS_MESH
try:
if _METAL_DISABLED and platform.system() == 'Darwin':
raise ImportError('Metal disabled by TRELLIS_DISABLE_METAL=1')
from flex_gemm.ops.grid_sample import grid_sample_3d as _flex_grid_sample_3d
_HAS_FLEX_GEMM = True
except ImportError:
except (ImportError, RuntimeError, OSError) as exc:
_BACKEND_ERRORS['flex_gemm'] = str(exc)
_HAS_FLEX_GEMM = False
@ -80,7 +91,7 @@ def to_glb(
aabb: Union[list, tuple, np.ndarray, torch.Tensor],
voxel_size: Union[float, list, tuple, np.ndarray, torch.Tensor] = None,
grid_size: Union[int, list, tuple, np.ndarray, torch.Tensor] = None,
decimation_target: int = 1000000,
decimation_target: Optional[int] = 1000000,
texture_size: int = 2048,
remesh: bool = False,
remesh_band: float = 1,
@ -105,7 +116,7 @@ def to_glb(
aabb: (2, 3) tensor of minimum and maximum coordinates of the volume
voxel_size: (3,) tensor of size of each voxel
grid_size: (3,) tensor of number of voxels in each dimension
decimation_target: target number of vertices for mesh simplification
decimation_target: target face count, or None to preserve full resolution
texture_size: size of the texture for baking
remesh: whether to perform remeshing
remesh_band: size of the remeshing band
@ -178,6 +189,12 @@ def to_glb(
if verbose:
print(f"Original mesh: {vertices.shape[0]} vertices, {faces.shape[0]} faces")
effective_decimation_target = (
int(faces.shape[0]) if decimation_target is None else int(decimation_target)
)
if effective_decimation_target <= 0:
effective_decimation_target = int(faces.shape[0])
# Move data to GPU
vertices = vertices.to(device)
faces = faces.to(device)
@ -214,7 +231,7 @@ def to_glb(
# --- Branch 1: Standard Pipeline (Simplification & Cleaning) ---
if not remesh:
# Step 1: Aggressive simplification (3x target)
mesh.simplify(decimation_target * 3, verbose=verbose)
mesh.simplify(effective_decimation_target * 3, verbose=verbose)
if verbose:
print(f"After inital simplification: {mesh.num_vertices} vertices, {mesh.num_faces} faces")
@ -227,7 +244,7 @@ def to_glb(
print(f"After initial cleanup: {mesh.num_vertices} vertices, {mesh.num_faces} faces")
# Step 3: Final simplification to target count
mesh.simplify(decimation_target, verbose=verbose)
mesh.simplify(effective_decimation_target, verbose=verbose)
if verbose:
print(f"After final simplification: {mesh.num_vertices} vertices, {mesh.num_faces} faces")
@ -271,7 +288,7 @@ def to_glb(
print(f"After cleanup: {mesh.num_vertices} vertices, {mesh.num_faces} faces")
# Simplify and clean the remeshed result
mesh.simplify(decimation_target, verbose=verbose)
mesh.simplify(effective_decimation_target, verbose=verbose)
if verbose:
print(f"After simplifying: {mesh.num_vertices} vertices, {mesh.num_faces} faces")

View File

@ -218,7 +218,7 @@ def to_glb(
aabb: Union[list, tuple, np.ndarray, torch.Tensor],
voxel_size: Union[float, list, tuple, np.ndarray, torch.Tensor] = None,
grid_size: Union[int, list, tuple, np.ndarray, torch.Tensor] = None,
decimation_target: int = 1000000,
decimation_target: Optional[int] = 1000000,
texture_size: int = 2048,
remesh: bool = False,
remesh_band: float = 1,
@ -282,16 +282,19 @@ def to_glb(
print(f"After hole filling: {len(tm.vertices)} vertices, {len(tm.faces)} faces")
# --- Step 2: Simplification ---
if decimation_target < len(tm.faces):
effective_decimation_target = len(tm.faces) if decimation_target is None else int(decimation_target)
if effective_decimation_target <= 0:
effective_decimation_target = len(tm.faces)
if effective_decimation_target < len(tm.faces):
if HAS_FAST_SIMPLIFICATION:
ratio = min(1.0, decimation_target / max(len(tm.faces), 1))
ratio = min(1.0, effective_decimation_target / max(len(tm.faces), 1))
new_verts, new_faces = fast_simplification.simplify(
tm.vertices.astype(np.float64), tm.faces,
target_reduction=(1.0 - ratio),
)
tm = trimesh.Trimesh(vertices=new_verts, faces=new_faces, process=False)
elif hasattr(tm, 'simplify_quadric_decimation'):
tm = tm.simplify_quadric_decimation(decimation_target)
tm = tm.simplify_quadric_decimation(effective_decimation_target)
if verbose:
print(f"After simplification: {len(tm.vertices)} vertices, {len(tm.faces)} faces")

View File

@ -1,45 +1,5 @@
# Trellis2 MLX requirements (Apple Silicon)
# NO: flash_attn, spconv, torchsparse (CUDA-only)
# Core
# torch >= 2.11.0 is required because mtlgemm's metal_context.mm calls
# at::mps::dispatch_sync_with_rethrow, which was promoted from
# at::native::mps:: to at::mps:: in PyTorch 2.11 (PR #167445, merged
# 2025-11-11). Earlier stable versions (2.6 2.10) only expose it under
# at::native::mps:: and won't link.
torch>=2.11.0
torchvision>=0.26.0
transformers>=4.40.0,<5
safetensors
huggingface_hub
pillow
numpy
# Mesh processing
trimesh
xatlas
fast-simplification
pygltflib
# Image processing
opencv-python-headless
# Server
fastapi
uvicorn[standard]
pydantic>=2.0
# MLX (Apple Silicon native)
mlx>=0.31.0
# Metal GPU packages (Apple Silicon — install with: pip install --no-build-isolation <url>)
mtldiffrast @ https://github.com/pedronaugusto/mtldiffrast/archive/main.tar.gz
cumesh @ https://github.com/pedronaugusto/mtlmesh/archive/main.tar.gz
flex_gemm @ https://github.com/pedronaugusto/mtlgemm/archive/main.tar.gz
# Misc
tqdm
imageio
imageio-ffmpeg
scipy
einops
# Verified Apple Silicon runtime pair. scripts/setup_macos.sh retries the
# documented 2.11/0.26 ABI fallback only when the primary Metal build fails.
torch==2.13.0
torchvision==0.28.0
-r requirements_macos_core.txt

View File

@ -0,0 +1,33 @@
# Python runtime
transformers==4.57.6
huggingface-hub==0.36.0
safetensors==0.8.0
pillow==12.3.0
numpy==2.2.6
scipy==1.17.1
# Inference and image processing
opencv-python-headless==4.12.0.88
easydict==1.13
einops==0.8.2
kornia==0.8.3
timm==1.0.28
tqdm==4.68.4
imageio==2.37.3
imageio-ffmpeg==0.6.0
# Mesh and GLB processing
trimesh==4.12.2
xatlas==0.0.11
fast-simplification==0.1.13
pygltflib==1.16.5
plyfile==1.1.3
zstandard==0.25.0
# Apple backend and diagnostics
mlx==0.32.0
psutil==7.2.2
pytest==9.1.1
# Upstream renderer utility, pinned instead of following main.
utils3d @ git+https://github.com/EasternJournalist/utils3d.git@9a4eb15e4021b67b12c460c7057d642626897ec8

118
scripts/download_weights.py Normal file → Executable file
View File

@ -1,59 +1,93 @@
"""Download TRELLIS.2 weights into a local directory structure.
The rest of the codebase already knows how to load TRELLIS.2 from a local
snapshot directory as long as it contains the same layout as the Hugging Face
repo root:
pipeline.json
texturing_pipeline.json
ckpts/*.json
ckpts/*.safetensors
Example:
python export/download_weights.py --output-dir weights/TRELLIS.2-4B
"""
#!/usr/bin/env python3
"""Populate the dedicated Hugging Face cache with every pinned runtime input."""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
# The Xet transport has produced non-resumable response-body decode failures
# on large model shards on macOS. Plain HTTP keeps .incomplete files in the
# selected HF cache and resumes them on the next attempt.
os.environ.setdefault("HF_HUB_DISABLE_XET", "1")
from huggingface_hub import snapshot_download
from huggingface_hub.errors import GatedRepoError, HfHubHTTPError
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from trellis2.model_revisions import MODEL_REVISIONS, TRELLIS_REPO
DEFAULT_PATTERNS = [
"pipeline.json",
"texturing_pipeline.json",
"ckpts/*",
]
def main() -> None:
parser = argparse.ArgumentParser(description="Download TRELLIS.2 weights locally")
parser.add_argument("--repo-id", default="microsoft/TRELLIS.2-4B")
parser.add_argument("--output-dir", default="weights/TRELLIS.2-4B")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--full-snapshot",
action="store_true",
help="Download the entire repo instead of just pipeline configs and checkpoints",
"--cache-dir",
type=Path,
default=Path.home() / ".cache" / "trellis2" / "huggingface",
)
parser.add_argument("--offline", action="store_true", help="verify that the pinned cache is complete")
parser.add_argument("--max-workers", type=int, default=2)
parser.add_argument("--retries", type=int, default=3)
args = parser.parse_args()
if args.max_workers < 1:
parser.error("--max-workers must be at least 1")
if args.retries < 1:
parser.error("--retries must be at least 1")
cache_dir = args.cache_dir.expanduser().resolve()
cache_dir.mkdir(parents=True, exist_ok=True)
output_dir = os.path.abspath(args.output_dir)
os.makedirs(output_dir, exist_ok=True)
snapshots = {}
for repo_id, revision in MODEL_REVISIONS.items():
for attempt in range(1, args.retries + 1):
try:
snapshots[repo_id] = snapshot_download(
repo_id=repo_id,
revision=revision,
cache_dir=str(cache_dir),
local_files_only=args.offline,
max_workers=args.max_workers,
)
break
except GatedRepoError as exc:
raise RuntimeError(
f"access to {repo_id}@{revision} is gated; accept its terms and run "
"`hf auth login`, then retry"
) from exc
except (OSError, RuntimeError, HfHubHTTPError) as exc:
if args.offline or attempt == args.retries:
raise RuntimeError(
f"failed to cache {repo_id}@{revision} after {attempt} attempt(s): {exc}"
) from exc
delay = min(5, attempt * 2)
print(
f"Retrying {repo_id}@{revision} after transient download error "
f"({attempt}/{args.retries}): {exc}",
file=sys.stderr,
)
time.sleep(delay)
download_kwargs = {
"repo_id": args.repo_id,
"local_dir": output_dir,
"local_dir_use_symlinks": False,
"resume_download": True,
}
if not args.full_snapshot:
download_kwargs["allow_patterns"] = DEFAULT_PATTERNS
snapshot_path = snapshot_download(**download_kwargs)
print(f"Downloaded {args.repo_id} to {snapshot_path}")
print(
json.dumps(
{
"cache_dir": str(cache_dir),
"primary_repo": TRELLIS_REPO,
"revisions": MODEL_REVISIONS,
"snapshots": snapshots,
"offline": args.offline,
"max_workers": args.max_workers,
},
indent=2,
sort_keys=True,
)
)
return 0
if __name__ == "__main__":
main()
raise SystemExit(main())

574
scripts/generate_asset.py Executable file
View File

@ -0,0 +1,574 @@
#!/usr/bin/env python3
"""Generate reproducible full-resolution and PBR TRELLIS.2 assets."""
from __future__ import annotations
import argparse
import hashlib
import importlib.metadata
import json
import os
import platform
import resource
import subprocess
import sys
import time
import traceback
from pathlib import Path
from typing import Any, Optional
# MPS fallback must be configured before torch or any Metal extension imports.
os.environ.setdefault("PYTORCH_ENABLE_MPS_FALLBACK", "1")
os.environ.setdefault("FLEX_GEMM_QUIET", "1")
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from trellis2.model_revisions import ( # noqa: E402
DINOV3_REPO,
DINOV3_REVISION,
RMBG_REPO,
RMBG_REVISION,
SOURCE_REVISIONS,
TRELLIS_IMAGE_LARGE_REPO,
TRELLIS_IMAGE_LARGE_REVISION,
TRELLIS_REPO,
TRELLIS_REVISION,
)
SAFETY_FACE_TARGET = 200_000
WATCHDOG_SIGNATURES = (
"non-zero size",
"BVH needs at least 8 triangles",
"kIOGPUCommandBufferCallbackErrorImpactingInteractivity",
"empty mesh",
)
OOM_SIGNATURES = (
"out of memory",
"mps backend out of memory",
"failed to allocate",
"allocation failed",
)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _write_json(path: Path, payload: dict[str, Any]) -> None:
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(path)
def _git_revision() -> Optional[str]:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=ROOT,
text=True,
capture_output=True,
check=False,
)
return result.stdout.strip() if result.returncode == 0 else None
def _peak_rss_bytes() -> int:
value = int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
return value if sys.platform == "darwin" else value * 1024
def _package_versions() -> dict[str, Optional[str]]:
names = (
"torch",
"torchvision",
"transformers",
"huggingface-hub",
"mlx",
"flex-gemm",
"mtldiffrast",
"cumesh",
"trimesh",
"xatlas",
"fast-simplification",
)
versions = {}
for name in names:
try:
versions[name] = importlib.metadata.version(name)
except importlib.metadata.PackageNotFoundError:
versions[name] = None
return versions
def _parse_target(value: str) -> Optional[int]:
if value.lower() in {"none", "full", "off", "0"}:
return None
target = int(value)
if target < 8:
raise argparse.ArgumentTypeError("decimation target must be at least 8 faces or 'none'")
return target
def _watchdog_message(error: BaseException) -> str:
detail = str(error)
if isinstance(error, MemoryError) or any(
signature in detail.lower() for signature in OOM_SIGNATURES
):
return (
"MPS ran out of memory. Close GPU-heavy apps, use one TRELLIS process, "
"and retry with --pipeline-type 512. Do not disable the MPS high-watermark "
f"guard. Original error: {detail}"
)
if any(signature.lower() in detail.lower() for signature in WATCHDOG_SIGNATURES):
return (
"The decoder produced an empty or watchdog-corrupted mesh. Close GPU-heavy apps, "
"retry pipeline 512, or run with TRELLIS_DISABLE_METAL=1 for the slower fallback. "
f"Original error: {detail}"
)
return detail
def _raw_trimesh(mesh):
import numpy as np
import trimesh
vertices = mesh.vertices.detach().cpu().numpy().astype(np.float32, copy=True)
faces = mesh.faces.detach().cpu().numpy().astype(np.int64, copy=True)
# Match o_voxel.postprocess's asset-space conversion exactly.
converted = vertices.copy()
converted[:, 1] = vertices[:, 2]
converted[:, 2] = -vertices[:, 1]
raw = trimesh.Trimesh(vertices=converted, faces=faces, process=False)
# trimesh omits NORMAL from glTF when a process=False mesh has not yet
# materialized its cached vertex normals. Accessing the property computes
# them without merging vertices or changing the full-resolution topology.
_ = raw.vertex_normals
return raw
def _export_raw(mesh, path: Path) -> dict[str, Any]:
raw = _raw_trimesh(mesh)
raw.export(path)
return {
"vertices": int(len(raw.vertices)),
"triangles": int(len(raw.faces)),
"bounds_min": [float(value) for value in raw.bounds[0]],
"bounds_max": [float(value) for value in raw.bounds[1]],
"sha256": _sha256(path),
"bytes": path.stat().st_size,
}
def _metal_baker_available() -> tuple[bool, str]:
try:
from trellis2.backends import probe_metal_backends
from o_voxel import postprocess
probes = probe_metal_backends()
failed_probes = {
name: result for name, result in probes.items() if not result.get("ok")
}
if failed_probes:
return False, json.dumps(failed_probes, sort_keys=True)
available = bool(
getattr(postprocess, "_HAS_DR", False)
and getattr(postprocess, "_HAS_MESH", False)
and getattr(postprocess, "_BACKEND", None) == "metal"
)
if available:
return True, ""
errors = getattr(postprocess, "_BACKEND_ERRORS", {})
return False, json.dumps(errors, sort_keys=True) if errors else "Metal backend is incomplete"
except (ImportError, RuntimeError, OSError) as exc:
return False, str(exc)
def _export_pbr(mesh, path: Path, *, baker: str, target: Optional[int], texture_size: int):
vertices = mesh.vertices.cpu()
faces = mesh.faces.cpu()
pre_simplified = False
# cumesh builds its BVH before its own decimation pass. Reduce only the
# technical fallback candidate up front so the same oversized mesh cannot
# trip the Metal watchdog again. raw_full.glb is exported separately and
# remains untouched.
if target is not None and int(faces.shape[0]) > target:
import fast_simplification
import torch
reduction = 1.0 - (target / int(faces.shape[0]))
simplified_vertices, simplified_faces = fast_simplification.simplify(
vertices.numpy(),
faces.numpy(),
target_reduction=reduction,
)
vertices = torch.from_numpy(simplified_vertices).to(dtype=mesh.vertices.dtype)
faces = torch.from_numpy(simplified_faces).to(dtype=mesh.faces.dtype)
pre_simplified = True
if baker == "metal":
available, reason = _metal_baker_available()
if not available:
raise RuntimeError(f"Metal baker unavailable: {reason}")
from o_voxel import postprocess
exporter = postprocess.to_glb
elif baker == "kdtree":
from o_voxel import postprocess_cpu
exporter = postprocess_cpu.to_glb
else:
raise ValueError(f"Unknown baker: {baker}")
result = exporter(
vertices=vertices,
faces=faces,
attr_volume=mesh.attrs.cpu(),
coords=mesh.coords.cpu(),
attr_layout=mesh.layout,
voxel_size=mesh.voxel_size,
aabb=[[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]],
decimation_target=target,
texture_size=texture_size,
verbose=True,
)
result.export(path)
return result, pre_simplified
def _attempt_schedule(preferred: str, requested_target: Optional[int], raw_faces: int):
preferred_order = ["metal", "kdtree"] if preferred in {"auto", "metal"} else ["kdtree"]
targets = [requested_target]
if requested_target is None and raw_faces > SAFETY_FACE_TARGET:
targets.append(SAFETY_FACE_TARGET)
seen = set()
for target in targets:
for baker in preferred_order:
key = (baker, target)
if key not in seen:
seen.add(key)
yield baker, target
def _run_pbr_attempts(
mesh,
candidate_path: Path,
*,
preferred_baker: str,
requested_target: Optional[int],
texture_size: int,
export_fn=None,
on_attempt=None,
):
"""Run the fixed Metal/KDTree/full/safety fallback order."""
export_fn = export_fn or _export_pbr
raw_faces = int(mesh.faces.shape[0])
attempts = []
for baker, target in _attempt_schedule(preferred_baker, requested_target, raw_faces):
attempt = {
"baker": baker,
"target_faces": target,
"technical_safety_target": target == SAFETY_FACE_TARGET and raw_faces > SAFETY_FACE_TARGET,
}
attempt_started = time.perf_counter()
try:
exported, pre_simplified = export_fn(
mesh,
candidate_path,
baker=baker,
target=target,
texture_size=texture_size,
)
attempt["status"] = "ok"
attempt["pre_simplified_before_bvh"] = pre_simplified
except Exception as exc: # each failure is recorded before the prescribed fallback
exported = None
attempt["status"] = "failed"
attempt["error_type"] = type(exc).__name__
attempt["error"] = str(exc)
if candidate_path.exists():
candidate_path.unlink()
attempt["duration_seconds"] = round(time.perf_counter() - attempt_started, 3)
attempts.append(attempt)
if on_attempt is not None:
on_attempt(attempt)
if exported is not None:
return exported, attempt, attempts
errors = "; ".join(
f"{attempt['baker']}@{attempt['target_faces']}: {attempt.get('error', 'failed')}"
for attempt in attempts
)
raise RuntimeError(f"All PBR export attempts failed: {errors}")
def _candidate_stats(path: Path, exported) -> dict[str, Any]:
import numpy as np
vertices = np.asarray(exported.vertices)
faces = np.asarray(exported.faces)
return {
"vertices": int(len(vertices)),
"triangles": int(len(faces)),
"bounds_min": [float(value) for value in vertices.min(axis=0)],
"bounds_max": [float(value) for value in vertices.max(axis=0)],
"sha256": _sha256(path),
"bytes": path.stat().st_size,
}
def _load_pipeline(args):
import torch
if args.backend == "mlx-experimental":
from huggingface_hub import snapshot_download
from mlx_backend.pipeline import create_mlx_pipeline
snapshot = snapshot_download(
TRELLIS_REPO,
revision=TRELLIS_REVISION,
cache_dir=args.cache_dir,
local_files_only=args.offline,
)
return create_mlx_pipeline(
snapshot,
cache_dir=args.cache_dir,
local_files_only=args.offline,
), "mlx-experimental"
if args.backend == "mps":
if platform.system() != "Darwin" or platform.machine() != "arm64":
raise RuntimeError("The mps backend requires an Apple Silicon Mac")
if not torch.backends.mps.is_available():
raise RuntimeError("PyTorch MPS is not available in this Python environment")
elif args.backend == "cuda":
if not torch.cuda.is_available():
raise RuntimeError("auto selected CUDA, but PyTorch CUDA is unavailable")
else:
raise RuntimeError(f"Unsupported resolved backend: {args.backend}")
from trellis2.pipelines import Trellis2ImageTo3DPipeline
pipeline = Trellis2ImageTo3DPipeline.from_pretrained(
TRELLIS_REPO,
revision=TRELLIS_REVISION,
cache_dir=args.cache_dir,
local_files_only=args.offline,
)
if args.backend == "mps":
pipeline.to(torch.device("mps"))
return pipeline, "mps"
if args.backend == "cuda":
pipeline.cuda()
return pipeline, "cuda"
raise AssertionError("resolved backend validation fell through")
def _resolve_backend(requested: str) -> str:
if requested != "auto":
return requested
if platform.system() == "Darwin" and platform.machine() == "arm64":
return "mps"
# Keep the official Linux/CUDA route intact without adding a new public
# CLI choice: --backend auto resolves to CUDA on supported non-Mac hosts.
import torch
if torch.cuda.is_available():
return "cuda"
raise RuntimeError("auto found neither Apple MPS nor NVIDIA CUDA")
def _parse_args():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("image", type=Path)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--backend", choices=("auto", "mps", "mlx-experimental"), default="auto")
parser.add_argument("--baker", choices=("auto", "metal", "kdtree"), default="auto")
parser.add_argument("--pipeline-type", choices=("512", "1024", "1024_cascade"), default="512")
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--steps", type=int)
parser.add_argument("--texture-size", type=int, choices=(512, 1024, 2048), default=1024)
parser.add_argument("--background", choices=("auto", "keep"), default="auto")
parser.add_argument("--pbr-decimation-target", type=_parse_target, default=None, metavar="none|N")
parser.add_argument("--cache-dir", type=Path)
parser.add_argument("--offline", action="store_true")
parser.add_argument("--force", action="store_true")
return parser.parse_args()
def main() -> int:
args = _parse_args()
requested_backend = args.backend
args.image = args.image.expanduser().resolve()
args.output_dir = args.output_dir.expanduser().resolve()
args.cache_dir = str(args.cache_dir.expanduser().resolve()) if args.cache_dir else None
if not args.image.is_file():
raise SystemExit(f"Input image not found: {args.image}")
if args.offline:
os.environ["HF_HUB_OFFLINE"] = "1"
args.backend = _resolve_backend(args.backend)
args.output_dir.mkdir(parents=True, exist_ok=True)
raw_path = args.output_dir / "raw_full.glb"
candidate_path = args.output_dir / "candidate_pbr.glb"
metadata_path = args.output_dir / "meta.json"
existing = [path for path in (raw_path, candidate_path, metadata_path) if path.exists()]
if existing and not args.force:
raise SystemExit("Refusing to overwrite existing outputs: " + ", ".join(map(str, existing)))
if args.force:
for path in existing:
path.unlink()
from PIL import Image
with Image.open(args.image) as opened:
image = opened.copy()
input_size = list(opened.size)
input_mode = opened.mode
metadata: dict[str, Any] = {
"schema_version": 1,
"status": "running",
"input": {
"path": str(args.image),
"sha256": _sha256(args.image),
"size": input_size,
"mode": input_mode,
},
"configuration": {
"backend": requested_backend,
"resolved_backend": args.backend,
"preferred_baker": args.baker,
"pipeline_type": args.pipeline_type,
"seed": args.seed,
"steps": args.steps,
"texture_size": args.texture_size,
"background": args.background,
"pbr_decimation_target": args.pbr_decimation_target,
"offline": args.offline,
"cache_dir": args.cache_dir,
},
"revisions": {
"code": _git_revision(),
TRELLIS_REPO: TRELLIS_REVISION,
TRELLIS_IMAGE_LARGE_REPO: TRELLIS_IMAGE_LARGE_REVISION,
DINOV3_REPO: DINOV3_REVISION,
RMBG_REPO: RMBG_REVISION,
},
"source_revisions": SOURCE_REVISIONS,
"platform": {
"system": platform.system(),
"release": platform.release(),
"machine": platform.machine(),
"python": platform.python_version(),
},
"packages": _package_versions(),
"timings_seconds": {},
"pbr_attempts": [],
}
_write_json(metadata_path, metadata)
started = time.perf_counter()
try:
load_started = time.perf_counter()
pipeline, resolved_backend = _load_pipeline(args)
metadata["configuration"]["resolved_backend"] = resolved_backend
try:
from trellis2.backends import backend_report
metadata["backend_capabilities"] = backend_report()
except (ImportError, RuntimeError, OSError) as exc:
metadata["backend_capabilities"] = {"probe_error": str(exc)}
metadata["timings_seconds"]["pipeline_load"] = round(time.perf_counter() - load_started, 3)
_write_json(metadata_path, metadata)
sampler_overrides = {"steps": args.steps} if args.steps is not None else {}
generation_started = time.perf_counter()
try:
outputs = pipeline.run(
image,
seed=args.seed,
pipeline_type=args.pipeline_type,
preprocess_image=args.background == "auto",
sparse_structure_sampler_params=sampler_overrides,
shape_slat_sampler_params=sampler_overrides,
tex_slat_sampler_params=sampler_overrides,
)
except (IndexError, AssertionError, RuntimeError, MemoryError) as exc:
raise RuntimeError(_watchdog_message(exc)) from exc
metadata["timings_seconds"]["generation"] = round(time.perf_counter() - generation_started, 3)
mesh = outputs[0] if isinstance(outputs, list) else outputs
if mesh.vertices.shape[0] == 0 or mesh.faces.shape[0] < 8:
raise RuntimeError(_watchdog_message(RuntimeError("empty mesh")))
raw_started = time.perf_counter()
metadata["raw_full"] = _export_raw(mesh, raw_path)
metadata["timings_seconds"]["raw_export"] = round(time.perf_counter() - raw_started, 3)
_write_json(metadata_path, metadata)
pbr_started = time.perf_counter()
raw_faces = int(mesh.faces.shape[0])
def record_attempt(attempt):
metadata["pbr_attempts"].append(attempt)
_write_json(metadata_path, metadata)
exported, chosen, _ = _run_pbr_attempts(
mesh,
candidate_path,
preferred_baker=args.baker,
requested_target=args.pbr_decimation_target,
texture_size=args.texture_size,
on_attempt=record_attempt,
)
metadata["candidate_pbr"] = _candidate_stats(candidate_path, exported)
metadata["candidate_pbr"].update(chosen)
metadata["candidate_pbr"]["technical_decimation"] = bool(
chosen["target_faces"] is not None and chosen["target_faces"] < raw_faces
)
metadata["timings_seconds"]["pbr_export"] = round(time.perf_counter() - pbr_started, 3)
from trellis2.gltf_validation import validate_output_pair
validation_started = time.perf_counter()
metadata["validation"] = validate_output_pair(raw_path, candidate_path)
metadata["timings_seconds"]["validation"] = round(
time.perf_counter() - validation_started, 3
)
metadata["timings_seconds"]["total"] = round(time.perf_counter() - started, 3)
metadata["peak_rss_bytes"] = _peak_rss_bytes()
metadata["status"] = "ok"
_write_json(metadata_path, metadata)
print(f"raw_full.glb: {metadata['raw_full']['triangles']:,} triangles")
print(
"candidate_pbr.glb: "
f"{metadata['candidate_pbr']['triangles']:,} triangles via {chosen['baker']}"
)
print(f"metadata: {metadata_path}")
return 0
except Exception as exc:
metadata["status"] = "failed"
metadata["error"] = {
"type": type(exc).__name__,
"message": str(exc),
"traceback": traceback.format_exc(),
}
metadata["timings_seconds"]["total"] = round(time.perf_counter() - started, 3)
metadata["peak_rss_bytes"] = _peak_rss_bytes()
_write_json(metadata_path, metadata)
print(f"ERROR: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())

118
scripts/probe_macos.py Executable file
View File

@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Probe the supported MPS, Metal, SDPA, MLX, and fallback paths."""
from __future__ import annotations
import argparse
import json
import os
import platform
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "o-voxel"))
def _metal_compiler() -> dict:
result = subprocess.run(
["xcrun", "--find", "metal"],
text=True,
capture_output=True,
check=False,
)
return {
"available": result.returncode == 0,
"path": result.stdout.strip() or None,
"error": result.stderr.strip() or None,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--require-metal", action="store_true")
args = parser.parse_args()
import mlx.core as mx
import numpy as np
import torch
import torch.nn.functional as torch_f
from scipy.spatial import cKDTree
report = {
"platform": {"system": platform.system(), "machine": platform.machine()},
"torch": torch.__version__,
"mps_built": torch.backends.mps.is_built(),
"mps_available": torch.backends.mps.is_available(),
"metal_compiler": _metal_compiler(),
}
failures = []
if not report["mps_available"]:
failures.append("PyTorch MPS is unavailable")
else:
device = torch.device("mps")
left = torch.arange(16, dtype=torch.float32, device=device).reshape(4, 4)
report["mps_matmul_sum"] = float((left @ left.T).sum().cpu())
query = torch.randn(2, 2, 8, 16, device=device)
sdpa = torch_f.scaled_dot_product_attention(query, query, query)
report["sdpa_shape"] = list(sdpa.shape)
mlx_matrix = mx.arange(16, dtype=mx.float32).reshape(4, 4)
mlx_value = mx.sum(mlx_matrix @ mlx_matrix.T)
mx.eval(mlx_value)
report["mlx_matmul_sum"] = float(mlx_value.item())
tree = cKDTree(np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]))
distance, index = tree.query([[0.1, 0.1, 0.1]], k=1)
report["kdtree_probe"] = {"distance": float(distance[0]), "index": int(index[0])}
try:
from trellis2.backends import backend_report
report["backends"] = backend_report()
except (ImportError, RuntimeError, OSError) as exc:
report["backends"] = {"probe_error": str(exc)}
try:
from trellis2.modules.sparse import config as sparse_config
report["sparse_backends"] = {
"convolution": sparse_config.CONV,
"attention": sparse_config.ATTN,
"metal_attention_parity": (
sparse_config.probe_flex_gemm_sparse_attention_on_mps()
),
}
except (ImportError, RuntimeError, OSError) as exc:
report["sparse_backends"] = {"probe_error": str(exc)}
if args.require_metal:
backends = report.get("backends", {})
capability_probes = backends.get("capability_probes", {})
sparse_backends = report.get("sparse_backends", {})
if not report["metal_compiler"]["available"]:
failures.append("Xcode Metal compiler is unavailable")
if backends.get("rasterizer") != "metal":
failures.append("mtldiffrast Metal rasterizer failed capability probe")
if backends.get("mesh") != "metal":
failures.append("mtlmesh/mtlbvh failed capability probe")
if not backends.get("flex_gemm"):
failures.append("mtlgemm/flex_gemm failed capability probe")
if sparse_backends.get("convolution") != "flex_gemm":
failures.append("flex_gemm sparse-convolution capability probe failed")
if not capability_probes.get("rasterizer", {}).get("ok"):
failures.append("mtldiffrast raster capability probe failed")
if not capability_probes.get("mesh_bvh", {}).get("ok"):
failures.append("mtlmesh/mtlbvh capability probe failed")
report["ok"] = not failures
report["failures"] = failures
print(json.dumps(report, indent=2, sort_keys=True))
return 0 if not failures else 2
if __name__ == "__main__":
raise SystemExit(main())

118
scripts/setup_macos.sh Executable file
View File

@ -0,0 +1,118 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHON_BIN="${PYTHON_BIN:-python3.11}"
VENV_DIR="${VENV_DIR:-$ROOT/.venv}"
DEPS_DIR="${TRELLIS_DEPS_DIR:-$HOME/.cache/trellis2/source-deps}"
PRIMARY_TORCH="2.13.0"
PRIMARY_TORCHVISION="0.28.0"
FALLBACK_TORCH="2.11.0"
FALLBACK_TORCHVISION="0.26.0"
MTLGEMM_SHA="867aec8234299a7fe1ede7f802c8debe5a939a82"
MTLDIFFRAST_SHA="4668cd91cb6d27f5e264731f94a06841fbf7aab8"
MTLBVH_SHA="23f441c470ce1f537e1fd836f3ffb5b8245f7975"
MTLMESH_SHA="212079e55772cff3d648a21372392c37e0643f3b"
if [[ "$(uname -s)" != "Darwin" || "$(uname -m)" != "arm64" ]]; then
echo "ERROR: this installer supports Apple Silicon macOS only" >&2
exit 2
fi
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
echo "ERROR: Python 3.11 is required (set PYTHON_BIN if it is installed elsewhere)" >&2
exit 2
fi
if ! "$PYTHON_BIN" -c 'import sys; raise SystemExit(sys.version_info[:2] != (3, 11))'; then
echo "ERROR: $PYTHON_BIN is not Python 3.11" >&2
exit 2
fi
if [[ "${SKIP_METAL:-0}" != "1" ]] && ! xcrun --find metal >/dev/null 2>&1; then
echo "Metal Toolchain is missing; downloading the matching Xcode component..."
xcodebuild -downloadComponent MetalToolchain
fi
if [[ ! -d "$VENV_DIR" ]]; then
"$PYTHON_BIN" -m venv "$VENV_DIR"
fi
PYTHON="$VENV_DIR/bin/python"
PIP=("$PYTHON" -m pip)
"${PIP[@]}" install --upgrade 'pip==26.0.1' 'setuptools==80.9.0' 'wheel==0.46.3' 'ninja==1.13.0' 'pybind11==3.0.1'
"${PIP[@]}" install -r "$ROOT/requirements_macos_core.txt"
install_torch_pair() {
local torch_version="$1"
local torchvision_version="$2"
"${PIP[@]}" uninstall -y torch torchvision mtldiffrast mtlbvh cumesh flex_gemm o_voxel >/dev/null 2>&1 || true
"${PIP[@]}" install "torch==$torch_version" "torchvision==$torchvision_version"
}
checkout_dependency() {
local name="$1"
local url="$2"
local revision="$3"
local destination="$DEPS_DIR/$name-$revision"
local attempt
if [[ -d "$destination/.git" ]] && [[ "$(git -C "$destination" rev-parse HEAD)" == "$revision" ]]; then
DEP_PATH="$destination"
return 0
fi
mkdir -p "$DEPS_DIR"
for attempt in 1 2 3; do
rm -rf "$destination"
if git -c http.version=HTTP/1.1 clone --filter=blob:none "$url" "$destination" \
&& git -C "$destination" -c http.version=HTTP/1.1 fetch --depth 1 origin "$revision" \
&& git -C "$destination" switch --detach "$revision" \
&& git -C "$destination" -c http.version=HTTP/1.1 submodule update --init --recursive; then
DEP_PATH="$destination"
return 0
fi
echo "Dependency checkout failed ($name, attempt $attempt/3); retrying..." >&2
done
echo "ERROR: failed to checkout pinned dependency $name@$revision" >&2
return 1
}
install_metal_stack() {
export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-12.0}"
checkout_dependency mtlbvh https://github.com/pedronaugusto/mtlbvh.git "$MTLBVH_SHA"
"${PIP[@]}" install --no-build-isolation "$DEP_PATH"
checkout_dependency mtldiffrast https://github.com/pedronaugusto/mtldiffrast.git "$MTLDIFFRAST_SHA"
"${PIP[@]}" install --no-build-isolation "$DEP_PATH"
checkout_dependency mtlmesh https://github.com/pedronaugusto/mtlmesh.git "$MTLMESH_SHA"
"${PIP[@]}" install --no-build-isolation "$DEP_PATH"
checkout_dependency mtlgemm https://github.com/pedronaugusto/mtlgemm.git "$MTLGEMM_SHA"
"${PIP[@]}" install --no-build-isolation "$DEP_PATH"
BUILD_TARGET=cpu "${PIP[@]}" install --no-build-isolation -e "$ROOT/o-voxel"
}
install_torch_pair "$PRIMARY_TORCH" "$PRIMARY_TORCHVISION"
resolved_torch="$PRIMARY_TORCH"
resolved_torchvision="$PRIMARY_TORCHVISION"
if [[ "${SKIP_METAL:-0}" == "1" ]]; then
BUILD_TARGET=cpu "${PIP[@]}" install --no-build-isolation -e "$ROOT/o-voxel"
else
install_metal_stack
if ! "$PYTHON" "$ROOT/scripts/probe_macos.py" --require-metal; then
echo "Primary PyTorch pair failed the Metal build/probe; retrying the prescribed ABI fallback..." >&2
install_torch_pair "$FALLBACK_TORCH" "$FALLBACK_TORCHVISION"
install_metal_stack
"$PYTHON" "$ROOT/scripts/probe_macos.py" --require-metal
resolved_torch="$FALLBACK_TORCH"
resolved_torchvision="$FALLBACK_TORCHVISION"
fi
fi
"${PIP[@]}" check
"$PYTHON" "$ROOT/scripts/probe_macos.py"
echo
echo "TRELLIS.2 Apple environment is ready."
echo " source $VENV_DIR/bin/activate"
echo " torch==$resolved_torch torchvision==$resolved_torchvision"
if ! "$PYTHON" -c 'from huggingface_hub import get_token; raise SystemExit(not bool(get_token()))' 2>/dev/null; then
echo " Hugging Face auth is missing: run '$VENV_DIR/bin/hf auth login' before downloading gated weights."
fi

28
scripts/validate_outputs.py Executable file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Validate a raw_full.glb/candidate_pbr.glb pair and print JSON."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from trellis2.gltf_validation import validate_output_pair
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("raw_full", type=Path)
parser.add_argument("candidate_pbr", type=Path)
args = parser.parse_args()
report = validate_output_pair(args.raw_full.resolve(), args.candidate_pbr.resolve())
print(json.dumps(report, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -5,10 +5,10 @@ runs a SparseConv3d through the flex_gemm backend, and feeds the result
through a LayerNorm the exact path that originally crashed with
"Passed CPU tensor to MPS op" before mtlgemm's device-routing fix.
Exercises both Algorithm.IMPLICIT_GEMM (default dense kernel) and
Algorithm.MASKED_IMPLICIT_GEMM (the masked kernel that landed in mtlgemm
round 2). Both should produce numerically equivalent output and pass the
LayerNorm hand-off without crashing.
Exercises Algorithm.IMPLICIT_GEMM, Algorithm.MASKED_IMPLICIT_GEMM, and the
production default Algorithm.MASKED_IMPLICIT_GEMM_SPLITK. All should produce
numerically equivalent output and pass the LayerNorm hand-off without
crashing.
Note: this script intentionally uses torch.nn.functional.layer_norm rather
than trellis2's SparseLayerNorm wrapper, because that wrapper currently calls
@ -26,7 +26,8 @@ import torch
assert torch.backends.mps.is_available(), "This test needs MPS"
from trellis2.modules.sparse import SparseTensor, SparseConv3d
from flex_gemm.ops.spconv import Algorithm, set_algorithm
from trellis2.modules.sparse.conv import config as conv_config
from flex_gemm.ops.spconv import Algorithm
device = "mps"
dtype = torch.float16
@ -57,8 +58,18 @@ if conv.bias is not None:
print(f"Conv weight device: {conv.weight.device}, dtype: {conv.weight.dtype}")
def _run_with_algo(algo, label):
set_algorithm(algo)
out = conv(x)
# conv_flex_gemm sets flex_gemm's global algorithm from this config on
# every forward call, so changing flex_gemm directly would be overwritten.
conv_config.FLEX_GEMM_ALGO = algo
# Neighbor-cache layouts are algorithm-specific. Use a fresh sparse tensor
# so this parity test cannot feed one algorithm another algorithm's cache.
run_x = SparseTensor(
feats=feats,
coords=coords,
shape=torch.Size([1, ch]),
spatial_shape=[res, res, res],
)
out = conv(run_x)
assert out.feats.device.type == "mps", f"FAIL [{label}]: SparseConv3d on {out.feats.device}, expected mps"
assert out.feats.dtype == dtype, f"FAIL [{label}]: SparseConv3d dtype {out.feats.dtype}, expected {dtype}"
# LayerNorm hand-off — the original crash site
@ -73,13 +84,17 @@ def _run_with_algo(algo, label):
print()
y_dense = _run_with_algo(Algorithm.IMPLICIT_GEMM, "IMPLICIT_GEMM (dense)")
y_masked = _run_with_algo(Algorithm.MASKED_IMPLICIT_GEMM, "MASKED_IMPLICIT_GEMM")
y_splitk = _run_with_algo(Algorithm.MASKED_IMPLICIT_GEMM_SPLITK, "MASKED_IMPLICIT_GEMM_SPLITK")
# Numerical parity between the two algorithms — same inputs, equivalent output.
diff = (y_dense - y_masked).abs().max().item()
splitk_diff = (y_dense - y_splitk).abs().max().item()
parity_tol = 2e-2 # fp16 — masked reduces in a different order
assert diff <= parity_tol, f"FAIL: dense vs masked diff {diff:.4e} > tol {parity_tol:.4e}"
assert splitk_diff <= parity_tol, f"FAIL: dense vs split-k diff {splitk_diff:.4e} > tol {parity_tol:.4e}"
print(f" parity dense vs masked: max_diff={diff:.4e} (tol={parity_tol})")
print(f" parity dense vs split-k: max_diff={splitk_diff:.4e} (tol={parity_tol})")
print()
print("PASS — trellis2-apple SparseConv3d + LayerNorm runs end-to-end on MPS via flex_gemm")
print(" Both IMPLICIT_GEMM and MASKED_IMPLICIT_GEMM produce equivalent output.")
print(" Dense, masked, and production split-k algorithms produce equivalent output.")

6
tests/conftest.py Normal file
View File

@ -0,0 +1,6 @@
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "o-voxel"))

View File

@ -0,0 +1,114 @@
import json
import os
import platform
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
def test_disable_metal_selects_controlled_pytorch_fallback():
env = os.environ.copy()
env.update(
{
"TRELLIS_DISABLE_METAL": "1",
"PYTHONPATH": str(ROOT),
"SPARSE_CONV_BACKEND": "pytorch",
"SPARSE_ATTN_BACKEND": "sdpa",
}
)
code = """
import json
from trellis2.modules.sparse import config
from trellis2.backends import backend_report
print('REPORT=' + json.dumps({
'conv': config.CONV,
'attention': config.ATTN,
'backend': backend_report(),
}, sort_keys=True))
"""
completed = subprocess.run(
[sys.executable, "-c", code],
cwd=ROOT,
env=env,
text=True,
capture_output=True,
check=True,
)
line = next(line for line in completed.stdout.splitlines() if line.startswith("REPORT="))
report = json.loads(line.removeprefix("REPORT="))
assert report["conv"] == "pytorch"
assert report["attention"] == "sdpa"
assert report["backend"]["metal_disabled"] is True
assert report["backend"]["rasterizer"] is None
assert report["backend"]["mesh"] is None
@pytest.mark.skipif(platform.system() != "Darwin", reason="Metal probe requires macOS")
def test_metal_sparse_attention_has_its_own_numerical_probe():
import torch
if not torch.backends.mps.is_available():
pytest.skip("MPS is unavailable")
from trellis2.modules.sparse import config
assert config.probe_flex_gemm_sparse_attention_on_mps()
assert config.CONV == "flex_gemm"
assert config.ATTN == "flex_gemm_sparse_attn"
from trellis2.backends import probe_metal_backends
probes = probe_metal_backends()
assert probes["rasterizer"]["ok"], probes["rasterizer"]
assert probes["mesh_bvh"]["ok"], probes["mesh_bvh"]
@pytest.mark.skipif(platform.system() != "Darwin", reason="Metal parity requires macOS")
def test_pure_pytorch_sparse_conv_matches_flex_gemm_on_mps():
code = r"""
import torch
from trellis2.modules.sparse import SparseTensor, config
from trellis2.modules.sparse.conv.conv import SparseConv3d
if not torch.backends.mps.is_available():
raise SystemExit(77)
coords = torch.tensor(
[[0, x, y, z] for x in range(3) for y in range(3) for z in range(3)],
dtype=torch.int32,
device='mps',
)
features = torch.randn(27, 4, dtype=torch.float16, device='mps')
sparse = SparseTensor(
feats=features,
coords=coords,
shape=torch.Size([1, 4]),
spatial_shape=[3, 3, 3],
)
config.set_conv_backend('flex_gemm')
metal = SparseConv3d(4, 5, 3, bias=True).half().to('mps')
metal_out = metal(sparse).feats
config.set_conv_backend('pytorch')
fallback = SparseConv3d(4, 5, 3, bias=True).half().to('mps')
fallback.weight.data.copy_(metal.weight.data)
fallback.bias.data.copy_(metal.bias.data)
fallback_out = fallback(sparse).feats
torch.mps.synchronize()
print('PARITY=' + str(float((metal_out - fallback_out).abs().max().item())))
"""
completed = subprocess.run(
[sys.executable, "-c", code],
cwd=ROOT,
text=True,
capture_output=True,
check=False,
)
if completed.returncode == 77:
pytest.skip("MPS is unavailable")
assert completed.returncode == 0, completed.stderr
parity_line = next(line for line in completed.stdout.splitlines() if line.startswith("PARITY="))
assert float(parity_line.removeprefix("PARITY=")) <= 2e-2

View File

@ -0,0 +1,72 @@
import numpy as np
import pytest
from PIL import Image
from trellis2.pipelines.trellis2_image_to_3d import Trellis2ImageTo3DPipeline
class _FakeRembg:
def __init__(self):
self.calls = 0
def __call__(self, image):
self.calls += 1
rgba = image.convert("RGBA")
alpha = np.zeros((image.height, image.width), dtype=np.uint8)
alpha[2:6, 2:6] = 255
rgba.putalpha(Image.fromarray(alpha, mode="L"))
return rgba
def _pipeline(rembg):
pipeline = object.__new__(Trellis2ImageTo3DPipeline)
pipeline.low_vram = False
pipeline.rembg_model = rembg
pipeline._device = "cpu"
return pipeline
def test_rgba_with_transparency_bypasses_rmbg():
rembg = _FakeRembg()
rgba = np.full((8, 8, 4), 255, dtype=np.uint8)
rgba[:, :, :3] = [80, 120, 160]
rgba[:2, :, 3] = 0
output = _pipeline(rembg).preprocess_image(Image.fromarray(rgba, mode="RGBA"))
assert rembg.calls == 0
assert output.mode == "RGB"
assert output.size[0] > 0 and output.size[1] > 0
def test_opaque_rgba_uses_official_background_remover():
rembg = _FakeRembg()
opaque = Image.new("RGBA", (8, 8), (80, 120, 160, 255))
output = _pipeline(rembg).preprocess_image(opaque)
assert rembg.calls == 1
assert output.mode == "RGB"
assert output.size[0] > 0 and output.size[1] > 0
def test_soft_authored_alpha_is_preserved_without_rmbg():
rembg = _FakeRembg()
rgba = np.zeros((8, 8, 4), dtype=np.uint8)
rgba[2:6, 2:6, :3] = [80, 120, 160]
rgba[2:6, 2:6, 3] = 128
output = _pipeline(rembg).preprocess_image(Image.fromarray(rgba, mode="RGBA"))
assert rembg.calls == 0
assert output.size[0] > 0 and output.size[1] > 0
def test_empty_authored_alpha_has_actionable_error():
rembg = _FakeRembg()
empty = Image.new("RGBA", (8, 8), (80, 120, 160, 0))
with pytest.raises(RuntimeError, match="empty alpha mask"):
_pipeline(rembg).preprocess_image(empty)
assert rembg.calls == 0

View File

@ -0,0 +1,128 @@
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import torch
from scripts import generate_asset
def _mesh(face_count):
return SimpleNamespace(faces=np.zeros((face_count, 3), dtype=np.int64))
def test_default_has_no_decimation_limit():
assert generate_asset._parse_target("none") is None
assert generate_asset._parse_target("0") is None
assert generate_asset._parse_target("250000") == 250000
def test_oom_diagnostic_recommends_safe_single_process_retry():
message = generate_asset._watchdog_message(RuntimeError("MPS backend out of memory"))
assert "--pipeline-type 512" in message
assert "one TRELLIS process" in message
def test_auto_backend_selects_mps_on_apple_silicon(monkeypatch):
monkeypatch.setattr(generate_asset.platform, "system", lambda: "Darwin")
monkeypatch.setattr(generate_asset.platform, "machine", lambda: "arm64")
assert generate_asset._resolve_backend("auto") == "mps"
def test_auto_backend_preserves_linux_cuda_route(monkeypatch):
monkeypatch.setattr(generate_asset.platform, "system", lambda: "Linux")
monkeypatch.setattr(generate_asset.platform, "machine", lambda: "x86_64")
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
assert generate_asset._resolve_backend("auto") == "cuda"
def test_auto_fallback_order_keeps_full_resolution_first():
assert list(generate_asset._attempt_schedule("auto", None, 800_000)) == [
("metal", None),
("kdtree", None),
("metal", 200_000),
("kdtree", 200_000),
]
def test_simulated_bvh_failure_uses_full_kdtree(tmp_path: Path):
calls = []
def fake_export(mesh, path, *, baker, target, texture_size):
calls.append((baker, target, texture_size))
if baker == "metal":
raise RuntimeError("simulated Metal BVH failure")
path.write_bytes(b"candidate")
return object(), False
_, chosen, attempts = generate_asset._run_pbr_attempts(
_mesh(800_000),
tmp_path / "candidate_pbr.glb",
preferred_baker="auto",
requested_target=None,
texture_size=1024,
export_fn=fake_export,
)
assert calls == [("metal", None, 1024), ("kdtree", None, 1024)]
assert chosen["baker"] == "kdtree"
assert chosen["target_faces"] is None
assert [attempt["status"] for attempt in attempts] == ["failed", "ok"]
def test_safety_candidate_is_only_after_both_full_attempts(tmp_path: Path):
calls = []
def fake_export(mesh, path, *, baker, target, texture_size):
calls.append((baker, target))
if target is None:
raise RuntimeError("full-resolution baker failure")
path.write_bytes(b"candidate")
return object(), True
_, chosen, _ = generate_asset._run_pbr_attempts(
_mesh(800_000),
tmp_path / "candidate_pbr.glb",
preferred_baker="auto",
requested_target=None,
texture_size=512,
export_fn=fake_export,
)
assert calls == [("metal", None), ("kdtree", None), ("metal", 200_000)]
assert chosen["technical_safety_target"] is True
assert chosen["pre_simplified_before_bvh"] is True
def test_explicit_kdtree_never_loads_metal():
assert list(generate_asset._attempt_schedule("kdtree", None, 1000)) == [("kdtree", None)]
def test_raw_export_preserves_full_topology_and_writes_vertex_normals(tmp_path: Path):
vertices = torch.tensor(
[
[-1.0, -1.0, -1.0],
[1.0, -1.0, -1.0],
[1.0, 1.0, -1.0],
[-1.0, 1.0, -1.0],
[-1.0, -1.0, 1.0],
[1.0, -1.0, 1.0],
[1.0, 1.0, 1.0],
[-1.0, 1.0, 1.0],
]
)
faces = torch.tensor(
[
[0, 1, 2], [0, 2, 3], [4, 6, 5], [4, 7, 6],
[0, 4, 5], [0, 5, 1], [1, 5, 6], [1, 6, 2],
[2, 6, 7], [2, 7, 3], [3, 7, 4], [3, 4, 0],
]
)
output = tmp_path / "raw_full.glb"
stats = generate_asset._export_raw(SimpleNamespace(vertices=vertices, faces=faces), output)
from trellis2.gltf_validation import inspect_glb
validated = inspect_glb(output, require_pbr=False)
assert stats["vertices"] == validated["vertices"] == 8
assert stats["triangles"] == validated["triangles"] == 12

103
tests/test_mlx_parity.py Normal file
View File

@ -0,0 +1,103 @@
import numpy as np
import pytest
mx = pytest.importorskip("mlx.core")
torch = pytest.importorskip("torch")
from mlx_backend.attention import MlxMultiHeadAttention
from mlx_backend.norm import LayerNorm32
from mlx_backend.rope import apply_rope
from mlx_backend.sparse_conv import MlxSparseConv3d
from mlx_backend.sparse_tensor import MlxSparseTensor
def _numpy(value):
mx.eval(value)
return np.asarray(value)
def test_layer_norm_matches_torch_on_small_float32_input():
rng = np.random.default_rng(42)
values = rng.normal(size=(3, 4, 8)).astype(np.float32)
weight = rng.normal(size=(8,)).astype(np.float32)
bias = rng.normal(size=(8,)).astype(np.float32)
layer = LayerNorm32(8, eps=1e-6)
layer.weight = mx.array(weight)
layer.bias = mx.array(bias)
actual = _numpy(layer(mx.array(values)))
expected = torch.nn.functional.layer_norm(
torch.from_numpy(values),
(8,),
torch.from_numpy(weight),
torch.from_numpy(bias),
eps=1e-6,
).numpy()
np.testing.assert_allclose(actual, expected, rtol=2e-5, atol=2e-5)
def test_rope_matches_complex_pair_reference():
rng = np.random.default_rng(7)
values = rng.normal(size=(5, 2, 8)).astype(np.float32)
phases = rng.normal(size=(5, 4)).astype(np.float32)
cos = np.cos(phases)
sin = np.sin(phases)
actual = _numpy(apply_rope(mx.array(values), mx.array(cos), mx.array(sin)))
paired = values.reshape(5, 2, 4, 2)
expected = np.empty_like(paired)
expected[..., 0] = paired[..., 0] * cos[:, None] - paired[..., 1] * sin[:, None]
expected[..., 1] = paired[..., 0] * sin[:, None] + paired[..., 1] * cos[:, None]
np.testing.assert_allclose(actual, expected.reshape(values.shape), rtol=1e-6, atol=1e-6)
def test_mlx_sdpa_matches_torch():
rng = np.random.default_rng(11)
q = rng.normal(size=(6, 2, 4)).astype(np.float32)
k = rng.normal(size=(6, 2, 4)).astype(np.float32)
v = rng.normal(size=(6, 2, 4)).astype(np.float32)
attention = MlxMultiHeadAttention(channels=8, num_heads=2)
actual = _numpy(attention._sdpa(mx.array(q), mx.array(k), mx.array(v)))
expected = torch.nn.functional.scaled_dot_product_attention(
torch.from_numpy(q).transpose(0, 1).unsqueeze(0),
torch.from_numpy(k).transpose(0, 1).unsqueeze(0),
torch.from_numpy(v).transpose(0, 1).unsqueeze(0),
scale=0.5,
)[0].transpose(0, 1).numpy()
np.testing.assert_allclose(actual, expected, rtol=3e-5, atol=3e-5)
def test_sparse_conv_matches_numpy_reference():
rng = np.random.default_rng(19)
coords = np.array(
[
[0, 0, 0, 0],
[0, 0, 0, 1],
[0, 0, 1, 0],
[0, 1, 0, 0],
[0, 1, 1, 1],
],
dtype=np.int32,
)
feats = rng.normal(size=(len(coords), 2)).astype(np.float32)
weight = rng.normal(size=(3, 3, 3, 3, 2)).astype(np.float32)
bias = rng.normal(size=(3,)).astype(np.float32)
layer = MlxSparseConv3d(2, 3, kernel_size=3)
layer.weight = mx.array(weight)
layer.bias = mx.array(bias)
sparse = MlxSparseTensor(mx.array(feats), mx.array(coords), shape=(1, 2))
actual = _numpy(layer(sparse).feats)
lookup = {tuple(coord): index for index, coord in enumerate(coords)}
expected = np.tile(bias, (len(coords), 1))
offsets = [(x, y, z) for x in (-1, 0, 1) for y in (-1, 0, 1) for z in (-1, 0, 1)]
flat_weight = weight.reshape(3, 27, 2).transpose(1, 2, 0)
for output_index, coord in enumerate(coords):
for kernel_index, offset in enumerate(offsets):
neighbor = (coord[0], coord[1] + offset[0], coord[2] + offset[1], coord[3] + offset[2])
if neighbor in lookup:
expected[output_index] += feats[lookup[neighbor]] @ flat_weight[kernel_index]
np.testing.assert_allclose(actual, expected, rtol=2e-5, atol=2e-5)

View File

@ -0,0 +1,36 @@
from pathlib import Path
from trellis2.model_revisions import (
DINOV3_REPO,
DINOV3_REVISION,
MODEL_REVISIONS,
RMBG_REPO,
RMBG_REVISION,
SOURCE_REVISIONS,
TRELLIS_REPO,
TRELLIS_REVISION,
revision_for_repo,
)
ROOT = Path(__file__).resolve().parents[1]
def test_runtime_revisions_are_full_commits():
assert MODEL_REVISIONS[TRELLIS_REPO] == TRELLIS_REVISION
assert MODEL_REVISIONS[DINOV3_REPO] == DINOV3_REVISION
assert MODEL_REVISIONS[RMBG_REPO] == RMBG_REVISION
assert all(len(revision) == 40 for revision in MODEL_REVISIONS.values())
assert all(len(revision) == 40 for revision in SOURCE_REVISIONS.values())
def test_unknown_repo_keeps_explicit_revision():
assert revision_for_repo("example/private-model", "release-1") == "release-1"
def test_source_revisions_are_present_in_reproducible_inputs():
reproducibility_text = "\n".join(
(ROOT / path).read_text(encoding="utf-8")
for path in ("scripts/setup_macos.sh", "requirements_macos_core.txt", "README.md")
)
for revision in SOURCE_REVISIONS.values():
assert revision in reproducibility_text

View File

@ -8,6 +8,8 @@ Priority: Metal (mtldiffrast) > CUDA (nvdiffrast) > CPU fallback.
Mesh: cumesh (auto-selects Metal/CUDA internally).
"""
import platform
import os
from functools import lru_cache
import torch
# ---------------------------------------------------------------------------
@ -15,6 +17,8 @@ import torch
# ---------------------------------------------------------------------------
HAS_MPS = hasattr(torch.backends, 'mps') and torch.backends.mps.is_available()
HAS_CUDA = torch.cuda.is_available()
METAL_DISABLED = os.environ.get('TRELLIS_DISABLE_METAL', '0') == '1'
BACKEND_ERRORS = {}
# ---------------------------------------------------------------------------
# Differentiable rasterizer (dr)
@ -23,20 +27,21 @@ HAS_CUDA = torch.cuda.is_available()
dr = None
_dr_backend = None
try:
import mtldiffrast.torch as _mtldr
dr = _mtldr
_dr_backend = 'metal'
except ImportError:
pass
if not METAL_DISABLED:
try:
import mtldiffrast.torch as _mtldr
dr = _mtldr
_dr_backend = 'metal'
except (ImportError, RuntimeError, OSError) as exc:
BACKEND_ERRORS['mtldiffrast'] = str(exc)
if dr is None:
try:
import nvdiffrast.torch as _nvdr
dr = _nvdr
_dr_backend = 'cuda'
except ImportError:
pass
except (ImportError, RuntimeError, OSError) as exc:
BACKEND_ERRORS['nvdiffrast'] = str(exc)
HAS_DR = dr is not None
@ -58,14 +63,15 @@ BVH = None
remesh_narrow_band_dc = None
_mesh_backend = None
try:
import cumesh
MeshBackend = cumesh.CuMesh
BVH = cumesh.cuBVH
remesh_narrow_band_dc = cumesh.remeshing.remesh_narrow_band_dc
_mesh_backend = 'metal' if platform.system() == 'Darwin' else 'cuda'
except ImportError:
pass
if not METAL_DISABLED or platform.system() != 'Darwin':
try:
import cumesh
MeshBackend = cumesh.CuMesh
BVH = cumesh.cuBVH
remesh_narrow_band_dc = cumesh.remeshing.remesh_narrow_band_dc
_mesh_backend = 'metal' if platform.system() == 'Darwin' else 'cuda'
except (ImportError, RuntimeError, OSError) as exc:
BACKEND_ERRORS['cumesh'] = str(exc)
HAS_MESH = MeshBackend is not None
HAS_REMESH = remesh_narrow_band_dc is not None
@ -93,15 +99,124 @@ except ImportError:
_flex_grid_sample_3d = None
HAS_FLEX_GEMM = False
try:
from flex_gemm.ops.grid_sample import grid_sample_3d as _fgs3d
_flex_grid_sample_3d = _fgs3d
HAS_FLEX_GEMM = True
except ImportError:
pass
if not METAL_DISABLED or platform.system() != 'Darwin':
try:
from flex_gemm.ops.grid_sample import grid_sample_3d as _fgs3d
_flex_grid_sample_3d = _fgs3d
HAS_FLEX_GEMM = True
except (ImportError, RuntimeError, OSError) as exc:
BACKEND_ERRORS['flex_gemm'] = str(exc)
# ---------------------------------------------------------------------------
# Overall backend name
# ---------------------------------------------------------------------------
BACKEND = _dr_backend or _mesh_backend or ('cpu' if HAS_TRIMESH else None)
HAS_GPU = HAS_MPS or HAS_CUDA
def _probe_result(ok, error=None, **details):
return {'ok': bool(ok), 'error': error, **details}
@lru_cache(maxsize=1)
def probe_metal_rasterizer():
"""Run a tiny UV raster instead of treating a successful import as proof."""
if METAL_DISABLED or _dr_backend != 'metal':
return _probe_result(False, 'Metal rasterizer is disabled or unavailable')
try:
positions = torch.tensor(
[
[-1.0, -1.0, 0.0, 1.0],
[1.0, -1.0, 0.0, 1.0],
[1.0, 1.0, 0.0, 1.0],
[-1.0, 1.0, 0.0, 1.0],
],
dtype=torch.float32,
)
triangles = torch.tensor([[0, 1, 2], [0, 2, 3]], dtype=torch.int32)
raster, _ = dr.rasterize(
RasterizeContext(),
positions,
triangles,
resolution=[8, 8],
)
covered = int((raster[..., 3] != 0).sum().item())
ok = tuple(raster.shape) == (1, 8, 8, 4) and covered == 64
return _probe_result(ok, None if ok else 'unexpected raster output', covered_pixels=covered)
except Exception as exc:
return _probe_result(False, f'{type(exc).__name__}: {exc}')
@lru_cache(maxsize=1)
def probe_metal_mesh_bvh():
"""Exercise both MtlMesh and an actual nearest-point MtlBVH query."""
if METAL_DISABLED or _mesh_backend != 'metal' or not HAS_MPS:
return _probe_result(False, 'Metal mesh/BVH backend is disabled or unavailable')
try:
vertices = torch.tensor(
[
[-1.0, -1.0, -1.0], [1.0, -1.0, -1.0],
[1.0, 1.0, -1.0], [-1.0, 1.0, -1.0],
[-1.0, -1.0, 1.0], [1.0, -1.0, 1.0],
[1.0, 1.0, 1.0], [-1.0, 1.0, 1.0],
],
dtype=torch.float32,
device='mps',
)
faces = torch.tensor(
[
[0, 1, 2], [0, 2, 3], [4, 6, 5], [4, 7, 6],
[0, 4, 5], [0, 5, 1], [1, 5, 6], [1, 6, 2],
[2, 6, 7], [2, 7, 3], [3, 7, 4], [3, 4, 0],
],
dtype=torch.int32,
device='mps',
)
mesh = MeshBackend()
mesh.init(vertices, faces)
mesh_vertices, mesh_faces = mesh.read()
bvh = BVH(vertices, faces)
query = torch.tensor([[0.0, 0.0, 1.2]], dtype=torch.float32, device='mps')
distance, face_id, uvw = bvh.unsigned_distance(query, return_uvw=True)
torch.mps.synchronize()
ok = (
tuple(mesh_vertices.shape) == (8, 3)
and tuple(mesh_faces.shape) == (12, 3)
and bool(torch.isfinite(distance).all().item())
and bool(torch.isfinite(uvw).all().item())
and int(face_id.item()) >= 0
)
return _probe_result(
ok,
None if ok else 'unexpected mesh/BVH output',
distance=float(distance.cpu().item()),
face_id=int(face_id.cpu().item()),
)
except Exception as exc:
return _probe_result(False, f'{type(exc).__name__}: {exc}')
def probe_metal_backends():
return {
'rasterizer': probe_metal_rasterizer(),
'mesh_bvh': probe_metal_mesh_bvh(),
}
def backend_report(run_probes=True):
"""Return a JSON-serializable snapshot used by setup and run metadata."""
report = {
'platform': platform.system(),
'mps_available': HAS_MPS,
'cuda_available': HAS_CUDA,
'metal_disabled': METAL_DISABLED,
'rasterizer': _dr_backend,
'mesh': _mesh_backend,
'flex_gemm': HAS_FLEX_GEMM,
'trimesh': HAS_TRIMESH,
'fast_simplification': HAS_FAST_SIMPLIFICATION,
'errors': dict(BACKEND_ERRORS),
}
if run_probes and platform.system() == 'Darwin':
report['capability_probes'] = probe_metal_backends()
return report

151
trellis2/gltf_validation.py Normal file
View File

@ -0,0 +1,151 @@
"""Small glTF 2.0 validator for generated TRELLIS runtime outputs."""
from __future__ import annotations
import base64
import hashlib
import io
import json
import struct
from pathlib import Path
from typing import Any
from PIL import Image
JSON_CHUNK = 0x4E4F534A
BIN_CHUNK = 0x004E4942
def _load_glb(path: Path) -> tuple[dict[str, Any], bytes, bytes]:
payload = path.read_bytes()
if len(payload) < 20:
raise ValueError(f"truncated GLB: {path}")
magic, version, declared_length = struct.unpack_from("<4sII", payload, 0)
if magic != b"glTF" or version != 2 or declared_length != len(payload):
raise ValueError(f"not a complete glTF 2.0 binary: {path}")
document = None
binary = b""
offset = 12
while offset < len(payload):
chunk_length, chunk_type = struct.unpack_from("<II", payload, offset)
offset += 8
chunk = payload[offset : offset + chunk_length]
offset += chunk_length
if chunk_type == JSON_CHUNK:
document = json.loads(chunk.rstrip(b" \t\r\n\0").decode("utf-8"))
elif chunk_type == BIN_CHUNK:
binary = chunk
if document is None:
raise ValueError(f"missing GLB JSON chunk: {path}")
return document, binary, payload
def _view(document: dict[str, Any], binary: bytes, index: int) -> bytes:
view = document["bufferViews"][index]
start = int(view.get("byteOffset", 0))
return binary[start : start + int(view["byteLength"])]
def _image(document: dict[str, Any], binary: bytes, index: int) -> bytes:
descriptor = document["images"][index]
if "bufferView" in descriptor:
return _view(document, binary, int(descriptor["bufferView"]))
uri = descriptor.get("uri", "")
if uri.startswith("data:"):
return base64.b64decode(uri.split(",", 1)[1])
raise ValueError("all runtime textures must be embedded")
def _texture_payload(document: dict[str, Any], binary: bytes, info: dict[str, Any]) -> bytes:
texture = document["textures"][int(info["index"])]
return _image(document, binary, int(texture["source"]))
def inspect_glb(path: Path, *, require_pbr: bool) -> dict[str, Any]:
document, binary, payload = _load_glb(path)
if document.get("asset", {}).get("version") != "2.0":
raise ValueError("asset.version must be 2.0")
if document.get("animations") or document.get("cameras"):
raise ValueError("static outputs must not contain animations or cameras")
if document.get("extensions", {}).get("KHR_lights_punctual", {}).get("lights"):
raise ValueError("static outputs must not contain lights")
vertices = 0
triangles = 0
bounds_min = [float("inf")] * 3
bounds_max = [float("-inf")] * 3
base_hashes = []
mr_hashes = []
primitive_count = 0
for mesh in document.get("meshes", []):
for primitive in mesh.get("primitives", []):
primitive_count += 1
if int(primitive.get("mode", 4)) != 4:
raise ValueError("only TRIANGLES primitives are supported")
attributes = primitive.get("attributes", {})
required = ["POSITION", "NORMAL"] + (["TEXCOORD_0"] if require_pbr else [])
missing = [name for name in required if name not in attributes]
if missing:
raise ValueError(f"primitive is missing attributes: {', '.join(missing)}")
position = document["accessors"][int(attributes["POSITION"])]
if not position.get("min") or not position.get("max"):
raise ValueError("POSITION accessor has no bounds")
vertices += int(position["count"])
for axis in range(3):
bounds_min[axis] = min(bounds_min[axis], float(position["min"][axis]))
bounds_max[axis] = max(bounds_max[axis], float(position["max"][axis]))
index_count = (
int(document["accessors"][int(primitive["indices"])]["count"])
if "indices" in primitive
else int(position["count"])
)
if index_count <= 0 or index_count % 3:
raise ValueError("invalid triangle index count")
triangles += index_count // 3
if require_pbr:
if "material" not in primitive:
raise ValueError("PBR primitive has no material")
material = document["materials"][int(primitive["material"])]
pbr = material.get("pbrMetallicRoughness", {})
for key, hashes in (
("baseColorTexture", base_hashes),
("metallicRoughnessTexture", mr_hashes),
):
if key not in pbr:
raise ValueError(f"PBR material is missing {key}")
texture = _texture_payload(document, binary, pbr[key])
hashes.append(hashlib.sha256(texture).hexdigest())
with Image.open(io.BytesIO(_texture_payload(document, binary, pbr["baseColorTexture"]))) as image:
if "A" not in image.getbands():
raise ValueError("base-color texture is missing generated alpha")
if primitive_count == 0 or vertices == 0 or triangles == 0:
raise ValueError("GLB contains no non-empty mesh")
dimensions = [bounds_max[index] - bounds_min[index] for index in range(3)]
if any(dimension <= 0 for dimension in dimensions):
raise ValueError("GLB has empty bounds")
return {
"sha256": hashlib.sha256(payload).hexdigest(),
"bytes": len(payload),
"vertices": vertices,
"triangles": triangles,
"primitives": primitive_count,
"bounds_min": bounds_min,
"bounds_max": bounds_max,
"dimensions": dimensions,
"base_color_image_hashes": base_hashes,
"metallic_roughness_image_hashes": mr_hashes,
}
def validate_output_pair(raw_path: Path, candidate_path: Path) -> dict[str, Any]:
raw = inspect_glb(raw_path, require_pbr=False)
candidate = inspect_glb(candidate_path, require_pbr=True)
for raw_size, candidate_size in zip(raw["dimensions"], candidate["dimensions"]):
relative_delta = abs(raw_size - candidate_size) / max(raw_size, 1e-8)
if relative_delta > 0.05:
raise ValueError(
"raw_full.glb and candidate_pbr.glb bounds differ by more than 5%"
)
return {"raw_full": raw, "candidate_pbr": candidate, "bounds_consistent": True}

View File

@ -0,0 +1,34 @@
"""Pinned Hugging Face inputs used by the supported inference CLIs."""
TRELLIS_REPO = "microsoft/TRELLIS.2-4B"
TRELLIS_REVISION = "af44b45f2e35a493886929c6d786e563ec68364d"
DINOV3_REPO = "facebook/dinov3-vitl16-pretrain-lvd1689m"
DINOV3_REVISION = "ea8dc2863c51be0a264bab82070e3e8836b02d51"
RMBG_REPO = "briaai/RMBG-2.0"
RMBG_REVISION = "5df4c9c76d8170882c34f6986e848ee07fd0ba43"
TRELLIS_IMAGE_LARGE_REPO = "microsoft/TRELLIS-image-large"
TRELLIS_IMAGE_LARGE_REVISION = "25e0d31ffbebe4b5a97464dd851910efc3002d96"
MODEL_REVISIONS = {
TRELLIS_REPO: TRELLIS_REVISION,
TRELLIS_IMAGE_LARGE_REPO: TRELLIS_IMAGE_LARGE_REVISION,
DINOV3_REPO: DINOV3_REVISION,
RMBG_REPO: RMBG_REVISION,
}
SOURCE_REVISIONS = {
"pedronaugusto/mtlgemm": "867aec8234299a7fe1ede7f802c8debe5a939a82",
"pedronaugusto/mtldiffrast": "4668cd91cb6d27f5e264731f94a06841fbf7aab8",
"pedronaugusto/mtlbvh": "23f441c470ce1f537e1fd836f3ffb5b8245f7975",
"pedronaugusto/mtlmesh": "212079e55772cff3d648a21372392c37e0643f3b",
"EasternJournalist/utils3d": "9a4eb15e4021b67b12c460c7057d642626897ec8",
"pedronaugusto/trellis2-apple": "6055b868734af6e12769d229d90580e775fae9f0",
"shivampkumar/trellis-mac": "d58628f4f5b9c3de8274cb110074154f4b31cef2",
}
def revision_for_repo(repo_id, default=None):
return MODEL_REVISIONS.get(repo_id, default)

View File

@ -1,4 +1,7 @@
import importlib
from typing import Optional
from ..model_revisions import revision_for_repo
__attributes = {
# Sparse Structure
@ -35,7 +38,14 @@ def __getattr__(name):
return globals()[name]
def from_pretrained(path: str, **kwargs):
def from_pretrained(
path: str,
*,
revision: Optional[str] = None,
cache_dir: Optional[str] = None,
local_files_only: bool = False,
**kwargs,
):
"""
Load a model from a pretrained checkpoint.
@ -57,8 +67,14 @@ def from_pretrained(path: str, **kwargs):
path_parts = path.split('/')
repo_id = f'{path_parts[0]}/{path_parts[1]}'
model_name = '/'.join(path_parts[2:])
config_file = hf_hub_download(repo_id, f"{model_name}.json")
model_file = hf_hub_download(repo_id, f"{model_name}.safetensors")
revision = revision_for_repo(repo_id, revision)
hub_kwargs = {
"revision": revision,
"cache_dir": cache_dir,
"local_files_only": local_files_only,
}
config_file = hf_hub_download(repo_id, f"{model_name}.json", **hub_kwargs)
model_file = hf_hub_download(repo_id, f"{model_name}.safetensors", **hub_kwargs)
with open(config_file, 'r') as f:
config = json.load(f)

View File

@ -8,6 +8,7 @@ from torchvision import transforms
from transformers import DINOv3ViTModel
import numpy as np
from PIL import Image
from ..model_revisions import DINOV3_REPO, DINOV3_REVISION
class DinoV2FeatureExtractor:
@ -69,9 +70,21 @@ class DinoV3FeatureExtractor:
"""
Feature extractor for DINOv3 models.
"""
def __init__(self, model_name: str, image_size=512):
def __init__(
self,
model_name: str = DINOV3_REPO,
image_size=512,
revision: Optional[str] = DINOV3_REVISION,
cache_dir: Optional[str] = None,
local_files_only: bool = False,
):
self.model_name = model_name
self.model = DINOv3ViTModel.from_pretrained(model_name)
self.model = DINOv3ViTModel.from_pretrained(
model_name,
revision=revision,
cache_dir=cache_dir,
local_files_only=local_files_only,
)
self.model.eval()
self.image_size = image_size
self.transform = transforms.Compose([

View File

@ -1,4 +1,5 @@
from typing import *
import math
import platform
import sys
@ -12,12 +13,15 @@ def __detect_defaults():
if platform.system() == 'Darwin':
if __flex_gemm_works_on_mps():
CONV = 'flex_gemm'
# flex_gemm_sparse_attn is now flash-attention-v2 with simdgroup
# matmul + simd-shuffle softmax (515× over SDPA-padded-CPU-bounce
# at every measured shape including max_seqlen=2048). The conv
# probe above covers the same package, so if it passed, the
# attention path is available too.
ATTN = 'flex_gemm_sparse_attn'
# Sparse convolution and sparse attention are separate Metal
# kernels. A working convolution install does not prove that the
# attention entry point is ABI-compatible with the active torch
# build, so select it only after its own numerical probe.
ATTN = (
'flex_gemm_sparse_attn'
if probe_flex_gemm_sparse_attention_on_mps()
else 'sdpa'
)
else:
CONV = 'pytorch'
ATTN = 'sdpa'
@ -35,6 +39,9 @@ def __flex_gemm_works_on_mps():
and move to MPS because some PyTorch builds lack int/fp16 torch.zeros
kernels on MPS."""
try:
import os
if os.environ.get('TRELLIS_DISABLE_METAL', '0') == '1':
return False
import torch
if not torch.backends.mps.is_available():
return False
@ -58,6 +65,60 @@ def __flex_gemm_works_on_mps():
except Exception:
return False
def probe_flex_gemm_sparse_attention_on_mps() -> bool:
"""Exercise the real Metal sparse-attention kernel and compare it to SDPA.
This intentionally uses the production head dimension (64) while keeping
the sequence tiny. Returning ``False`` is a controlled capability result:
callers fall back to PyTorch SDPA without disabling working Metal sparse
convolution kernels.
"""
try:
import os
if os.environ.get('TRELLIS_DISABLE_METAL', '0') == '1':
return False
import torch
import torch.nn.functional as F
if not torch.backends.mps.is_available():
return False
import flex_gemm
tokens, heads, head_dim = 16, 2, 64
generator = torch.Generator(device='cpu').manual_seed(42)
q = torch.randn(tokens, heads, head_dim, dtype=torch.float16, generator=generator).to('mps').contiguous()
k = torch.randn(tokens, heads, head_dim, dtype=torch.float16, generator=generator).to('mps').contiguous()
v = torch.randn(tokens, heads, head_dim, dtype=torch.float16, generator=generator).to('mps').contiguous()
cu_seqlens = torch.tensor([0, tokens], dtype=torch.int32).to('mps')
out = flex_gemm.kernels.cuda.sparse_attention_fwd(
q,
k,
v,
cu_seqlens,
cu_seqlens,
tokens,
tokens,
1.0 / math.sqrt(head_dim),
)
reference = F.scaled_dot_product_attention(
q.transpose(0, 1).unsqueeze(0),
k.transpose(0, 1).unsqueeze(0),
v.transpose(0, 1).unsqueeze(0),
).squeeze(0).transpose(0, 1)
torch.mps.synchronize()
return (
out.device.type == 'mps'
and out.shape == reference.shape
and bool(torch.isfinite(out).all().item())
and bool(torch.allclose(out, reference, rtol=2e-2, atol=2e-2))
)
except Exception:
return False
def __has_cuda():
try:
import torch

View File

@ -1,4 +1,5 @@
import importlib
from ..model_revisions import revision_for_repo
__attributes = {
"Trellis2ImageTo3DPipeline": "trellis2_image_to_3d",
@ -23,7 +24,7 @@ def __getattr__(name):
return globals()[name]
def from_pretrained(path: str):
def from_pretrained(path: str, **hub_kwargs):
"""
Load a pipeline from a model folder or a Hugging Face model hub.
@ -38,11 +39,13 @@ def from_pretrained(path: str):
config_file = f"{path}/pipeline.json"
else:
from huggingface_hub import hf_hub_download
config_file = hf_hub_download(path, "pipeline.json")
if hub_kwargs.get("revision") is None:
hub_kwargs["revision"] = revision_for_repo(path)
config_file = hf_hub_download(path, "pipeline.json", **hub_kwargs)
with open(config_file, 'r') as f:
config = json.load(f)
return globals()[config['name']].from_pretrained(path)
return globals()[config['name']].from_pretrained(path, **hub_kwargs)
# For PyLance

View File

@ -2,6 +2,7 @@ from typing import *
import torch
import torch.nn as nn
from .. import models
from ..model_revisions import revision_for_repo
class Pipeline:
@ -19,7 +20,15 @@ class Pipeline:
model.eval()
@classmethod
def from_pretrained(cls, path: str, config_file: str = "pipeline.json") -> "Pipeline":
def from_pretrained(
cls,
path: str,
config_file: str = "pipeline.json",
*,
revision: Optional[str] = None,
cache_dir: Optional[str] = None,
local_files_only: bool = False,
) -> "Pipeline":
"""
Load a pretrained model.
"""
@ -31,7 +40,14 @@ class Pipeline:
config_file = f"{path}/{config_file}"
else:
from huggingface_hub import hf_hub_download
config_file = hf_hub_download(path, config_file)
revision = revision_for_repo(path, revision)
config_file = hf_hub_download(
path,
config_file,
revision=revision,
cache_dir=cache_dir,
local_files_only=local_files_only,
)
with open(config_file, 'r') as f:
args = json.load(f)['args']
@ -40,13 +56,22 @@ class Pipeline:
for k, v in args['models'].items():
if hasattr(cls, 'model_names_to_load') and k not in cls.model_names_to_load:
continue
try:
_models[k] = models.from_pretrained(f"{path}/{v}")
except Exception as e:
_models[k] = models.from_pretrained(v)
is_external_model = v.count('/') >= 2
model_path = v if is_external_model else f"{path}/{v}"
_models[k] = models.from_pretrained(
model_path,
revision=None if is_external_model else revision,
cache_dir=cache_dir,
local_files_only=local_files_only,
)
new_pipeline = cls(_models)
new_pipeline._pretrained_args = args
new_pipeline._pretrained_hub_kwargs = {
"revision": revision,
"cache_dir": cache_dir,
"local_files_only": local_files_only,
}
return new_pipeline
@property

View File

@ -3,12 +3,23 @@ from transformers import AutoModelForImageSegmentation
import torch
from torchvision import transforms
from PIL import Image
from ...model_revisions import RMBG_REPO, RMBG_REVISION
class BiRefNet:
def __init__(self, model_name: str = "ZhengPeng7/BiRefNet"):
def __init__(
self,
model_name: str = RMBG_REPO,
revision: Optional[str] = RMBG_REVISION,
cache_dir: Optional[str] = None,
local_files_only: bool = False,
):
self.model = AutoModelForImageSegmentation.from_pretrained(
model_name, trust_remote_code=True,
model_name,
revision=revision,
cache_dir=cache_dir,
local_files_only=local_files_only,
trust_remote_code=True,
)
self.model.eval()
self.transform_image = transforms.Compose(
@ -40,4 +51,3 @@ class BiRefNet:
mask = pred_pil.resize(image_size)
image.putalpha(mask)
return image

View File

@ -89,15 +89,24 @@ class Trellis2ImageTo3DPipeline(Pipeline):
self._device = 'cpu'
@classmethod
def from_pretrained(cls, path: str, config_file: str = "pipeline.json") -> "Trellis2ImageTo3DPipeline":
def from_pretrained(
cls,
path: str,
config_file: str = "pipeline.json",
**hub_kwargs,
) -> "Trellis2ImageTo3DPipeline":
"""
Load a pretrained model.
Args:
path (str): The path to the model. Can be either local path or a Hugging Face repository.
"""
pipeline = super().from_pretrained(path, config_file)
pipeline = super().from_pretrained(path, config_file, **hub_kwargs)
args = pipeline._pretrained_args
dependency_hub_kwargs = {
"cache_dir": hub_kwargs.get("cache_dir"),
"local_files_only": hub_kwargs.get("local_files_only", False),
}
pipeline.sparse_structure_sampler = getattr(samplers, args['sparse_structure_sampler']['name'])(**args['sparse_structure_sampler']['args'])
pipeline.sparse_structure_sampler_params = args['sparse_structure_sampler']['params']
@ -111,8 +120,14 @@ class Trellis2ImageTo3DPipeline(Pipeline):
pipeline.shape_slat_normalization = args['shape_slat_normalization']
pipeline.tex_slat_normalization = args['tex_slat_normalization']
pipeline.image_cond_model = getattr(image_feature_extractor, args['image_cond_model']['name'])(**args['image_cond_model']['args'])
pipeline.rembg_model = getattr(rembg, args['rembg_model']['name'])(**args['rembg_model']['args'])
pipeline.image_cond_model = getattr(image_feature_extractor, args['image_cond_model']['name'])(
**args['image_cond_model']['args'],
**dependency_hub_kwargs,
)
pipeline.rembg_model = getattr(rembg, args['rembg_model']['name'])(
**args['rembg_model']['args'],
**dependency_hub_kwargs,
)
pipeline.low_vram = args.get('low_vram', True)
pipeline.default_pipeline_type = args.get('default_pipeline_type', '1024_cascade')
@ -161,6 +176,16 @@ class Trellis2ImageTo3DPipeline(Pipeline):
output_np = np.array(output)
alpha = output_np[:, :, 3]
bbox = np.argwhere(alpha > 0.8 * 255)
# Preserve deliberately soft RGBA masks. The high-confidence threshold
# is useful for RMBG output, but an authored semi-transparent object can
# legitimately have no pixels above it.
if bbox.size == 0:
bbox = np.argwhere(alpha > 0)
if bbox.size == 0:
raise RuntimeError(
"Background preprocessing produced an empty alpha mask; provide "
"a non-empty RGBA input or retry with --background keep."
)
bbox = np.min(bbox[:, 1]), np.min(bbox[:, 0]), np.max(bbox[:, 1]), np.max(bbox[:, 0])
center = (bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2
size = max(bbox[2] - bbox[0], bbox[3] - bbox[1])

View File

@ -68,15 +68,24 @@ class Trellis2TexturingPipeline(Pipeline):
self._device = 'cpu'
@classmethod
def from_pretrained(cls, path: str, config_file: str = "pipeline.json") -> "Trellis2TexturingPipeline":
def from_pretrained(
cls,
path: str,
config_file: str = "pipeline.json",
**hub_kwargs,
) -> "Trellis2TexturingPipeline":
"""
Load a pretrained model.
Args:
path (str): The path to the model. Can be either local path or a Hugging Face repository.
"""
pipeline = super().from_pretrained(path, config_file)
pipeline = super().from_pretrained(path, config_file, **hub_kwargs)
args = pipeline._pretrained_args
dependency_hub_kwargs = {
"cache_dir": hub_kwargs.get("cache_dir"),
"local_files_only": hub_kwargs.get("local_files_only", False),
}
pipeline.tex_slat_sampler = getattr(samplers, args['tex_slat_sampler']['name'])(**args['tex_slat_sampler']['args'])
pipeline.tex_slat_sampler_params = args['tex_slat_sampler']['params']
@ -84,8 +93,14 @@ class Trellis2TexturingPipeline(Pipeline):
pipeline.shape_slat_normalization = args['shape_slat_normalization']
pipeline.tex_slat_normalization = args['tex_slat_normalization']
pipeline.image_cond_model = getattr(image_feature_extractor, args['image_cond_model']['name'])(**args['image_cond_model']['args'])
pipeline.rembg_model = getattr(rembg, args['rembg_model']['name'])(**args['rembg_model']['args'])
pipeline.image_cond_model = getattr(image_feature_extractor, args['image_cond_model']['name'])(
**args['image_cond_model']['args'],
**dependency_hub_kwargs,
)
pipeline.rembg_model = getattr(rembg, args['rembg_model']['name'])(
**args['rembg_model']['args'],
**dependency_hub_kwargs,
)
pipeline.low_vram = args.get('low_vram', True)
pipeline.pbr_attr_layout = {

View File

@ -2,6 +2,7 @@ from typing import *
import torch
import torch.nn.functional as F_grid
import numpy as np
import os
from ..voxel import Voxel
from trellis2.backends import (
@ -46,7 +47,10 @@ class Mesh:
return self.to('cpu')
def _gpu_device(self):
if _HAS_MPS:
# Decoder-sized meshes have historically triggered watchdogs or
# process-level crashes in Metal cleanup kernels. Keep the supported
# default on the safe CPU implementations; advanced users may opt in.
if _HAS_MPS and os.environ.get('TRELLIS_ENABLE_MPS_DECODE_MESH_OPS') == '1':
return 'mps'
if _HAS_CUDA:
return 'cuda'