SAM 3D Body on TensorRT: an end-to-end INT8 deployment tutorial#

SAM 3D Body is Meta’s single-image full-body human-mesh-recovery model (CVPR 2026). It pairs a DINOv3 ViT-H+/16 encoder (840 M parameters — the dominant cost) with a promptable decoder + MHR parametric head that regresses an 18439-vertex human mesh.

This tutorial deploys the SAM 3D Body model with TensorRT for fast inference on NVIDIA GPUs using embedl-deploy-tensorrt. The pipeline consists of:

  1. Fetching the HuggingFace model and GitHub code.

  2. Quantizing the encoder with the Embedl-Deploy PTQ pipeline.

  3. Making the decoder + MHR head torch.onnx-exportable.

  4. Building strongly-typed TensorRT engines for both with TensorRT 11.

  5. Running a real image end-to-end on the engines, verifying fidelity against eager PyTorch, benchmarking, and rendering the recovered 3D mesh.

Results on an NVIDIA L4 (TensorRT 11.1, 512x512 input, batch 1):

encoder  INT8+FP16   ~34 ms      (FP32 eager PyTorch: ~237 ms -> 7.0x)
decoder  FP16        ~16 ms
total                ~50 ms

full-pipeline mesh error vs FP32 eager : 2.3 mm mean / 9.7 mm max

Requirements (an NVIDIA GPU with ~20 GB VRAM; ~15 GB of disk for artifacts):

pip install torch tensorrt pycuda numpy pillow opencv-python matplotlib
pip install imageio onnx huggingface_hub
pip install pytorch-lightning yacs scikit-image einops
pip install timm roma loguru optree termcolor
pip install braceexpand omegaconf onnxscript
pip install "embedl-deploy["tensorrt"]"

The facebook/sam-3d-body-dinov3 checkpoint is HuggingFace-gated: accept the license on the model page and log in (hf auth login) first.

This tutorial ships as two files that must sit in the same directory: this script and its export-utilities companion sam_3d_body_export.py. Note that the download buttons at the bottom of this page bundle only this script — fetch the companion module through the link above. Download both, then run:

python sam_3d_body.py

Every stage caches its artifact in ./artifacts/ and is skipped when the artifact already exists, so an interrupted run resumes where it stopped.

Stage 0 — configuration and public sources#

Everything the pipeline needs is public:

  • facebook/sam-3d-body-dinov3 (gated): model.ckpt with all weights, and assets/mhr_model.pt, the TorchScript MHR body rig.

  • facebookresearch/dinov3 (GitHub): the encoder architecture code, loaded through torch.hub with pretrained=False — the encoder weights come from the SAM-3D-Body checkpoint, not the DINOv3 release.

  • facebookresearch/sam-3d-body (GitHub): the decoder/head code.

import json
import statistics
import subprocess
import sys
import time
import types
import warnings
from collections.abc import Callable
from pathlib import Path

import cv2  # type: ignore[import-not-found]
import imageio.v2 as imageio  # type: ignore[import-not-found]
import matplotlib as mpl  # type: ignore[import-not-found]
import numpy as np  # type: ignore[import-not-found]
import numpy.typing as npt  # type: ignore[import-not-found]
import onnx
import pycuda.autoinit  # type: ignore[import-not-found]  # noqa: F401
import pycuda.driver as cuda  # type: ignore[import-not-found]
import tensorrt as trt
import torch
from huggingface_hub import hf_hub_download  # type: ignore[import-not-found]
from onnx import TensorProto, numpy_helper
from PIL import Image  # type: ignore[import-not-found]
from torch import nn

from embedl_deploy import transform
from embedl_deploy.backend import set_backend

set_backend("tensorrt")
from embedl_deploy.quantize import (
    CalibrationMethod,
    ModulesToSkip,
    QuantConfig,
    TensorQuantConfig,
    quantize,
)
from embedl_deploy.tensorrt import TENSORRT_PATTERNS

mpl.use("Agg")
import matplotlib.pyplot as plt  # type: ignore[import-not-found]
from matplotlib.axes import Axes  # type: ignore[import-not-found]
from matplotlib.collections import (  # type: ignore[import-not-found]
    PolyCollection,
)

# the SAM-3D-Body code (decoder, MHR head, estimator utilities), pinned to
# a known commit so runs are reproducible and the imported code is exactly
# the reviewed revision
HERE = Path(__file__).resolve().parent
REPO_URL = "https://github.com/facebookresearch/sam-3d-body"
REPO_COMMIT = "b5c765a0d89d789985e186d396315e7590887b94"
DINOV3_COMMIT = "346f38fee679c56a6888f91c51670fae61d364e0"
REPO_DIR = HERE / "sam-3d-body"
if not REPO_DIR.exists():
    print(f"fetching facebookresearch/sam-3d-body @ {REPO_COMMIT[:12]} ...")
    subprocess.run(["git", "init", "-q", str(REPO_DIR)], check=True)
    subprocess.run(
        [
            "git",
            "-C",
            str(REPO_DIR),
            "fetch",
            "-q",
            "--depth",
            "1",
            REPO_URL,
            REPO_COMMIT,
        ],
        check=True,
    )
    subprocess.run(
        ["git", "-C", str(REPO_DIR), "checkout", "-q", REPO_COMMIT],
        check=True,
    )
repo_head = subprocess.run(
    ["git", "-C", str(REPO_DIR), "rev-parse", "HEAD"],
    check=True,
    capture_output=True,
    text=True,
).stdout.strip()
if repo_head != REPO_COMMIT:
    print(
        f"    warning: sam-3d-body checkout is at {repo_head[:12]}, "
        f"expected {REPO_COMMIT[:12]}"
    )
sys.path.insert(0, str(REPO_DIR))

from sam_3d_body import load_sam_3d_body  # type: ignore[attr-defined]
from sam_3d_body.models.heads import (  # type: ignore[import-not-found]
    mhr_head as MH,
)
from sam_3d_body.models.modules import (  # type: ignore[import-not-found]
    mhr_utils as MU,
)
from sam_3d_body_export import (
    _camera_encoder_fwd_nodownsample,
    _patched_embed_keypoints,
    _patched_mhr_head_forward,
    _patched_perspective_projection,
    _patched_replace_hands_in_pose,
    _safe_compact_cont_to_model_params_body,
    _safe_compact_cont_to_model_params_hand,
    flatten_rig_for_export,
    register_ts_exporter_symbolics,
)

