Skip to content

Commit 0deab24

Browse files
wep21claude
andcommitted
feat(sam3): add SAM3-LiteText image segmentation presets
SAM3-LiteText (arXiv:2602.12173) is SAM3-Image with the heavy text encoder replaced by a distilled MobileCLIP student; the ViT-H vision encoder, geometry encoder and mask decoder are kept intact. The presets therefore reuse the SAM3 image vision/decoder ONNX (jamjamjon/assets `sam3`) and only swap in the lightweight MobileCLIP text encoder (wep21/assets `sam3-litetext`). - Config::sam3_litetext_s0() / _s1() / _l() (MobileCLIP-S0/S1/L) - open-set-segmentation example: `sam3-litetext` subcommand (reuses the Sam3Image inference path) + README usage - scripts/sam3-litetext: text-encoder ONNX export (fp32 + fp16 via modelopt AutoCast), verified ORT==PyTorch (fp32 cos=1.0, fp16 cos~=0.99999) Quality matches SAM3-Image (dog.jpg: 0.972 vs 0.965); the text encoder is ~8x smaller (674MB -> 83MB) with equivalent end-to-end masks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 67a07a0 commit 0deab24

7 files changed

Lines changed: 364 additions & 2 deletions

File tree

examples/open-set-segmentation/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ The latest generation of Segment Anything Model (SAM3) optimized for image-based
1111

