#!/usr/bin/env python3 """Export the official TripoSplat Flux2 VAE encoder as a fixed-shape ONNX graph. Graph contract: * ``image_rgb``: ``float32`` or ``float16`` ``[1, 3, 1024, 1024]`` in ``[0, 1]`` * ``epsilon``: matching dtype ``[1, 32, 128, 128]`` * ``feature2``: ``float32 [1, 4101, 128]`` The graph includes image range conversion, stochastic reparameterization with the explicit epsilon input, 2x2 latent packing, the checkpointed BatchNorm running-stat normalization, flatten/transpose, and the five-token DINO alignment prefix. """ from __future__ import annotations import argparse import hashlib import inspect import os import stat import subprocess import tempfile from pathlib import Path from typing import Any from flux2_vae_common import ( IMAGE_SHAPE, LATENT_SHAPE, OFFICIAL_REPOSITORY_URL, OUTPUT_SHAPE, choose_torch_device, load_official_encoder, make_explicit_noise_encoder, ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--triposplat-repo", type=Path, required=True, help=f"Local clone of {OFFICIAL_REPOSITORY_URL} (must contain model.py).", ) parser.add_argument( "--weights", type=Path, required=True, help="Local flux2-vae.safetensors checkpoint from VAST-AI/TripoSplat.", ) parser.add_argument( "--output", type=Path, default=Path("public/models/triposplat/flux2_vae_encoder.onnx"), help="Destination ONNX graph (default: %(default)s).", ) parser.add_argument( "--precision", choices=("fp32", "fp16"), default="fp32", help=( "Graph parameter/input precision. feature2 remains float32 exactly as in " "the official pipeline (default: %(default)s)." ), ) parser.add_argument( "--device", choices=("cpu", "mps", "cuda", "auto"), default="cpu", help="PyTorch device used while tracing (default: %(default)s).", ) parser.add_argument( "--opset", type=int, default=20, help="ONNX opset version (default: %(default)s).", ) parser.add_argument( "--external-data", choices=("always", "auto", "inline"), default="always", help=( "Weight storage: one consolidated .onnx.data file, automatic based on " "the initial export, or inline in the graph (default: %(default)s)." ), ) parser.add_argument( "--external-data-threshold", type=int, default=1024, metavar="BYTES", help=( "In 'always' mode, keep tensors smaller than this inline while putting all " "larger tensors in one data file (default: %(default)s)." ), ) parser.add_argument( "--skip-check", action="store_true", help="Skip onnx.checker after consolidation (not recommended).", ) parser.add_argument( "--verbose", action="store_true", help="Enable verbose PyTorch ONNX export logging.", ) args = parser.parse_args() if args.opset < 18: parser.error("--opset must be at least 18 for scaled dot-product attention export") if args.external_data_threshold < 0: parser.error("--external-data-threshold must be non-negative") return args def source_commit(repo: Path) -> str: """Return the checked-out source commit without making Git a hard dependency.""" try: result = subprocess.run( ["git", "-C", str(repo), "rev-parse", "HEAD"], check=True, capture_output=True, text=True, ) except (OSError, subprocess.CalledProcessError): return "unknown" return result.stdout.strip() or "unknown" def set_metadata(model_proto: Any, values: dict[str, str]) -> None: """Replace this tool's metadata keys while retaining unrelated metadata.""" retained = {entry.key: entry.value for entry in model_proto.metadata_props} retained.update(values) del model_proto.metadata_props[:] for key, value in sorted(retained.items()): entry = model_proto.metadata_props.add() entry.key = key entry.value = value def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as stream: while block := stream.read(8 * 1024 * 1024): digest.update(block) return digest.hexdigest() def staged_graph_uses_external_data(onnx: Any, graph_path: Path) -> bool: proto = onnx.load_model(str(graph_path), load_external_data=False) return any( tensor.data_location == onnx.TensorProto.EXTERNAL for tensor in proto.graph.initializer ) def fixed_value_shape(value_info: Any) -> tuple[int | None, ...]: tensor_type = value_info.type.tensor_type return tuple( int(dimension.dim_value) if dimension.HasField("dim_value") else None for dimension in tensor_type.shape.dim ) def verify_fixed_contract(onnx: Any, model_proto: Any, precision: str) -> None: """Reject an export whose public names, element types, or axes drifted.""" inputs = {value.name: value for value in model_proto.graph.input} outputs = {value.name: value for value in model_proto.graph.output} if set(inputs) != {"image_rgb", "epsilon"}: raise RuntimeError(f"Exported ONNX inputs are {sorted(inputs)}, not image_rgb/epsilon") if set(outputs) != {"feature2"}: raise RuntimeError(f"Exported ONNX outputs are {sorted(outputs)}, not feature2") expected_input_type = ( onnx.TensorProto.FLOAT16 if precision == "fp16" else onnx.TensorProto.FLOAT ) contracts = ( (inputs["image_rgb"], IMAGE_SHAPE, expected_input_type), (inputs["epsilon"], LATENT_SHAPE, expected_input_type), (outputs["feature2"], OUTPUT_SHAPE, onnx.TensorProto.FLOAT), ) for value_info, expected_shape, expected_type in contracts: observed_shape = fixed_value_shape(value_info) observed_type = value_info.type.tensor_type.elem_type if observed_shape != expected_shape: raise RuntimeError( f"Exported {value_info.name} shape is {observed_shape}, expected {expected_shape}" ) if observed_type != expected_type: raise RuntimeError( f"Exported {value_info.name} ONNX element type is {observed_type}, " f"expected {expected_type}" ) def consolidate_and_publish( onnx: Any, staged_export: Path, output_path: Path, external_mode: str, external_threshold: int, metadata: dict[str, str], run_checker: bool, expected_precision: str | None = None, ) -> list[Path]: """Consolidate exporter shards, validate, then atomically publish artifacts.""" output_path.parent.mkdir(parents=True, exist_ok=True) data_filename = output_path.name + ".data" final_data_path = output_path.parent / data_filename original_was_external = staged_graph_uses_external_data(onnx, staged_export) model_proto = onnx.load_model(str(staged_export), load_external_data=True) # onnx.save_model can otherwise preserve the exporter's shard locations. First # materialize every initializer, then choose one explicit publication policy. onnx.external_data_helper.convert_model_from_external_data(model_proto) set_metadata(model_proto, metadata) if expected_precision is not None: verify_fixed_contract(onnx, model_proto, expected_precision) write_external = external_mode == "always" or ( external_mode == "auto" and original_was_external ) with tempfile.TemporaryDirectory( dir=str(output_path.parent), prefix=".flux2-vae-publish-", ) as publish_dir_string: publish_dir = Path(publish_dir_string) publish_graph = publish_dir / output_path.name if write_external: threshold = external_threshold if external_mode == "always" else 0 onnx.save_model( model_proto, str(publish_graph), save_as_external_data=True, all_tensors_to_one_file=True, location=data_filename, size_threshold=threshold, convert_attribute=False, ) publish_data = publish_dir / data_filename if not publish_data.is_file(): raise RuntimeError( "ONNX requested external data but did not create the consolidated " f"artifact {publish_data}" ) else: onnx.save_model(model_proto, str(publish_graph), save_as_external_data=False) publish_data = None if run_checker: # Checking the path (rather than only the in-memory protobuf) also verifies # that every external-data offset resolves in the consolidated file. onnx.checker.check_model(str(publish_graph), full_check=True) # Publish data first and the graph second, so the final graph never points at a # missing new data file. os.replace is atomic on the destination filesystem. if publish_data is not None: os.replace(publish_data, final_data_path) os.replace(publish_graph, output_path) if not write_external and final_data_path.exists(): # This is only the exact sidecar name owned by this graph, never a broad cleanup. final_data_path.unlink() artifacts = [output_path, final_data_path] if write_external else [output_path] # TemporaryDirectory files can inherit mode 0600. Make static model artifacts # readable by a CDN/web-server user while preserving all existing permission bits. for artifact in artifacts: artifact.chmod(artifact.stat().st_mode | stat.S_IRGRP | stat.S_IROTH) return artifacts def export_graph(args: argparse.Namespace) -> list[Path]: try: import onnx import torch except ImportError as exc: raise SystemExit( "Missing export dependency. Install a PyTorch-supported Python version and " "run `python -m pip install -r scripts/triposplat/requirements.txt`. " f"Original error: {exc}" ) from exc repo = args.triposplat_repo.expanduser().resolve() weights = args.weights.expanduser().resolve() output = args.output.expanduser().resolve() device = choose_torch_device(torch, args.device) dtype = torch.float16 if args.precision == "fp16" else torch.float32 print(f"Loading official Flux2VAEEncoder from {repo}") encoder = load_official_encoder( torch=torch, triposplat_repo=repo, weights=weights, device=device, precision=args.precision, ) graph = make_explicit_noise_encoder(torch, encoder).eval().to(device) image = torch.zeros(IMAGE_SHAPE, dtype=dtype, device=device) epsilon = torch.zeros(LATENT_SHAPE, dtype=dtype, device=device) output.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory( dir=str(output.parent), prefix=".flux2-vae-export-", ) as export_dir_string: staged_export = Path(export_dir_string) / output.name kwargs: dict[str, Any] = { "export_params": True, "do_constant_folding": True, "opset_version": args.opset, "input_names": ["image_rgb", "epsilon"], "output_names": ["feature2"], "verbose": args.verbose, } export_signature = inspect.signature(torch.onnx.export) if "dynamo" in export_signature.parameters: # The legacy tracer is deliberately selected: it has stable fixed-shape # SDPA lowering and does not add an onnxscript dependency. kwargs["dynamo"] = False if "external_data" in export_signature.parameters: # Let PyTorch shard if needed; consolidation below normalizes the result. kwargs["external_data"] = True print( f"Tracing {args.precision} graph on {device} with inputs " f"{IMAGE_SHAPE} and {LATENT_SHAPE}" ) with torch.inference_mode(): torch.onnx.export( graph, (image, epsilon), str(staged_export), **kwargs, ) metadata = { "triposplat.component": "flux2_vae_encoder", "triposplat.source_repository": OFFICIAL_REPOSITORY_URL, "triposplat.source_commit": source_commit(repo), "triposplat.precision": args.precision, "triposplat.image_input": "image_rgb NCHW [1,3,1024,1024] RGB [0,1]", "triposplat.epsilon_input": "epsilon NCHW [1,32,128,128]", "triposplat.output": "feature2 [1,4101,128] float32", "triposplat.stochastic_sampling": "mean + exp(0.5 * logvar) * epsilon", } artifacts = consolidate_and_publish( onnx=onnx, staged_export=staged_export, output_path=output, external_mode=args.external_data, external_threshold=args.external_data_threshold, metadata=metadata, run_checker=not args.skip_check, expected_precision=args.precision, ) print(f"Output contract: feature2 float32 {OUTPUT_SHAPE}") for artifact in artifacts: print( f"Wrote {artifact} ({artifact.stat().st_size:,} bytes, " f"sha256={sha256(artifact)})" ) return artifacts def main() -> None: export_graph(parse_args()) if __name__ == "__main__": main()