warnings.filterwarnings("ignore")
torch.manual_seed(0)

ART = HERE / "artifacts"
ART.mkdir(exist_ok=True)

IMG_SIZE = 512  # SAM-3D-Body production resolution (32x32 tokens)
N_CALIB = 16  # PTQ calibration batches
SAMPLE_IMAGE_URL = "https://github.com/facebookresearch/sam-3d-body/raw/main/assets/qualitative_comparisons/sample1/input_bbox.png"
SAMPLE_IMG = HERE / "sample_input.png"

FP32_PT2 = ART / "sam3dbody_backbone_fp32.pt2"
REF_FEATURES = ART / "ref_features_fp32.npy"
ENC_ONNX = ART / "embedl_sam3dbody_int8.onnx"
DEC_ONNX = ART / "sam3dbody_decoder.onnx"
FACES_NPY = ART / "sam3dbody_faces.npy"
REF_MESH = ART / "ref_mesh_fp32.npz"
STATS_PATH = ART / "sam3dbody_trt_stats.json"

IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], np.float32).reshape(3, 1, 1)
IMAGENET_STD = np.array([0.229, 0.224, 0.225], np.float32).reshape(3, 1, 1)

DEC_INPUT_NAMES = [
    "features",
    "rays_ds",
    "condition_info",
    "keypoints",
    "bbox_center",
    "bbox_scale",
    "ori_img_size",
    "cam_int",
    "affine_trans",
    "img_size",
]
DEC_OUTPUT_NAMES = [
    "pred_vertices",
    "pred_cam_t",
    "focal_length",
    "pred_keypoints_3d",
    "pred_keypoints_2d",
    "global_rot",
    "body_pose",
    "scale",
    "shape",
    "hand",
    "pred_pose_raw",
    "pred_cam",
]

STATS: dict[str, float | int | str] = {}
print("=" * 70)
print(f"torch  : {torch.__version__}")
print(f"GPU    : {torch.cuda.get_device_name(0)}")

# the gated checkpoint (weights + TorchScript MHR rig)
print("fetching facebook/sam-3d-body-dinov3 (cached after first run) ...")
CKPT_PATH = hf_hub_download("facebook/sam-3d-body-dinov3", "model.ckpt")
MHR_PATH = hf_hub_download(
    "facebook/sam-3d-body-dinov3", "assets/mhr_model.pt"
)
# read by load_sam_3d_body
hf_hub_download("facebook/sam-3d-body-dinov3", "model_config.yaml")

if not SAMPLE_IMG.exists():
    print(f"fetching sample image {SAMPLE_IMAGE_URL} ...")
    imageio.imwrite(SAMPLE_IMG, imageio.imread(SAMPLE_IMAGE_URL))


def benchmark_ms(
    fn: Callable[[], object], n: int = 50, warmup: int = 10
) -> tuple[float, float]:
    """Mean and p95 wall-clock latency of `fn` in milliseconds.

    :param fn:
        The callable to time; it must block until its work completes
        (synchronize the GPU inside).
    :param n:
        Number of timed iterations.
    :param warmup:
        Number of untimed warmup iterations.
    :returns:
        The ``(mean, p95)`` latency in milliseconds.
    """
    for _ in range(warmup):
        fn()
    times = []
    for _ in range(n):
        t0 = time.perf_counter()
        fn()
        times.append((time.perf_counter() - t0) * 1e3)
    return statistics.mean(times), sorted(times)[int(0.95 * n)]

Preprocessing — a numpy port of the upstream top-down transforms#

SAM 3D Body is a top-down method: it takes a person bounding box, expands it by 1.25x, fixes the aspect ratio, and warps the crop to 512x512. The decoder is additionally conditioned on the camera: a ray direction per feature cell (computed from a default full-image intrinsic matrix) and a CLIFF-style bbox-location vector. We reproduce those transforms in plain numpy so the deployed pipeline has no PyTorch dependency at inference time.

def read_rgb(path: Path) -> npt.NDArray[np.uint8]:
    """Load an image file as an RGB uint8 array."""
    img = cv2.imread(str(path))
    assert img is not None, f"cannot read image {path}"
    return np.asarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB), dtype=np.uint8)


def _fix_aspect_ratio(
    scale: npt.NDArray[np.float32], ratio: float
) -> npt.NDArray[np.float32]:
    """Grow the smaller side of `scale` to match the aspect `ratio`."""
    w, h = scale
    return np.array(
        [w, w / ratio] if w > h * ratio else [h * ratio, h], np.float32
    )


def _third(
    a: npt.NDArray[np.float32], b: npt.NDArray[np.float32]
) -> npt.NDArray[np.float32]:
    """Return ``b + (a - b)`` rotated 90 degrees anticlockwise."""
    d = a - b
    return b + np.array([-d[1], d[0]], np.float32)


def _warp_matrix(
    center: npt.NDArray[np.float32],
    scale: npt.NDArray[np.float32],
    out: int = IMG_SIZE,
) -> npt.NDArray[np.float64]:
    """Upstream ``get_warp_matrix`` with rot=0 (bbox_utils)."""
    # numpy broadcast add, not list concatenation
    src = np.stack([center, center + [0, -0.5 * scale[0]]])  # noqa: RUF005
    src = src.astype(np.float32)
    dst = np.array([[out / 2, out / 2], [out / 2, 0.0]], np.float32)
    src = np.vstack([src, _third(src[0], src[1])])
    dst = np.vstack([dst, _third(dst[0], dst[1])])
    # float64, as upstream
    return np.asarray(cv2.getAffineTransform(src, dst), dtype=np.float64)