1212
**Note**: For SAM3 with box/point prompts, see the [sam3-tracker example](../image-segmentation/README.md#sam3-tracker).
1313

14+
### SAM3-LiteText
15+
SAM3-Image with the heavy text encoder replaced by a distilled MobileCLIP text encoder ([arXiv:2602.12173](https://arxiv.org/abs/2602.12173)). The ViT-H vision encoder, geometry encoder and mask decoder are kept intact, so it reuses the SAM3-Image vision/decoder ONNX and only swaps the text encoder.
16+
- Same prompts and interface as SAM3-Image (`Sam3Image` model).
17+
- Variants (`--variant`): `s0` (MobileCLIP-S0), `s1` (MobileCLIP-S1), `l` (MobileCLIP2-L).
18+
- Text-encoder ONNX: [wep21/assets `sam3-litetext`](https://github.com/wep21/assets/releases/tag/sam3-litetext); vision/decoder ONNX: [jamjamjon/assets `sam3`](https://github.com/jamjamjon/assets/releases/tag/sam3).
19+
1420
### YOLOEPromptBased
1521
YOLOE with prompt support for flexible object detection and segmentation.
1622
- `Visual`: Uses a visual prompt (image + bounding box) to find similar objects.
@@ -129,6 +135,27 @@ cargo run -F cuda-full -F vlm --example open-set-segmentation -- sam3-image \
129135
-p "handle;neg:40,183,278,21"
130136
```
131137

138+
### SAM3-LiteText
139+
140+
Same prompts/format as SAM3-Image (`Sam3Image` model), with a lightweight MobileCLIP text encoder. Select the variant with `--variant {s0,s1,l}`.
141+
142+
```bash
143+
cargo run -F cuda-full -F vlm --example open-set-segmentation -- sam3-litetext \
144+
--variant s0 \
145+
--visual-encoder-dtype f16 --visual-encoder-device cuda:0 \
146+
--textual-encoder-dtype fp16 --textual-encoder-device cuda:0 \
147+
--decoder-dtype f16 --decoder-device cuda:0 \
148+
--processor-device cuda:0 \
149+
--source ./assets/dog.jpg \
150+
-p dog
151+
```
152+
153+
```bash
154+
# CPU
155+
cargo run -F vlm --example open-set-segmentation -- sam3-litetext \
156+
--variant s0 --source ./assets/dog.jpg -p dog
157+
```
158+
132159
#### Prompt Format
133160

134161
| Format | Description | Example |

examples/open-set-segmentation/main.rs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use usls::{
66
};
77

88
mod sam3_image;
9+
mod sam3_litetext;
910
#[path = "../utils/mod.rs"]
1011
mod utils;
1112
mod yoloe_prompt_based;
@@ -38,6 +39,7 @@ struct Cli {
3839
enum Commands {
3940
YOLOEPromptBased(yoloe_prompt_based::YoloePromptArgs),
4041
Sam3Image(sam3_image::Sam3ImageArgs),
42+
Sam3Litetext(sam3_litetext::Sam3LitetextArgs),
4143
}
4244

4345
fn main() -> Result<()> {
@@ -59,6 +61,12 @@ fn main() -> Result<()> {
5961
.commit()?;
6062
run_sam3_image(config, cli.source, &annotator, args, &cli.prompts)?
6163
}
64+
Commands::Sam3Litetext(args) => {
65+
let config = sam3_litetext::config(args)?
66+
.with_class_confs(&cli.confs)
67+
.commit()?;
68+
run_sam3_litetext(config, cli.source, &annotator, args, &cli.prompts)?
69+
}
6270
Commands::YOLOEPromptBased(args) => {
6371
let config = yoloe_prompt_based::config(args)?
6472
.with_class_confs(&cli.confs)
@@ -135,6 +143,43 @@ fn run_sam3_image(
135143
annotator: &Annotator,
136144
args: &sam3_image::Sam3ImageArgs,
137145
prompts: &[String],
146+
) -> Result<()> {
147+
run_sam3_image_with_batch(
148+
config,
149+
source,
150+
annotator,
151+
args.visual_encoder_batch,
152+
prompts,
153+
"sam3-image",
154+
)
155+
}
156+
157+
// SAM3-LiteText reuses the SAM3 image model (same vision/geometry/decoder), so it
158+
// shares the Sam3Image inference path and only differs in the config preset.
159+
fn run_sam3_litetext(
160+
config: Config,
161+
source: Source,
162+
annotator: &Annotator,
163+
args: &sam3_litetext::Sam3LitetextArgs,
164+
prompts: &[String],
165+
) -> Result<()> {
166+
run_sam3_image_with_batch(
167+
config,
168+
source,
169+
annotator,
170+
args.visual_encoder_batch,
171+
prompts,
172+
"sam3-litetext",
173+
)
174+
}
175+
176+
fn run_sam3_image_with_batch(
177+
config: Config,
178+
source: Source,
179+
annotator: &Annotator,
180+
visual_encoder_batch: usize,
181+
prompts: &[String],
182+
output_dir: &str,
138183
) -> Result<()> {
139184
if prompts.is_empty() {
140185
anyhow::bail!("No prompt. Use -p \"text\" or -p \"text;pos:x,y,w,h\"");
@@ -147,7 +192,7 @@ fn run_sam3_image(
147192

148193
let mut model = Sam3Image::new(config)?;
149194
let dl = DataLoader::new(source)?
150-
.with_batch(args.visual_encoder_batch)
195+
.with_batch(visual_encoder_batch)
151196
.with_progress_bar(true)
152197
.stream()?;
153198

@@ -161,7 +206,7 @@ fn run_sam3_image(
161206
}
162207
annotated.save(
163208
usls::Dir::Current
164-
.base_dir_with_subs(&["runs/open-set-segmentation", "sam3-image"])?
209+
.base_dir_with_subs(&["runs/open-set-segmentation", output_dir])?
165210
.join(format!("{}.jpg", usls::timestamp(None))),
166211
)?;
167212
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
use anyhow::Result;
2+
use clap::{Args, ValueEnum};
3+
use usls::{Config, DType, Device};
4+
5+
#[derive(Debug, Clone, Copy, ValueEnum)]
6+
pub enum Sam3LitetextVariant {
7+
/// S0: MobileCLIP-S0 text encoder.
8+
S0,
9+
/// S1: MobileCLIP-S1 text encoder.
10+
S1,
11+
/// L: MobileCLIP2-L text encoder.
12+
L,
13+
}
14+
15+
#[derive(Args, Debug)]
16+
pub struct Sam3LitetextArgs {
17+
/// SAM3-LiteText text-encoder variant.
18+
#[arg(long, value_enum, default_value = "s0")]
19+
pub variant: Sam3LitetextVariant,
20+
21+
/// Visual Encoder Dtype: fp32, fp16, q4f16, etc.
22+
#[arg(long, default_value = "f16")]
23+
pub visual_encoder_dtype: DType,
24+
25+
/// Visual Encoder Device: cpu, cuda:0, mps, coreml, openvino:CPU, etc.
26+
#[arg(long, global = true, default_value = "cpu")]
27+
pub visual_encoder_device: Device,
28+
29+
/// Visual encoder batch
30+
#[arg(long, default_value_t = 1)]
31+
pub visual_encoder_batch: usize,
32+
33+
/// Textual Encoder Dtype: fp32, fp16, q4f16, etc.
34+
#[arg(long, default_value = "fp16")]
35+
pub textual_encoder_dtype: DType,
36+
37+
/// Textual Encoder Device: cpu, cuda:0, mps, coreml, openvino:CPU, etc.
38+
#[arg(long, global = true, default_value = "cpu")]
39+
pub textual_encoder_device: Device,
40+
41+
/// Textual encoder batch
42+
#[arg(long, default_value_t = 1)]
43+
pub textual_encoder_batch: usize,
44+
45+
/// Decoder Dtype: fp32, fp16, q4f16, etc.
46+
#[arg(long, default_value = "f16")]
47+
pub decoder_dtype: DType,
48+
49+
/// Decoder Device: cpu, cuda:0, mps, coreml, openvino:CPU, etc.
50+
#[arg(long, global = true, default_value = "cpu")]
51+
pub decoder_device: Device,
52+
53+
/// Decoder batch
54+
#[arg(long, default_value_t = 1)]
55+
pub decoder_batch: usize,
56+
57+
/// Processor device (for pre/post processing)
58+
#[arg(long, global = true, default_value = "cpu")]
59+
pub processor_device: Device,
60+
61+
/// num dry run
62+
#[arg(long, global = true, default_value_t = 0)]
63+
pub num_dry_run: usize,
64+
65+
/// trt_max_workspace_size
66+
#[arg(long, global = true, default_value_t = 3221225472)]
67+
pub trt_max_workspace_size: usize,
68+
}
69+
70+
pub fn config(args: &Sam3LitetextArgs) -> Result<Config> {
71+
let config = match args.variant {
72+
Sam3LitetextVariant::S0 => Config::sam3_litetext_s0(),
73+
Sam3LitetextVariant::S1 => Config::sam3_litetext_s1(),
74+
Sam3LitetextVariant::L => Config::sam3_litetext_l(),
75+
};
76+
77+
let config = config
78+
.with_visual_encoder_batch_min_opt_max(1, args.visual_encoder_batch, 2)
79+
.with_textual_encoder_batch_min_opt_max(1, args.textual_encoder_batch, 2)
80+
.with_decoder_batch_min_opt_max(1, args.decoder_batch, 2)
81+
.with_visual_encoder_device(args.visual_encoder_device)
82+
.with_visual_encoder_dtype(args.visual_encoder_dtype)
83+
.with_textual_encoder_device(args.textual_encoder_device)
84+
.with_textual_encoder_dtype(args.textual_encoder_dtype)
85+
.with_decoder_device(args.decoder_device)
86+
.with_decoder_dtype(args.decoder_dtype)
87+
.with_num_dry_run_all(args.num_dry_run)
88+
.with_image_processor_device(args.processor_device)
89+
.with_tensorrt_max_workspace_size_all(args.trt_max_workspace_size);
90+
91+
Ok(config)
92+
}

scripts/sam3-litetext/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# SAM3-LiteText text-encoder ONNX export
2+
3+
[SAM3-LiteText](https://huggingface.co/docs/transformers/model_doc/sam3_lite_text)
4+
(arXiv:2602.12173) is SAM3-Image with the heavy text encoder replaced by a
5+
distilled MobileCLIP student; the vision encoder, geometry encoder and mask
6+
decoder are kept intact. The Rust presets reuse the SAM3 image vision/decoder
7+
ONNX and only need this lightweight text encoder.
8+
9+
The exported text encoder is a drop-in replacement for the SAM3 text encoder
10+
(inputs `input_ids[B,32]`, `attention_mask[B,32]`; outputs `text_features[B,32,256]`,
11+
`text_mask[B,32]`).
12+
13+
| variant | HF model | text encoder |
14+
|---|---|---|
15+
| `s0` | `vil-uob/sam3-litetext-s0` | MobileCLIP-S0 |
16+
| `s1` | `vil-uob/sam3-litetext-s1` | MobileCLIP-S1 |
17+
| `l` | `vil-uob/sam3-litetext-l` | MobileCLIP2-L |
18+
19+
## Export
20+
21+
```bash
22+
cd scripts/sam3-litetext
23+
uv run export_text_encoder.py --model vil-uob/sam3-litetext-s0 --prefix sam3-litetext-s0 --precision fp32
24+
uv run export_text_encoder.py --model vil-uob/sam3-litetext-s0 --prefix sam3-litetext-s0 --precision fp16
25+
```
26+
27+
Each run writes to `onnx-sam3-litetext/` (override with `--out-dir`) and verifies
28+
ONNX Runtime matches PyTorch. fp16 uses NVIDIA Model Optimizer AutoCast
29+
(`nvidia-modelopt[onnx]`) for precision-aware conversion.
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Export the SAM3-LiteText MobileCLIP text encoder to ONNX.
2+
3+
SAM3-LiteText (arXiv:2602.12173) keeps the SAM3 ViT-H vision encoder, geometry
4+
encoder and mask decoder intact and only replaces the text encoder with a
5+
distilled MobileCLIP student. usls therefore reuses the existing SAM3 image
6+
vision/decoder ONNX (jamjamjon/assets `sam3` release) and only needs this
7+
lightweight text encoder.
8+
9+
The exported ONNX is a drop-in replacement for the SAM3 text encoder:
10+
inputs : input_ids[B, 32], attention_mask[B, 32]
11+
outputs: text_features[B, 32, 256], text_mask[B, 32] (bool, True = valid)
12+
13+
Variants (HuggingFace `vil-uob/sam3-litetext-{s0,s1,l}`):
14+
s0 -> MobileCLIP-S0, s1 -> MobileCLIP-S1, l -> MobileCLIP2-L
15+
16+
Usage:
17+
uv run export_text_encoder.py --model vil-uob/sam3-litetext-s0 --prefix sam3-litetext-s0 --precision fp32
18+
uv run export_text_encoder.py --model vil-uob/sam3-litetext-s0 --prefix sam3-litetext-s0 --precision fp16
19+
20+
fp16 conversion uses NVIDIA Model Optimizer AutoCast (precision-aware; keeps
21+
numerically unsafe nodes in fp32), which handles MobileCLIP's `.float()`
22+
LayerNorm/Softmax regions that onnxconverter_common cannot.
23+
"""
24+
from __future__ import annotations
25+
26+
import argparse
27+
from pathlib import Path
28+
29+
import numpy as np
30+
import onnx
31+
import onnxruntime as ort
32+
import torch
33+
import torch.nn as nn
34+
from transformers import AutoModel
35+
36+
37+
class LiteTextTextEncoder(nn.Module):
38+
"""Wraps the HF model's text path into a plain-tensor ONNX interface."""
39+
40+
def __init__(self, model):
41+
super().__init__()
42+
self.model = model
43+
44+
def forward(self, input_ids, attention_mask):
45+
text = self.model.get_text_features(
46+
input_ids=input_ids, attention_mask=attention_mask, return_dict=True
47+
)
48+
# pooler_output is the per-token projected features [B, seq, 256];
49+
# text_mask uses True = valid token (matches the SAM3 decoder).
50+
return text.pooler_output, attention_mask.bool()
51+
52+
53+
def main() -> None:
54+
ap = argparse.ArgumentParser(description=__doc__)
55+
ap.add_argument("--model", default="vil-uob/sam3-litetext-s0")
56+
ap.add_argument("--out-dir", default="onnx-sam3-litetext")
57+
ap.add_argument("--prefix", default="sam3-litetext-s0")
58+
ap.add_argument("--precision", choices=["fp32", "fp16"], default="fp32")
59+
ap.add_argument("--seq", type=int, default=32, help="text context length (fixed by the SAM3 decoder)")
60+
args = ap.parse_args()
61+
62+
out_dir = Path(args.out_dir)
63+
out_dir.mkdir(parents=True, exist_ok=True)
64+
suffix = "-fp16" if args.precision == "fp16" else ""
65+
out = out_dir / f"{args.prefix}-text-encoder{suffix}.onnx"
66+
67+
model = AutoModel.from_pretrained(args.model).eval()
68+
wrapper = LiteTextTextEncoder(model).eval()
69+
70+
# Dummy prompt "dog": BOS, token, EOS, then EOS-padding to `seq`.
71+
ids = torch.full((1, args.seq), 49407, dtype=torch.long)
72+
ids[0, :3] = torch.tensor([49406, 1929, 49407])
73+
attn = torch.ones(1, args.seq, dtype=torch.long)
74+
75+
with torch.no_grad():
76+
ref_tf, ref_tm = wrapper(ids, attn)
77+
print("torch text_features", tuple(ref_tf.shape), "text_mask", tuple(ref_tm.shape))
78+
79+
torch.onnx.export(
80+
wrapper, (ids, attn), str(out),
81+
input_names=["input_ids", "attention_mask"],
82+
output_names=["text_features", "text_mask"],
83+
opset_version=17, do_constant_folding=True, dynamo=False,
84+
dynamic_axes={"input_ids": {0: "batch"}, "attention_mask": {0: "batch"},
85+
"text_features": {0: "batch"}, "text_mask": {0: "batch"}},
86+
)
87+
print("exported:", out)
88+
89+
if args.precision == "fp16":
90+
from modelopt.onnx.autocast import convert_to_mixed_precision
91+
92+
model_fp16 = convert_to_mixed_precision(
93+
str(out), low_precision_type="fp16", keep_io_types=True
94+
)
95+
onnx.save(model_fp16, str(out))
96+
print("converted to fp16 (modelopt AutoCast)")
97+
98+
# Verify ONNX Runtime matches PyTorch.
99+
sess = ort.InferenceSession(str(out), providers=["CPUExecutionProvider"])
100+
np_dtype = {"tensor(float)": np.float32, "tensor(float16)": np.float16,
101+
"tensor(int64)": np.int64, "tensor(bool)": np.bool_}
102+
feeds = {"input_ids": ids.numpy(), "attention_mask": attn.numpy()}
103+
cast = {i.name: feeds[i.name].astype(np_dtype[i.type]) for i in sess.get_inputs()}
104+
got = dict(zip([o.name for o in sess.get_outputs()], sess.run(None, cast)))
105+
r = ref_tf.numpy().astype(np.float64)
106+
g = np.asarray(got["text_features"], np.float64)
107+
diff = np.abs(r - g)
108+
cos = float(r.ravel() @ g.ravel() / (np.linalg.norm(r.ravel()) * np.linalg.norm(g.ravel()) + 1e-12))
109+
print(f"verify text_features: max_abs={diff.max():.3e} mean_abs={diff.mean():.3e} cos={cos:.6f}")
110+
mism = int((np.asarray(got["text_mask"]).astype(bool) != ref_tm.numpy()).sum())
111+
print(f"verify text_mask: mismatched={mism}")
112+
113+
114+
if __name__ == "__main__":
115+
main()
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[project]
2+
name = "sam3-litetext-tools"
3+
version = "0.1.0"
4+
requires-python = ">=3.10"
5+
6+
dependencies = [
7+
"torch",
8+
"transformers>=5.12",
9+
"onnx",
10+
"onnxruntime",
11+
"nvidia-modelopt[onnx]>=0.44",
12+
"numpy>=2.0",
13+
]

0 commit comments

Comments
 (0)