#!/usr/bin/env python3 """Compare the TripoSplat Flux2 VAE ONNX slice with official PyTorch output. The same image and explicit epsilon tensor are fed to both runtimes. The command prints absolute/relative error metrics, enforces configurable allclose tolerances, and can save a JSON report plus an NPZ fixture for browser-side comparison. """ from __future__ import annotations import argparse import json import platform import sys import time 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, deterministic_inputs, load_input_fixture, load_official_encoder, make_explicit_noise_encoder, synchronize_torch, tensor_type_to_precision, validate_input_arrays, write_json, ) 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 used for the ONNX export.", ) parser.add_argument( "--onnx", type=Path, required=True, help="Exported fixed-shape Flux2 VAE ONNX graph.", ) parser.add_argument( "--fixture", type=Path, help=( "Optional NPZ containing image_rgb [1,3,1024,1024] and epsilon " "[1,32,128,128]. If omitted, deterministic synthetic inputs are used." ), ) parser.add_argument( "--seed", type=int, default=20260516, help="Seed for synthetic epsilon when --fixture is omitted (default: %(default)s).", ) parser.add_argument( "--precision", choices=("auto", "fp32", "fp16"), default="auto", help="Expected graph precision; auto reads the ONNX input type (default: %(default)s).", ) parser.add_argument( "--device", choices=("cpu", "mps", "cuda", "auto"), default="cpu", help="Device for the official PyTorch reference (default: %(default)s).", ) parser.add_argument( "--provider", action="append", dest="providers", help=( "Python ONNX Runtime execution provider. Repeat to set fallback order " "(default: CPUExecutionProvider)." ), ) parser.add_argument( "--atol", type=float, help="Absolute allclose tolerance (default: 6e-3 fp32, 2e-2 fp16).", ) parser.add_argument( "--rtol", type=float, help="Relative allclose tolerance (default: 1e-3 fp32, 2e-2 fp16).", ) parser.add_argument( "--report", type=Path, help="Optional JSON metrics report destination.", ) parser.add_argument( "--save-fixture", type=Path, help=( "Optional NPZ destination containing graph-typed image_rgb/epsilon and " "the official float32 feature2 reference for browser parity tests." ), ) parser.add_argument( "--session-threads", type=int, default=0, help="ORT intra-op thread count; zero leaves the runtime default (default: %(default)s).", ) args = parser.parse_args() if args.atol is not None and args.atol < 0: parser.error("--atol must be non-negative") if args.rtol is not None and args.rtol < 0: parser.error("--rtol must be non-negative") if args.session_threads < 0: parser.error("--session-threads must be non-negative") return args def require_fixed_contract(session: Any) -> str: inputs = {item.name: item for item in session.get_inputs()} outputs = {item.name: item for item in session.get_outputs()} if set(inputs) != {"image_rgb", "epsilon"}: raise ValueError( f"ONNX inputs are {sorted(inputs)}; expected exactly image_rgb and epsilon" ) if set(outputs) != {"feature2"}: raise ValueError(f"ONNX outputs are {sorted(outputs)}; expected exactly feature2") if tuple(inputs["image_rgb"].shape) != IMAGE_SHAPE: raise ValueError( f"ONNX image_rgb shape is {inputs['image_rgb'].shape}; expected {IMAGE_SHAPE}" ) if tuple(inputs["epsilon"].shape) != LATENT_SHAPE: raise ValueError( f"ONNX epsilon shape is {inputs['epsilon'].shape}; expected {LATENT_SHAPE}" ) if tuple(outputs["feature2"].shape) != OUTPUT_SHAPE: raise ValueError( f"ONNX feature2 shape is {outputs['feature2'].shape}; expected {OUTPUT_SHAPE}" ) image_precision = tensor_type_to_precision(inputs["image_rgb"].type) epsilon_precision = tensor_type_to_precision(inputs["epsilon"].type) if image_precision != epsilon_precision: raise ValueError( "image_rgb and epsilon use different ONNX element types: " f"{inputs['image_rgb'].type} vs {inputs['epsilon'].type}" ) if outputs["feature2"].type != "tensor(float)": raise ValueError( f"ONNX feature2 type is {outputs['feature2'].type}; expected tensor(float)" ) return image_precision def array_summary(array: Any) -> dict[str, float]: import numpy as np values = np.asarray(array, dtype=np.float64) return { "min": float(values.min()), "max": float(values.max()), "mean": float(values.mean()), "std": float(values.std()), "l2_norm": float(np.linalg.norm(values.ravel())), } def comparison_metrics(reference: Any, candidate: Any, atol: float, rtol: float) -> dict[str, Any]: import numpy as np reference64 = np.asarray(reference, dtype=np.float64) candidate64 = np.asarray(candidate, dtype=np.float64) if reference64.shape != candidate64.shape: raise ValueError( f"Output shape mismatch: PyTorch {reference64.shape}, ORT {candidate64.shape}" ) reference_finite = bool(np.isfinite(reference64).all()) candidate_finite = bool(np.isfinite(candidate64).all()) if not reference_finite or not candidate_finite: return { "passed": False, "reference_finite": reference_finite, "candidate_finite": candidate_finite, "count": int(reference64.size), } delta = candidate64 - reference64 absolute = np.abs(delta) # The floor keeps relative-error metrics interpretable around reference zeros. relative = absolute / np.maximum(np.abs(reference64), 1e-6) allowed = atol + rtol * np.abs(reference64) within = absolute <= allowed ref_flat = reference64.ravel() candidate_flat = candidate64.ravel() denominator = float(np.linalg.norm(ref_flat) * np.linalg.norm(candidate_flat)) cosine = float(np.dot(ref_flat, candidate_flat) / denominator) if denominator else 1.0 worst_flat_index = int(np.argmax(absolute)) worst_index = tuple(int(v) for v in np.unravel_index(worst_flat_index, absolute.shape)) return { "passed": bool(within.all()), "reference_finite": reference_finite, "candidate_finite": candidate_finite, "count": int(reference64.size), "max_absolute_error": float(absolute.max()), "mean_absolute_error": float(absolute.mean()), "rmse": float(np.sqrt(np.mean(delta * delta))), "max_relative_error_at_1e-6_floor": float(relative.max()), "mean_relative_error_at_1e-6_floor": float(relative.mean()), "cosine_similarity": cosine, "fraction_within_tolerance": float(within.mean()), "worst_index": list(worst_index), "worst_reference": float(reference64[worst_index]), "worst_candidate": float(candidate64[worst_index]), "worst_allowed_error": float(allowed[worst_index]), } def make_session(ort: Any, graph_path: Path, providers: list[str], threads: int) -> Any: available = set(ort.get_available_providers()) missing = [provider for provider in providers if provider not in available] if missing: raise RuntimeError( f"Requested ORT providers are unavailable: {missing}. Available: {sorted(available)}" ) options = ort.SessionOptions() options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL if threads: options.intra_op_num_threads = threads return ort.InferenceSession(str(graph_path), sess_options=options, providers=providers) def validate(args: argparse.Namespace) -> dict[str, Any]: try: import numpy as np import onnxruntime as ort import torch except ImportError as exc: raise SystemExit( "Missing validation dependency. Install a PyTorch-supported Python version " "and run `python -m pip install -r scripts/triposplat/requirements.txt`. " f"Original error: {exc}" ) from exc graph_path = args.onnx.expanduser().resolve() if not graph_path.is_file(): raise FileNotFoundError(f"ONNX graph does not exist: {graph_path}") providers = args.providers or ["CPUExecutionProvider"] session = make_session(ort, graph_path, providers, args.session_threads) graph_precision = require_fixed_contract(session) if args.precision != "auto" and args.precision != graph_precision: raise ValueError( f"--precision {args.precision} conflicts with ONNX inputs ({graph_precision})" ) precision = graph_precision atol = args.atol if args.atol is not None else (6e-3 if precision == "fp32" else 2e-2) rtol = args.rtol if args.rtol is not None else (1e-3 if precision == "fp32" else 2e-2) if args.fixture: image, epsilon, fixture_expected = load_input_fixture(args.fixture) input_source = str(args.fixture.expanduser().resolve()) else: image, epsilon = deterministic_inputs(args.seed) fixture_expected = None input_source = f"deterministic synthetic inputs (seed={args.seed})" validate_input_arrays(image, epsilon) numpy_dtype = np.float16 if precision == "fp16" else np.float32 image_feed = np.ascontiguousarray(image.astype(numpy_dtype, copy=False)) epsilon_feed = np.ascontiguousarray(epsilon.astype(numpy_dtype, copy=False)) device = choose_torch_device(torch, args.device) torch_dtype = torch.float16 if precision == "fp16" else torch.float32 print(f"Loading official PyTorch encoder ({precision}) on {device}") encoder = load_official_encoder( torch=torch, triposplat_repo=args.triposplat_repo, weights=args.weights, device=device, precision=precision, ) reference_model = make_explicit_noise_encoder(torch, encoder).eval().to(device) torch_image = torch.from_numpy(image_feed).to(device=device, dtype=torch_dtype) torch_epsilon = torch.from_numpy(epsilon_feed).to(device=device, dtype=torch_dtype) synchronize_torch(torch, device) torch_started = time.perf_counter() with torch.inference_mode(): reference_tensor = reference_model(torch_image, torch_epsilon) synchronize_torch(torch, device) torch_duration_ms = (time.perf_counter() - torch_started) * 1000.0 reference = reference_tensor.detach().cpu().numpy().astype(np.float32, copy=False) ort_started = time.perf_counter() candidate = session.run( ["feature2"], {"image_rgb": image_feed, "epsilon": epsilon_feed}, )[0] ort_duration_ms = (time.perf_counter() - ort_started) * 1000.0 candidate = np.asarray(candidate, dtype=np.float32) if tuple(reference.shape) != OUTPUT_SHAPE: raise ValueError(f"PyTorch output shape is {reference.shape}; expected {OUTPUT_SHAPE}") if tuple(candidate.shape) != OUTPUT_SHAPE: raise ValueError(f"ORT output shape is {candidate.shape}; expected {OUTPUT_SHAPE}") metrics = comparison_metrics(reference, candidate, atol=atol, rtol=rtol) fixture_reference_metrics = None overall_passed = bool(metrics["passed"]) if fixture_expected is not None: fixture_reference_metrics = comparison_metrics( fixture_expected, reference, atol=atol, rtol=rtol, ) # A fixture with a saved reference is part of the contract too. A stale # checkpoint/fixture must not be hidden by fresh PyTorch-vs-ORT agreement. overall_passed = bool(overall_passed and fixture_reference_metrics["passed"]) report: dict[str, Any] = { "passed": overall_passed, "contract": { "image_rgb": {"shape": list(IMAGE_SHAPE), "range": [0.0, 1.0], "precision": precision}, "epsilon": {"shape": list(LATENT_SHAPE), "precision": precision}, "feature2": {"shape": list(OUTPUT_SHAPE), "precision": "fp32"}, }, "inputs": { "source": input_source, "image_rgb": array_summary(image_feed), "epsilon": array_summary(epsilon_feed), }, "comparison": metrics, "fixture_reference_comparison": fixture_reference_metrics, "outputs": { "pytorch": array_summary(reference), "onnxruntime": array_summary(candidate), }, "tolerance": {"atol": atol, "rtol": rtol}, "measured_single_run_ms": { "pytorch": torch_duration_ms, "onnxruntime_python": ort_duration_ms, "note": "Validation timings are measured single runs, not browser benchmarks.", }, "runtime": { "python": sys.version.split()[0], "platform": platform.platform(), "torch": torch.__version__, "onnxruntime": ort.__version__, "torch_device": str(device), "ort_providers": session.get_providers(), "onnx": str(graph_path), }, } if args.save_fixture: fixture_path = args.save_fixture.expanduser().resolve() fixture_path.parent.mkdir(parents=True, exist_ok=True) np.savez_compressed( fixture_path, image_rgb=image_feed, epsilon=epsilon_feed, feature2=reference, metadata=np.asarray( json.dumps( { "precision": precision, "image_range": [0.0, 1.0], "source": "official TripoSplat Flux2VAEEncoder PyTorch reference", }, sort_keys=True, ) ), ) report["saved_fixture"] = str(fixture_path) print(f"Wrote browser parity fixture {fixture_path}") if args.report: write_json(args.report, report) print(f"Wrote report {args.report.expanduser().resolve()}") print(json.dumps(report["comparison"], indent=2, sort_keys=True)) print( "PASS: official PyTorch and Python ONNX Runtime agree" if report["passed"] else "FAIL: output is outside the requested tolerances" ) return report def main() -> None: report = validate(parse_args()) if not report["passed"]: raise SystemExit(1) if __name__ == "__main__": main()