def preprocess(
    image_path: Path, bbox: tuple[int, int, int, int] | None = None
) -> tuple[
    npt.NDArray[np.float32],
    dict[str, npt.NDArray[np.float32]],
    npt.NDArray[np.uint8],
]:
    """Turn an image and person bbox into encoder and decoder inputs.

    :param image_path:
        Path to the input image.
    :param bbox:
        Person bounding box ``(x1, y1, x2, y2)``; the full image when
        omitted.
    :returns:
        The normalized ``(1, 3, 512, 512)`` encoder input, the decoder
        conditioning feed dict (everything except ``features``), and the
        RGB crop for visualization.
    """
    img = read_rgb(image_path)
    h, w = img.shape[:2]
    x1, y1, x2, y2 = bbox or (0, 0, w, h)

    center = np.array([(x1 + x2) / 2, (y1 + y2) / 2], np.float32)
    # GetBBoxCenterScale then TopdownAffine, as in the upstream pipeline
    scale = np.array([x2 - x1, y2 - y1], np.float32) * 1.25
    scale = _fix_aspect_ratio(_fix_aspect_ratio(scale, 0.75), 1.0)
    A = _warp_matrix(center, scale)
    crop = np.asarray(
        cv2.warpAffine(img, A, (IMG_SIZE, IMG_SIZE), flags=cv2.INTER_LINEAR),
        dtype=np.uint8,
    )
    x = (
        crop.transpose(2, 0, 1).astype(np.float32) / 255.0 - IMAGENET_MEAN
    ) / IMAGENET_STD

    # default full-image intrinsics: focal = image diagonal, principal point
    # at the image center (upstream's no-calibration fallback)
    focal = float(np.hypot(w, h))
    cam = np.array(
        [[[focal, 0, w / 2], [0, focal, h / 2], [0, 0, 1]]], np.float32
    )

    # camera-ray conditioning: the ray (x,y) for every crop pixel, antialias-
    # downsampled to the 32x32 feature grid (matches the upstream
    # CameraEncoder's F.interpolate(..., antialias=True))
    gx, gy = np.meshgrid(
        np.arange(IMG_SIZE), np.arange(IMG_SIZE), indexing="xy"
    )
    grid = np.stack([gx, gy], axis=2).astype(np.float32)
    A32 = A.astype(np.float32)
    diag, trans = A32[[0, 1], [0, 1]], A32[[0, 1], [2, 2]]
    grid = grid / diag - trans / diag  # crop pixel -> image pixel
    grid = (grid - cam[0, [0, 1], [2, 2]]) / cam[0, [0, 1], [0, 1]]  # -> ray
    n = IMG_SIZE // 16
    rays_ds = np.stack(
        [
            np.asarray(
                Image.fromarray(grid[:, :, c]).resize(
                    (n, n), Image.Resampling.BILINEAR
                )
            )
            for c in range(2)
        ]
    )[None]

    # CLIFF condition, intrinsic-center variant
    cond = np.array(
        [
            [
                (center[0] - cam[0, 0, 2]) / focal,
                (center[1] - cam[0, 1, 2]) / focal,
                scale[0] / focal,
            ]
        ],
        np.float32,
    )
    feeds = {
        "rays_ds": rays_ds.astype(np.float32),
        "condition_info": cond,
        "keypoints": np.array([[[0.0, 0.0, -2.0]]], np.float32),  # dummy
        "bbox_center": center.reshape(1, 1, 2),
        "bbox_scale": scale.reshape(1, 1, 2),
        "ori_img_size": np.array([[[w, h]]], np.float32),
        "cam_int": cam,
        "affine_trans": A32.reshape(1, 1, 2, 3),
        "img_size": np.full((1, 1, 2), IMG_SIZE, np.float32),
    }
    return x[None], feeds, crop


print("\n[stage 0] preprocessing the sample image ...")
enc_input, dec_feeds, crop_rgb = preprocess(SAMPLE_IMG)
print(
    f"    encoder input {enc_input.shape}, decoder conditioning "
    f"{sorted(dec_feeds)}"
)

Stage 1 — the encoder, with the real checkpoint weights#

The checkpoint stores the whole model; the encoder lives under the backbone.encoder. prefix. We wrap it: plain tensors in, plain tensors out, which is what torch.export and the Embedl PTQ flow expect.

Why deploy the encoder separately from the decoder? The full SAM3DBody.forward mixes data-dependent control flow (body/hand batch selection), so it doesn’t fit a single static graph. The encoder is a clean image-in/features-out function and carries most of the compute, so it is the natural quantization target; the decoder is exported as a second, FP16-only graph in stage 3.

class BackboneWrapper(nn.Module):
    """Tensor-in/tensor-out wrapper around the DINOv3 encoder."""

    def __init__(self, encoder: nn.Module) -> None:
        """Wrap the DINOv3 `encoder`."""
        super().__init__()
        self.encoder = encoder

    def forward(self, image: torch.Tensor) -> torch.Tensor:
        """Return the final 32x32 feature map for `image`."""
        feats = self.encoder.get_intermediate_layers(  # type: ignore[operator]
            image, n=1, reshape=True, norm=True
        )
        return feats[-1]  # type: ignore[no-any-return]  # (B, 1280, 32, 32)


def load_encoder_with_real_weights(device: str) -> nn.Module:
    """Build the DINOv3 architecture and load the SAM-3D-Body weights.

    :param device:
        Device to place the encoder on.
    :returns:
        The encoder in eval mode with the checkpoint weights loaded.
    """
    encoder = torch.hub.load(  # type: ignore[no-untyped-call]
        f"facebookresearch/dinov3:{DINOV3_COMMIT}",
        "dinov3_vith16plus",
        source="github",
        pretrained=False,
        trust_repo=True,
        skip_validation=True,  # ref is a pinned commit, not a branch
    ).eval()
    ckpt = torch.load(CKPT_PATH, map_location="cpu", weights_only=False)
    state = ckpt.get("state_dict", ckpt)
    backbone_state = {
        k.removeprefix("backbone.encoder."): v
        for k, v in state.items()
        if k.startswith("backbone.encoder.")
    }
    missing, unexpected = encoder.load_state_dict(backbone_state, strict=False)
    # the checkpoint omits only the mask_token, which inference never uses
    assert set(missing) <= {"mask_token"}, f"missing encoder keys: {missing}"
    assert not unexpected, f"checkpoint keys not in encoder: {unexpected}"
    return encoder.eval().to(device)  # type: ignore[no-any-return]


