Skip to content

SAM3 → ONNX → Triton Inference Server: full image-model deployment (text, box + cross-crop exemplars, interactive PVS) #609

Description

@ilanmotiei

Sharing a deployment path that I couldn't find elsewhere in the issues here: SAM3's image model exported to ONNX and served on NVIDIA Triton Inference Server, with the full prompting surface exposed as runtime inputs.

https://github.com/ilanmotiei/sam3-triton (MIT)

Several excellent ONNX/TensorRT exports already exist (#224, #159). What this adds is the serving layer — real ONNX graphs on Triton's ONNX backend with config.pbtxt and ensembles, rather than a python-backend wrapper around the PyTorch code — plus the parts of the image model that usually get dropped in an export.

The split

images_u8[1,3,H,W] ─► sam3_unified_encoder ─► 7 vision tensors ┐
token_ids[1,32] ────► sam3_text ────────────► 3 text tensors  ─┼─► sam3_decoder ─► sam3_postprocess ─► RLE
box / concept exemplars ─► gate ────────────► 3 tensors ───────┘
  • The ViT backbone (~1.9 GB) runs once per image; every prompt is a cheap runtime input on the grounding head.
  • One backbone serves both heads — the grounding neck and the PVS (interactive) neck — instead of shipping two ~1.9 GB encoders.
  • SAM3's BPE tokenizer can't be represented in ONNX, so it stays client-side and the ensemble takes token_ids. Since it pads to 32, every text tensor is fixed-shape — no dynamic axes anywhere.

What it covers

text prompts open-vocabulary, prompt is a runtime input
box exemplars positive and negative, in the same image
cross-crop concepts encode an appearance on crop A, reuse it on crop B (see below)
multi-keyword encode once, decode per keyword server-side
interactive (PVS) point / box / mask prompts, plus automatic mask generation

The cross-crop concept split

This one was the interesting part. In sam3_decoder, the geometry encoder lives inside the graph and pools the current image's features at the box — so a box exemplar is a pointer into the image being segmented, and a box from crop A prompts with garbage on crop B.

Splitting forward_grounding at the boundary where geo_feats is consumed turns the concept into a portable tensor:

sam3_concept_encoder : 7 vision + boxes         -> concept_tokens[18,1,256]
sam3_decoder_ct      : 7 vision + text + tokens -> the same head outputs

Feeding the encoder's output into sam3_decoder_ct reproduces the original decoder bit-for-bit on the same image (torch max|diff| = 0, 200/200 query agreement). The tokens are image-independent once produced, so you can bank a concept and reuse it on any raster later.

Results

Real output on the bundled sample tile (1008×1008), reproducible with examples.py:

segment

segment(tile, "building") → 142 instances.

multi

multi(tile, ["building", "road", "vegetation"]) → 142 / 13 / 65, one vision pass.

Export gotchas that might save someone time

  • aten::view_as_complex — the vitdet RoPE uses complex rotary embeddings ONNX can't represent. sam3_rope_patch.py swaps in an algebraic real/imag rotation.
  • aten::_pin_memory NYI in the fake-tensor tracer — made identity (value-neutral host transfer).
  • GuardOnDataDependentSymNode in the decoder — forcing the static-cache path avoids the assert-only guards.
  • The 0-box/0-point dummy prompt exports to a rank-mismatched Add that ORT rejects; passing one fully-masked box+point keeps text-only results identical.

The Python client

The repo also ships sam3_triton_client, so you're not hand-rolling gRPC tensor plumbing. It runs the BPE tokenizer, frames the image, and decodes the server's condensed RLE payload back into masks.

pip install ./client
from sam3_triton_client import Sam3Client
c = Sam3Client("localhost:8001")          # gRPC; connect_timeout= blocks until models load

Everything returns a pandas.DataFrame with box ([x1,y1,x2,y2] in the original image's pixels), score, and a full-frame HxW mask — so results drop straight into pandas/numpy without any coordinate bookkeeping on your side.

Text prompt — all instances of a concept:

df = c.segment("sample_tile.jpg", "building")
                            box  score                     mask
0     [107.0, 0.0, 173.0, 44.0]  0.796  <1008x1008 px, 2277 fg>
1    [194.0, 59.0, 231.0, 90.0]  0.785   <1008x1008 px, 941 fg>
... 142 rows total

Box exemplars — steer the concept with examples in the same image. Not a spatial filter: a positive box means "things that look like this", a negative one suppresses an appearance.

c.segment(tile, "building", exemplar_boxes=[POS])                                  # 133
c.segment(tile, "building", exemplar_boxes=[POS, NEG], exemplar_labels=[1, 0])     # 132
c.segment(tile, None,       exemplar_boxes=[POS])        # pure visual query, no text

exemplars

Multi-keyword — one encode, a keyword column on the way back:

df = c.multi(tile, ["building", "road", "vegetation"])   # 142 / 13 / 65

Portable concepts — encode an appearance once, reuse it anywhere, including across processes:

concept = c.encode_exemplars(cropA, boxes=[[120, 80, 210, 160]])
open("concept.npz","wb").write(concept.to_bytes())        # bank it
df = c.segment(cropB, prompt=None, concept=concept)       # find it on a different crop

Interactive session — encode once, then cheap prompts; each call returns 3 candidates ranked by predicted IoU:

s = c.interactive(tile)
s.add_point((504, 504))                   # positive click        -> best IoU 0.845
s.add_point((470, 470), positive=False)   # negative refinement   -> best IoU 0.903
s.add_box([470, 480, 560, 545])           # box prompt            -> best IoU 0.709
s.add_mask(df.iloc[0]["mask"])            # seed from a segment() result, then refine by hand
s.undo(); s.reset()                       # reset keeps the cached embedding
df = s.everything(points_per_side=16)     # automatic mask generation -> 47 objects

interactive

Other conveniences worth knowing:

  • Any input dtype. uint16/float rasters are percentile-stretched to 8-bit (robust to hot pixels), grayscale is replicated to RGB, alpha dropped — so 12-bit satellite tiles work without pre-conversion.
  • Client-side dedup. SAM3 grounding is set prediction with no server-side NMS, so at low thresholds it emits masks nested inside larger ones. dedup= (default 0.85) suppresses those by overlap coefficient inter/min(area) — plain IoU-NMS misses them because the union stays large.
  • Per-request hyperparameters. score_thr / mask_thr / max_det are optional ensemble inputs, so you tune without redeploying: on the sample tile score_thr 0.2/0.3/0.5/0.7 → 153/142/93/17 instances.
  • Connection handling. round-robin LB (point it at a headless Service to spread across pods), gRPC-native retries for transient transport errors only, and connect_timeout to block through a cold start — a full SAM3 load is ~60–90 s.

Caveats

  • Image model only — no video, tracking, or memory propagation.
  • fp32. The ViT correlations overflowed fp16 in my testing.
  • Fixed 1008 image input and 32-token text; other sizes need a re-export.
  • No weights shipped — the repo is tooling; you run the export against your own checkpoint, and SAM3 itself stays under Meta's license.

Happy to answer questions on the split or the Triton configs. Disclosure: this is my repo.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions