mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
AI masks from Meta's SAM 3, run in LightCraft itself: a new
`lightcraft-segment` crate ports the Hugging Face transformers
implementation (Apache-2.0) to candle, on the GPU through Metal on macOS
(CPU elsewhere), with no C/C++ and no `unsafe`.
- Object: click to include, ⌥-click to leave a part out (SAM 3's
interactive prompt encoder + two-way mask decoder).
- Describe: a text prompt selects every instance ("sky", "the red car";
"car, road" merges several) via the CLIP text encoder and DETR detector.
- Both as new masks and as Add / Subtract / Intersect components; + / −
buttons under the selected mask combine selections.
- The segmentation is stored with the mask (288² quantized logits, ~10-30
KB), so renders, exports and the web build never need the model.
- A zoomed-in detail pass (background, ~5 s on an M4 Pro) re-segments the
area around a selection from a sharper source: 5-10x finer edges on
small objects.
- Per-selection Edge slider (hard ↔ soft); hovering a mask in the list
shows it in red (the always-on overlay is now off by default, O toggles).
- The engine feature `sam` (on in the desktop app) gates the model; the
image is encoded once per photo in the background, clicks take ~35 ms.
- Weights are not bundled (SAM License): tools/install-sam3.sh / .ps1
download and verify facebook/sam3; docs/ai-masks.md.
- Clearer errors (missing file, missing model) and a stderr logger in the
app (LIGHTCRAFT_LOG=info).
candle is pinned at 0.9.2: later releases make candle-core depend on
tokenizers with the Oniguruma C library. Every stage is checked against
the Python reference (tests/reference.rs, tools/sam3_reference.py): on
Metal the backbone matches to 7e-5 relative error, masks to ~5e-5.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01C8BkS7JDqa1EMcjW6spDGA
98 lines
4.7 KiB
Rust
98 lines
4.7 KiB
Rust
//! The port against the reference implementation (Hugging Face `transformers`, fp32).
|
||
//!
|
||
//! Needs the checkpoint and a reference file made by `tools/sam3_reference.py`:
|
||
//! `LIGHTCRAFT_SAM3_DIR=<model dir> LIGHTCRAFT_SAM3_REF=<ref.safetensors> cargo test -p lightcraft-segment --release -- --nocapture`.
|
||
//! Skipped (passes) when either is missing.
|
||
|
||
use std::collections::HashMap;
|
||
use std::path::PathBuf;
|
||
|
||
use candle_core::{Device, IndexOp, Tensor};
|
||
use lightcraft_segment::{Click, Sam3, tokenizer::Tokenizer};
|
||
|
||
fn setup() -> Option<(PathBuf, HashMap<String, Tensor>)> {
|
||
let dir = PathBuf::from(std::env::var_os("LIGHTCRAFT_SAM3_DIR")?);
|
||
let reference = PathBuf::from(std::env::var_os("LIGHTCRAFT_SAM3_REF")?);
|
||
let r = candle_core::safetensors::load(&reference, &Device::Cpu).ok()?;
|
||
Some((dir, r))
|
||
}
|
||
|
||
fn vec(t: &Tensor) -> Vec<f32> {
|
||
t.to_device(&Device::Cpu).unwrap().flatten_all().unwrap().to_vec1::<f32>().unwrap()
|
||
}
|
||
|
||
/// Max |a − b| relative to the reference's max |b|.
|
||
fn rel_err(name: &str, a: &[f32], b: &[f32]) -> f32 {
|
||
assert_eq!(a.len(), b.len(), "{name}: length");
|
||
let scale = b.iter().fold(0f32, |m, v| m.max(v.abs())).max(1e-6);
|
||
let err = a.iter().zip(b).fold(0f32, |m, (x, y)| m.max((x - y).abs()));
|
||
eprintln!("{name:>18}: max abs err {err:.2e}, rel {:.2e} (scale {scale:.2e})", err / scale);
|
||
err / scale
|
||
}
|
||
|
||
/// Fraction of pixels where the two logit maps disagree on the mask (> 0).
|
||
fn mask_disagreement(a: &[f32], b: &[f32]) -> f32 {
|
||
a.iter().zip(b).filter(|(x, y)| (**x > 0.0) != (**y > 0.0)).count() as f32 / a.len() as f32
|
||
}
|
||
|
||
#[test]
|
||
fn matches_reference() {
|
||
let Some((dir, r)) = setup() else {
|
||
eprintln!("skipped: set LIGHTCRAFT_SAM3_DIR and LIGHTCRAFT_SAM3_REF");
|
||
return;
|
||
};
|
||
let mut model = Sam3::load(&dir).unwrap();
|
||
eprintln!("device: {:?}", model.device());
|
||
let t = std::time::Instant::now();
|
||
let mut enc = model.encode_pixels(&r["pixel_values"]).unwrap();
|
||
let backbone = enc.backbone().permute((0, 2, 3, 1)).unwrap();
|
||
assert!(rel_err("backbone", &vec(&backbone), &vec(&r["backbone"])) < 1e-3);
|
||
eprintln!("backbone: {:?}", t.elapsed());
|
||
|
||
// points: two clicks (single-mask output), one click (best of three)
|
||
let clicks = [Click { x: 0.5, y: 0.55, positive: true }, Click { x: 0.2, y: 0.2, positive: false }];
|
||
let t = std::time::Instant::now();
|
||
let p = model.segment_clicks(&mut enc, &clicks).unwrap();
|
||
eprintln!("clicks (incl. tracker load + features): {:?}", t.elapsed());
|
||
let want = vec(&r["trk_masks"]);
|
||
assert!(rel_err("2-click logits", &p.logits, &want) < 1e-2);
|
||
assert!(mask_disagreement(&p.logits, &want) < 1e-3);
|
||
let t = std::time::Instant::now();
|
||
let p = model.segment_clicks(&mut enc, &clicks[..1]).unwrap();
|
||
eprintln!("one more click: {:?}", t.elapsed());
|
||
let iou = vec(&r["trk_multi_iou"]);
|
||
let best = (0..3).max_by(|a, b| iou[*a].partial_cmp(&iou[*b]).unwrap()).unwrap();
|
||
let want = vec(&r["trk_multi_masks"].i((0, 0, best)).unwrap());
|
||
assert!(rel_err("1-click logits", &p.logits, &want) < 1e-2);
|
||
assert!(mask_disagreement(&p.logits, &want) < 1e-3);
|
||
|
||
// tokenizer
|
||
let tok = Tokenizer::load(&dir).unwrap();
|
||
let cases = ["person", "Red Car", "a dog's toy", "sky, clouds!", "café 2 people", "tree-line"];
|
||
for (i, c) in cases.iter().enumerate() {
|
||
let (ids, _) = tok.encode(c);
|
||
let want: Vec<i64> = r[&format!("tok{i}")].flatten_all().unwrap().to_vec1().unwrap();
|
||
assert_eq!(ids.iter().map(|v| *v as i64).collect::<Vec<_>>(), want, "tokens of {c:?}");
|
||
}
|
||
|
||
// text prompts
|
||
for (i, text) in ["moon", "sky", "bird"].iter().enumerate() {
|
||
let t = std::time::Instant::now();
|
||
let out = model.detect(&mut enc, text).unwrap();
|
||
eprintln!("detect {text:?}: {:?}", t.elapsed());
|
||
assert!(rel_err("text features", &vec(&out.text), &vec(&r[&format!("det{i}_text")])) < 1e-3);
|
||
assert!(rel_err("query logits", &out.logits, &vec(&r[&format!("det{i}_logits")])) < 1e-2);
|
||
let presence = vec(&r[&format!("det{i}_presence")])[0];
|
||
assert!((out.presence - presence).abs() < 1e-2, "presence {} vs {presence}", out.presence);
|
||
let boxes: Vec<f32> = out.boxes.iter().flatten().copied().collect();
|
||
assert!(rel_err("boxes", &boxes, &vec(&r[&format!("det{i}_boxes")])) < 1e-2);
|
||
// the best instance's mask
|
||
let logits = vec(&r[&format!("det{i}_logits")]);
|
||
let q = (0..logits.len()).max_by(|a, b| logits[*a].partial_cmp(&logits[*b]).unwrap()).unwrap();
|
||
let ours = vec(&out.masks.i(q).unwrap());
|
||
let want = vec(&r[&format!("det{i}_masks")].i((0, q)).unwrap());
|
||
assert!(rel_err("best mask", &ours, &want) < 2e-2);
|
||
assert!(mask_disagreement(&ours, &want) < 2e-3);
|
||
}
|
||
}
|