if not (FP32_PT2.exists() and REF_FEATURES.exists()):
    print("\n[stage 1] loading the DINOv3 ViT-H+/16 encoder (real weights)...")
    model = BackboneWrapper(load_encoder_with_real_weights("cuda"))
    n_params = sum(p.numel() for p in model.parameters())
    print(
        f"    {n_params / 1e6:.1f}M params, {n_params * 4 / 1e9:.2f} GB FP32"
    )

    x = torch.from_numpy(enc_input).cuda()
    with torch.no_grad():
        ref = model(x)
    np.save(REF_FEATURES, ref.float().cpu().numpy())
    print(f"    FP32 reference features {tuple(ref.shape)} saved")

    # eager FP32 latency -- the baseline the TRT engine is measured against
    def _run_eager() -> None:
        with torch.no_grad():
            model(x)
        torch.cuda.synchronize()

    STATS["encoder_fp32_eager_ms"], _ = benchmark_ms(
        _run_eager, n=20, warmup=5
    )
    print(f"    eager FP32 latency: {STATS['encoder_fp32_eager_ms']:.1f} ms")

    print("    torch.export.export(encoder) ...")
    dummy = torch.randn(1, 3, IMG_SIZE, IMG_SIZE, device="cuda")
    with torch.no_grad():
        exported = torch.export.export(model, (dummy,), strict=False)
    torch.export.save(exported, str(FP32_PT2))
    print(f"    saved {FP32_PT2.name}")
    del model, exported, ref
    torch.cuda.empty_cache()
else:
    print("\n[stage 1] reusing cached FP32 export + reference features")

Stage 2 — INT8 post-training quantization with Embedl-Deploy#

The standard Embedl-Deploy recipe — transform fuses the exported graph into TensorRT-friendly patterns, quantize inserts Q/DQ nodes and calibrates them — plus three customizations that matter for this transformer:

  • Skip the patch-embed conv. The 3-channel input projection gains almost nothing from INT8 and loses accuracy disproportionately.

  • Don’t SmoothQuant the LayerNorms. Smoothing folds scales into LayerNorm weights in-place; on this backbone that hurts more than it helps, so LayerNorm modules are excluded via ModulesToSkip(smooth=...).

  • Calibrate on the real crop. MINMAX with 16 lightly-jittered copies of the actual person crop gives ranges that match deployment.

Note

To keep the tutorial self-contained, PTQ calibrates on jittered copies of a single image. In a realistic deployment you would calibrate on a few hundred representative person crops from your target domain — for example a sample of the datasets SAM 3D Body itself is trained and evaluated on, such as COCO, MPII, AI Challenger, 3DPW, EgoHumans, EgoExo4D, Harmony4D, and SA-1B (see data/scripts in the upstream sam-3d-body repository for download helpers).

Note that the final INT8 coverage is decided by the backend’s precision propagation inside quantize: sites whose TensorRT neighbours cannot run INT8 (e.g., around scaled-dot-product attention) are settled back to FP16, so the exported graph carries Q/DQ only where TensorRT will actually use it.

The quantized graph is re-exported with torch.export and saved to ONNX with external data (the model is >2 GB, above protobuf’s limit).

def export_onnx_external(
    module: nn.Module, args: tuple[torch.Tensor, ...], onnx_path: Path
) -> None:
    """Dynamo-export to ONNX with all tensors in one external .data file.

    :param module:
        The module to export.
    :param args:
        Example positional inputs for the export.
    :param onnx_path:
        Destination path; the tensor data lands next to it in
        ``<name>.data``.
    """
    tmp = onnx_path.with_suffix(".tmp.onnx")
    with torch.no_grad():
        torch.onnx.export(
            module,
            args,
            str(tmp),
            input_names=["image"],
            output_names=["features"],
            do_constant_folding=True,
            dynamo=True,
        )
    m = onnx.load(str(tmp), load_external_data=True)
    onnx.save_model(
        m,
        str(onnx_path),
        save_as_external_data=True,
        all_tensors_to_one_file=True,
        location=onnx_path.name + ".data",
        size_threshold=1024,
    )
    tmp.unlink(missing_ok=True)
    for p in onnx_path.parent.glob(tmp.name + "*"):
        p.unlink(missing_ok=True)


if not ENC_ONNX.exists():
    print("\n[stage 2] Embedl-Deploy transform + INT8 PTQ ...")

    dummy = torch.randn(1, 3, IMG_SIZE, IMG_SIZE, device="cuda")
    graph = torch.export.load(str(FP32_PT2)).module()
    fused = transform(graph, (dummy,), TENSORRT_PATTERNS).model.eval()

    # the only 3-channel conv in the graph is the 16x16 patch embed
    patch_embed = next(
        m
        for m in fused.modules()
        if isinstance(m, nn.Conv2d) and m.in_channels == 3
    )
    quant_cfg = QuantConfig(
        activation=TensorQuantConfig(  # INT8, per-tensor activations
            symmetric=True,
            per_channel=False,
            calibration_method=CalibrationMethod.MINMAX,
        ),
        weight=TensorQuantConfig(  # INT8, per-channel weights
            symmetric=True,
            per_channel=True,
            calibration_method=CalibrationMethod.MINMAX,
        ),
        skip=ModulesToSkip(
            stub={patch_embed},
            weight={patch_embed},
            smooth={nn.LayerNorm},
        ),
    )

    real = torch.from_numpy(enc_input).cuda()
    calib = [real + 0.02 * torch.randn_like(real) for _ in range(N_CALIB)]

    def calibrate(m: nn.Module) -> None:
        """Run the calibration batches through the model."""
        m.eval()
        with torch.no_grad():
            for c in calib:
                m(c)

    print(f"    calibrating on {N_CALIB} jittered copies of the real crop ...")
    qmodel = quantize(
        fused,
        (dummy,),
        config=quant_cfg,
        forward_loop=calibrate,
        freeze_weights=True,
    )

    # fidelity of the QDQ graph itself (before TensorRT enters the picture)
    with torch.no_grad():
        q_feats = qmodel(real).float().cpu().numpy()
    ref = np.load(REF_FEATURES)
    rel = np.abs(q_feats - ref).mean() / (np.abs(ref).mean() + 1e-9)
    STATS["encoder_qdq_rel_diff"] = float(rel)
    print(f"    QDQ-vs-FP32 feature rel diff: {rel * 100:.3f}%")

    print("    exporting INT8 ONNX (external data) ...")
    with torch.no_grad():
        q_exported = torch.export.export(qmodel, (dummy,), strict=False)
    export_onnx_external(q_exported.module(), (dummy,), ENC_ONNX)
    sz = (
        ENC_ONNX.stat().st_size
        + (ART / (ENC_ONNX.name + ".data")).stat().st_size
    )
    print(f"    saved {ENC_ONNX.name} (+ .data, {sz / 1e9:.2f} GB)")
    del graph, fused, qmodel, q_exported
    torch.cuda.empty_cache()
else:
    print("\n[stage 2] reusing cached INT8 encoder ONNX")

Stage 3 — making the decoder + MHR head exportable#

The decoder (no-mask prompt add, ray-conditioned camera encoder, 6 promptable decoder layers with per-layer MHR head + camera projection) is wrapped with pure tensor I/O, but cannot be exported as-is:

  1. The MHR rig is TorchScript. torch.jit.load-ed modules can’t be traced through. We run the rig once under make_fx(functionalize(...)) to record the dispatched aten ops into a flat FX graph, then clean up the artifacts of that flattening (constant sparse tensors -> dense, in-place copies -> broadcasts, index_copy -> scatter with baked indices).

  2. Advanced-index assignment (x[:, idx] = v, boolean-mask writes) exports through a broken index_put lowering. Every such site is replaced by a numerically-identical gather/scatter/where formulation.

  3. roma’s rotmat->euler handles gimbal lock with data-dependent nonzero() indexing, which bakes trace-time element counts into the graph. A branchless torch.where version replaces it.

  4. Antialiased ray downsampling inside the CameraEncoder — TensorRT’s Resize has no antialias, so rays are downsampled on the host (stage 0) and passed in at feature resolution.

The machinery that fixes all four — FX graph surgery, ONNX-safe method patches, and custom exporter symbolics — lives in the companion module sam_3d_body_export.py. Each patch is numerically identical to the code it replaces (the rig flattening self-checks on five probe input sets before it is accepted), so here we only apply it and export.

class BodyDecoderWrapper(nn.Module):
    """No-mask prompt add + camera encoder + 6-layer promptable decoder (with
    per-layer MHR head, camera projection, and keypoint-token updates) -> final
    pose/mesh outputs. Batch size 1, one dummy keypoint prompt (label -2), no
    mask — exactly the body-only inference configuration.
    """

    def __init__(self, model: nn.Module) -> None:
        """Wrap the full SAM3DBody `model`."""
        super().__init__()
        self.model = model

    def forward(  # noqa: PLR0913 -- one argument per ONNX input
        self,
        features: torch.Tensor,
        rays_ds: torch.Tensor,
        condition_info: torch.Tensor,
        keypoints: torch.Tensor,
        bbox_center: torch.Tensor,
        bbox_scale: torch.Tensor,
        ori_img_size: torch.Tensor,
        cam_int: torch.Tensor,
        affine_trans: torch.Tensor,
        img_size: torch.Tensor,
    ) -> tuple[torch.Tensor, ...]:
        """Run the decoder on one person crop. Input shapes::

        features: (1, 1280, 32, 32) backbone output rays_ds: (1, 2, 32, 32)
        downsampled camera rays condition_info: (1, 3) CLIFF condition
        keypoints: (1, 1, 3) prompt (dummy: [0, 0, -2]) bbox_center: (1, 1, 2)
        bbox_scale: (1, 1, 2) ori_img_size: (1, 1, 2) cam_int: (1, 3, 3)
        affine_trans: (1, 1, 2, 3) img_size: (1, 1, 2)
        """
        m = self.model
        m._batch_size, m._max_num_person = 1, 1  # type: ignore[assignment]  # noqa: SLF001
        m.body_batch_idx, m.hand_batch_idx = [0], []  # type: ignore[assignment]

        # use_mask=False path of _get_mask_prompt reduces to a constant add
        no_mask = m.prompt_encoder.no_mask_embed.weight.reshape(1, -1, 1, 1)  # type: ignore[operator, union-attr]
        features = features + no_mask

        batch = {
            # only img.shape[1] (num_person) is read
            "img": features.new_zeros(1, 1, 1),
            "ray_cond": rays_ds,
            "bbox_center": bbox_center,
            "bbox_scale": bbox_scale,
            "ori_img_size": ori_img_size,
            "cam_int": cam_int,
            "affine_trans": affine_trans,
            "img_size": img_size,
        }
        _tokens, pose_outputs = m.forward_decoder(  # type: ignore[operator]
            features,
            init_estimate=None,
            keypoints=keypoints,
            prev_estimate=None,
            condition_info=condition_info,
            batch=batch,
        )
        po = pose_outputs[-1]
        return tuple(po[k] for k in DEC_OUTPUT_NAMES)


def apply_export_patches(model: nn.Module) -> None:
    """Swap every export-hostile site for an ONNX-safe equivalent.

    Every patch is numerically identical to the code it replaces for the
    body-only inference configuration the tutorial exports.

    :param model:
        The ``SAM3DBody`` model, patched in place.
    """
    # rebind in both namespaces: mhr_head imports these by name, so patching
    # the mhr_utils module attribute alone would not reach its call sites
    for mod in (MU, MH):
        mod.compact_cont_to_model_params_body = (
            _safe_compact_cont_to_model_params_body
        )
        mod.compact_cont_to_model_params_hand = (
            _safe_compact_cont_to_model_params_hand
        )
    model.ray_cond_emb.forward = types.MethodType(  # type: ignore[union-attr]
        _camera_encoder_fwd_nodownsample, model.ray_cond_emb
    )
    model.head_pose.replace_hands_in_pose = types.MethodType(  # type: ignore[union-attr]
        _patched_replace_hands_in_pose, model.head_pose
    )
    model.head_pose.forward = types.MethodType(  # type: ignore[union-attr]
        _patched_mhr_head_forward, model.head_pose
    )
    model.head_camera.perspective_projection = types.MethodType(  # type: ignore[union-attr]
        _patched_perspective_projection, model.head_camera
    )
    model.prompt_encoder._embed_keypoints = types.MethodType(  # type: ignore[union-attr]  # noqa: SLF001
        _patched_embed_keypoints, model.prompt_encoder
    )


if not (DEC_ONNX.exists() and FACES_NPY.exists() and REF_MESH.exists()):
    print("\n[stage 3] exporting the decoder + MHR head to ONNX ...")

    # everything on CPU: the flattened rig bakes its trace device into the
    # graph, and the ONNX constant folder needs single-device constants
    body_model, _cfg = load_sam_3d_body(
        CKPT_PATH, device="cpu", mhr_path=MHR_PATH
    )
    np.save(FACES_NPY, body_model.head_pose.faces.cpu().numpy())
    apply_export_patches(body_model)
    wrapper = BodyDecoderWrapper(body_model).eval()

    ref_features = np.load(REF_FEATURES)
    dec_args = tuple(
        torch.from_numpy({"features": ref_features, **dec_feeds}[k])
        for k in DEC_INPUT_NAMES
    )

    print("    flattening the TorchScript MHR rig to FX ...")
    flatten_rig_for_export(wrapper, body_model.head_pose, MHR_PATH, dec_args)

    # FP32 eager reference mesh for the real sample -- the ground truth that
    # the TensorRT pipeline is compared against in stage 5
    with torch.no_grad():
        ref_out = dict(zip(DEC_OUTPUT_NAMES, wrapper(*dec_args), strict=True))
    np.savez(
        REF_MESH,
        vertices=ref_out["pred_vertices"][0].numpy(),
        cam_t=ref_out["pred_cam_t"][0].numpy(),
    )
    print(
        f"    FP32 reference mesh: {ref_out['pred_vertices'].shape[1]} "
        f"vertices"
    )

    register_ts_exporter_symbolics()
    print("    exporting ONNX (TorchScript tracer, CPU) ...")
    with torch.no_grad():
        torch.onnx.export(
            wrapper,
            dec_args,
            str(DEC_ONNX),
            input_names=DEC_INPUT_NAMES,
            output_names=DEC_OUTPUT_NAMES,
            opset_version=17,
            dynamo=False,
            do_constant_folding=True,
        )
    print(
        f"    saved {DEC_ONNX.name} ({DEC_ONNX.stat().st_size / 1e6:.0f} MB)"
    )
    del body_model, wrapper
else:
    print("\n[stage 3] reusing cached decoder ONNX + reference mesh")

Stage 4 — TensorRT engines#

TensorRT 11 builds strongly-typed networks: every layer keeps the precision declared in the ONNX file, and the old FP16/INT8 builder flags are gone. So we set precisions in the ONNX itself:

  • FP32 tensors, Cast targets, and inline constants are rewritten to FP16 (clipped to the FP16 range). The encoder’s Q/DQ INT8 structure is untouched — those layers run INT8, the rest FP16.

  • The decoder’s float64 mesh-rig section (FK chains accumulate error) is demoted to FP32, not FP16, to keep the skinning math accurate.

The cast and the engine build each happen once and are cached on disk.

TRT_LOG = trt.Logger(trt.Logger.WARNING)
ENC_ENGINE = ART / "embedl_sam3dbody_int8.engine"
DEC_ENGINE = ART / "sam3dbody_decoder.engine"

ELEM_REMAP = {
    TensorProto.FLOAT: TensorProto.FLOAT16,
    TensorProto.DOUBLE: TensorProto.FLOAT,
}


def _retype_tensor(t: onnx.TensorProto) -> None:
    """Rewrite a tensor fp32 -> clipped fp16 or fp64 -> fp32, in place."""
    if t.data_type == TensorProto.FLOAT:
        arr = np.clip(numpy_helper.to_array(t), -65504, 65504).astype(
            np.float16
        )
        t.CopyFrom(numpy_helper.from_array(arr, t.name))
    elif t.data_type == TensorProto.DOUBLE:
        arr = numpy_helper.to_array(t).astype(np.float32)
        t.CopyFrom(numpy_helper.from_array(arr, t.name))


def cast_onnx_to_fp16(onnx_path: Path) -> Path:
    """Cast an fp32 ONNX file to fp16 for a strongly-typed TensorRT build.

    fp64 becomes fp32 and fp32 becomes fp16 across initializers, value infos,
    Cast targets, and inline tensor attributes; the Q/DQ INT8 structure is
    untouched.

    :param onnx_path:
        The fp32 ONNX file to cast.
    :returns:
        The path of the cast copy (cached across runs).
    """
    fp16_path = onnx_path.with_name(onnx_path.stem + "_fp16.onnx")
    if fp16_path.exists():
        return fp16_path
    print(f"    casting {onnx_path.name} to fp16 (one-time) ...")
    model = onnx.load(str(onnx_path))
    g = model.graph
    for init in g.initializer:
        _retype_tensor(init)
    for vi in list(g.input) + list(g.output) + list(g.value_info):
        t = vi.type.tensor_type
        t.elem_type = ELEM_REMAP.get(t.elem_type, t.elem_type)
    for node in g.node:
        for a in node.attribute:
            if node.op_type == "Cast" and a.name == "to":
                a.i = ELEM_REMAP.get(a.i, a.i)
            elif a.type == onnx.AttributeProto.TENSOR:
                _retype_tensor(a.t)
    onnx.save(
        model,
        str(fp16_path),
        save_as_external_data=True,
        location=fp16_path.name + ".data",
        all_tensors_to_one_file=True,
    )
    return fp16_path


def build_engine(onnx_path: Path, engine_path: Path) -> trt.ICudaEngine:
    """Build (or load cached) a strongly-typed fp16 engine from fp32 ONNX.

    :param onnx_path:
        The fp32 ONNX file (cast to fp16 on first use).
    :param engine_path:
        Where the serialized engine is cached.
    :returns:
        The deserialized TensorRT engine.
    """
    trt.init_libnvinfer_plugins(TRT_LOG, "")
    if engine_path.exists():
        return trt.Runtime(TRT_LOG).deserialize_cuda_engine(
            engine_path.read_bytes()
        )
    onnx_path = cast_onnx_to_fp16(onnx_path)
    print(f"    building {engine_path.name} from {onnx_path.name} ...")
    builder = trt.Builder(TRT_LOG)
    network = builder.create_network(
        1 << int(trt.NetworkDefinitionCreationFlag.STRONGLY_TYPED)
    )
    parser = trt.OnnxParser(network, TRT_LOG)
    if not parser.parse_from_file(str(onnx_path)):
        raise RuntimeError(
            "\n".join(
                str(parser.get_error(i)) for i in range(parser.num_errors)
            )
        )
    cfg = builder.create_builder_config()
    cfg.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 4 * 1024**3)
    t0 = time.perf_counter()
    plan = builder.build_serialized_network(network, cfg)
    if plan is None:
        raise RuntimeError("engine build failed")
    engine_path.write_bytes(bytes(plan))
    print(
        f"    {engine_path.name}: built in {time.perf_counter() - t0:.0f}s, "
        f"{engine_path.stat().st_size / 1e6:.0f} MB"
    )
    return trt.Runtime(TRT_LOG).deserialize_cuda_engine(bytes(plan))

Stage 5 — the TensorRT demo: real image in, 3D mesh out#

A small pycuda runner drives both engines: pinned host buffers, one device buffer per I/O tensor, execute_async_v3 on a private stream. Latency is measured on engine execution only (inputs already on device), which is the number that matters when the pipeline runs inside a larger system.

class Engine:
    """Single-context TensorRT runner with engine-only latency measurement."""

    def __init__(self, onnx_path: Path, engine_path: Path) -> None:
        """Build or load the engine and allocate its I/O buffers."""
        self.engine = build_engine(onnx_path, engine_path)
        self.ctx = self.engine.create_execution_context()
        self.stream = cuda.Stream()
        self.host: dict[str, npt.NDArray[np.generic]] = {}
        self.dev: dict[str, cuda.DeviceAllocation] = {}
        self.shapes: dict[str, tuple[int, ...]] = {}
        self.inputs: list[str] = []
        self.outputs: list[str] = []
        for i in range(self.engine.num_io_tensors):
            nm = self.engine.get_tensor_name(i)
            shape = tuple(self.engine.get_tensor_shape(nm))
            dt = trt.nptype(self.engine.get_tensor_dtype(nm))
            self.host[nm] = cuda.pagelocked_empty(int(np.prod(shape)) or 1, dt)
            self.dev[nm] = cuda.mem_alloc(max(self.host[nm].nbytes, 8))
            self.shapes[nm] = shape
            self.ctx.set_tensor_address(nm, int(self.dev[nm]))
            is_in = self.engine.get_tensor_mode(nm) == trt.TensorIOMode.INPUT
            (self.inputs if is_in else self.outputs).append(nm)

    def __call__(
        self, feeds: dict[str, npt.NDArray[np.float32]]
    ) -> dict[str, npt.NDArray[np.float32]]:
        """Run one inference on `feeds` and return the fp32 outputs."""
        for nm in self.inputs:
            np.copyto(
                self.host[nm], feeds[nm].ravel().astype(self.host[nm].dtype)
            )
            cuda.memcpy_htod_async(self.dev[nm], self.host[nm], self.stream)
        self.ctx.execute_async_v3(self.stream.handle)
        for nm in self.outputs:
            cuda.memcpy_dtoh_async(self.host[nm], self.dev[nm], self.stream)
        self.stream.synchronize()
        return {
            nm: self.host[nm]
            .reshape(self.shapes[nm])
            .astype(np.float32)
            .copy()
            for nm in self.outputs
        }

    def benchmark(self) -> tuple[float, float]:
        """Mean and p95 engine-only latency in milliseconds."""

        def _run() -> None:
            self.ctx.execute_async_v3(self.stream.handle)
            self.stream.synchronize()

        return benchmark_ms(_run)


print("\n[stage 4] TensorRT engines ...")
encoder = Engine(ENC_ONNX, ENC_ENGINE)
decoder = Engine(DEC_ONNX, DEC_ENGINE)

print("\n[stage 5] running the sample end to end on TensorRT ...")
features = encoder({"image": enc_input})["features"]
out = decoder({**dec_feeds, "features": features})
V = out["pred_vertices"][0]
cam_t = out["pred_cam_t"][0]
print(f"    recovered mesh: {V.shape[0]} vertices")

# fidelity vs the eager FP32 reference
ref_feats = np.load(REF_FEATURES)
feat_rel = np.abs(features - ref_feats).mean() / (
    np.abs(ref_feats).mean() + 1e-9
)
ref_mesh = np.load(REF_MESH)
mesh_d = np.abs(V - ref_mesh["vertices"])
STATS.update(
    {
        "encoder_trt_rel_diff": float(feat_rel),
        "mesh_max_mm": float(mesh_d.max() * 1000),
        "mesh_mean_mm": float(mesh_d.mean() * 1000),
    }
)
print(
    f"    encoder features INT8-TRT vs FP32 eager: "
    f"rel diff {feat_rel * 100:.3f}%"
)
print(
    f"    full-pipeline mesh vs FP32 eager: "
    f"max {mesh_d.max() * 1000:.2f} mm  mean {mesh_d.mean() * 1000:.3f} mm"
)

enc_ms, enc_p95 = encoder.benchmark()
dec_ms, dec_p95 = decoder.benchmark()
STATS.update(
    {
        "encoder_trt_ms": enc_ms,
        "encoder_trt_p95_ms": enc_p95,
        "decoder_trt_ms": dec_ms,
        "decoder_trt_p95_ms": dec_p95,
        "tensorrt": trt.__version__,
        "torch": torch.__version__,
        "gpu": torch.cuda.get_device_name(0),
        "input_size": IMG_SIZE,
    }
)

Rendering — mesh overlay + turntable#

The mesh is projected back onto the crop with the same intrinsics + affine used in preprocessing, flat-shaded, and painter-sorted (no GL dependency).

LIGHT = np.array([0.3, 0.5, 1.0])
LIGHT /= np.linalg.norm(LIGHT)
SKIN = np.array([0.80, 0.78, 0.72])


def _shade(
    v: npt.NDArray[np.float32], f: npt.NDArray[np.int64]
) -> npt.NDArray[np.float64]:
    """Per-face flat-shading colors from a fixed directional light."""
    n = np.cross(v[f][:, 1] - v[f][:, 0], v[f][:, 2] - v[f][:, 0])
    n /= np.linalg.norm(n, axis=1, keepdims=True) + 1e-9
    lam = np.clip(np.abs(n @ LIGHT), 0, 1)[:, None]
    return np.clip(0.25 + 0.75 * lam * SKIN, 0, 1)


def _view(
    ax: Axes,
    verts: npt.NDArray[np.float32],
    faces: npt.NDArray[np.int64],
    deg: float,
    title: str,
) -> None:
    """Draw the mesh into `ax`, rotated `deg` degrees about the y axis."""
    Vc = verts - verts.mean(0)
    th = np.radians(deg)
    R = np.array(
        [[np.cos(th), 0, np.sin(th)], [0, 1, 0], [-np.sin(th), 0, np.cos(th)]]
    )
    Vr = Vc @ R.T
    p = Vr[:, :2] * [1, -1]
    o = np.argsort(Vr[faces].mean(1)[:, 2])
    ax.add_collection(
        PolyCollection(
            p[faces][o], facecolors=_shade(Vr, faces)[o], edgecolors="none"
        )
    )
    ax.set_xlim(p[:, 0].min(), p[:, 0].max())
    ax.set_ylim(p[:, 1].min(), p[:, 1].max())
    ax.set_aspect("equal")
    ax.axis("off")
    ax.set_title(title, fontsize=11)


def render_overlay(
    image_path: Path,
    verts: npt.NDArray[np.float32],
    faces: npt.NDArray[np.int64],
    cam_t: npt.NDArray[np.float32],
    crop: npt.NDArray[np.uint8],
    cam_int: npt.NDArray[np.float32],
    affine: npt.NDArray[np.float32],
    out: Path,
) -> None:
    """Save a 4-panel figure: input, crop overlay, and two mesh views.

    :param image_path:
        Path of the original input image.
    :param verts:
        Mesh vertices, shape ``(N, 3)``.
    :param faces:
        Mesh faces, shape ``(M, 3)``.
    :param cam_t:
        Predicted camera translation, shape ``(3,)``.
    :param crop:
        The 512x512 RGB model crop from preprocessing.
    :param cam_int:
        Camera intrinsics used in preprocessing, shape ``(1, 3, 3)``.
    :param affine:
        The full-image-to-crop affine from preprocessing.
    :param out:
        Destination PNG path.
    """
    img = read_rgb(image_path)
    fig, ax = plt.subplots(1, 4, figsize=(15, 6))
    fig.patch.set_facecolor("white")
    ax[0].imshow(img)
    ax[0].axis("off")
    ax[0].set_title("Input", fontsize=11)
    # project with the camera intrinsics, then map full-image pixels into the
    # model crop with the same affine used for preprocessing
    Vc = verts + cam_t
    z = np.clip(Vc[:, 2], 1e-3, None)
    f, cx, cy = cam_int[0, 0, 0], cam_int[0, 0, 2], cam_int[0, 1, 2]
    pf = np.stack(
        [f * Vc[:, 0] / z + cx, f * Vc[:, 1] / z + cy, np.ones_like(z)], 1
    )
    A = affine.reshape(2, 3).astype(np.float64)
    p = pf @ A.T
    o = np.argsort(-Vc[faces].mean(1)[:, 2])
    ax[1].imshow(crop)
    ax[1].add_collection(
        PolyCollection(
            p[faces][o],
            facecolors=_shade(Vc, faces)[o],
            edgecolors="none",
            alpha=0.8,
        )
    )
    ax[1].set_xlim(0, IMG_SIZE)
    ax[1].set_ylim(IMG_SIZE, 0)
    ax[1].axis("off")
    ax[1].set_title("Mesh overlay", fontsize=11)
    _view(ax[2], verts, faces, 20, "¾ view")
    _view(ax[3], verts, faces, 90, "side view")
    plt.tight_layout()
    plt.savefig(out, dpi=160, bbox_inches="tight")
    plt.close()
    print(f"    wrote {out}")


def render_turntable(
    verts: npt.NDArray[np.float32],
    faces: npt.NDArray[np.int64],
    out: Path,
) -> None:
    """Save a rotating-mesh GIF (one frame every 15 degrees).

    :param verts:
        Mesh vertices, shape ``(N, 3)``.
    :param faces:
        Mesh faces, shape ``(M, 3)``.
    :param out:
        Destination GIF path.
    """
    frames = []
    for a in range(0, 360, 15):
        fig, ax = plt.subplots(figsize=(4, 6))
        fig.patch.set_facecolor("white")
        _view(ax, verts, faces, a, "")
        fig.canvas.draw()
        rgba = fig.canvas.buffer_rgba()
        frames.append(np.asarray(rgba)[..., :3].copy())
        plt.close()
    imageio.mimsave(out, frames, duration=0.1, loop=0)
    print(f"    wrote {out}")


faces = np.load(FACES_NPY)
render_overlay(
    SAMPLE_IMG,
    V,
    faces,
    cam_t,
    crop_rgb,
    dec_feeds["cam_int"],
    dec_feeds["affine_trans"],
    ART / "mesh_demo_trt.png",
)
render_turntable(V, faces, ART / "mesh_demo_trt_spin.gif")

Summary#

STATS_PATH.write_text(json.dumps(STATS, indent=2))
eager = STATS.get("encoder_fp32_eager_ms")
eager_ms = eager if isinstance(eager, float) else None
print("\n" + "=" * 72)
print(
    f"SAM 3D Body on TensorRT {trt.__version__} -- "
    f"{torch.cuda.get_device_name(0)}, {IMG_SIZE}x{IMG_SIZE}"
)
print("=" * 72)
print(f"{'Stage':<28}{'Mean':>10}{'p95':>10}{'Engine':>12}")
print("-" * 72)
if eager_ms:
    print(
        f"{'encoder FP32 (eager torch)':<28}{eager_ms:>7.2f} ms"
        f"{'--':>10}{'--':>12}"
    )
for name, ms, p95, engine_path in (
    ("encoder INT8+FP16 (TRT)", enc_ms, enc_p95, ENC_ENGINE),
    ("decoder FP16 (TRT)", dec_ms, dec_p95, DEC_ENGINE),
):
    print(
        f"{name:<28}{ms:>7.2f} ms{p95:>7.2f} ms"
        f"{engine_path.stat().st_size / 2**20:>8.0f} MiB"
    )
print(f"{'total (TRT)':<28}{enc_ms + dec_ms:>7.2f} ms")
if eager_ms:
    print(f"\nencoder speedup vs eager FP32: {eager_ms / enc_ms:.2f}x")
print(
    f"encoder features rel diff (INT8 engine vs FP32 eager): "
    f"{STATS['encoder_trt_rel_diff'] * 100:.3f}%"
)
print(
    f"mesh error vs FP32 eager pipeline: mean "
    f"{STATS['mesh_mean_mm']:.3f} mm, max {STATS['mesh_max_mm']:.2f} mm"
)
print("=" * 72)
print(f"\nresults written to {STATS_PATH}")

Gallery generated by Sphinx-Gallery