Skip to main content

rlx_sam3/
preprocess.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Host-side SAM3 preprocessing and patch embedding.
17//!
18//! SAM3's public builder uses a ViT patch-14 backbone at 1008x1008.
19//! RLX does patch projection on the host, matching the existing SAM and
20//! DINOv2 ports, because the IR surface does not currently include a
21//! general f32 Conv2d forward.
22
23use super::config::{
24    SAM3_IMG_SIZE, SAM3_PATCH_GRID, SAM3_PIXEL_MEAN, SAM3_PIXEL_STD, Sam3VitConfig,
25};
26use anyhow::{Result, ensure};
27use rlx_core::weight_map::WeightMap;
28
29#[derive(Clone)]
30pub struct Sam3PreprocessWeights {
31    /// Patch projection `[patch_dim, embed_dim]`, transposed for row-major matmul.
32    pub patch_proj_w: Vec<f32>,
33    pub patch_proj_b: Vec<f32>,
34    pub pos_embed: Option<Vec<f32>>,
35    pub embed_dim: usize,
36    pub patch_size: usize,
37    pub grid: usize,
38}
39
40pub(crate) fn extract_preprocess_weights(
41    weights: &mut WeightMap,
42    cfg: &Sam3VitConfig,
43) -> Result<Sam3PreprocessWeights> {
44    let e = cfg.embed_dim;
45    let ps = cfg.patch_size;
46    let grid = cfg.patch_grid();
47    let pd = 3 * ps * ps;
48
49    let (proj_raw, proj_shape) = take_first(
50        weights,
51        &[
52            "detector.backbone.vision_backbone.trunk.patch_embed.proj.weight",
53            "detector.backbone.visual.trunk.patch_embed.proj.weight",
54            "backbone.vision_backbone.trunk.patch_embed.proj.weight",
55            "backbone.visual.trunk.patch_embed.proj.weight",
56            "visual.trunk.patch_embed.proj.weight",
57            "trunk.patch_embed.proj.weight",
58        ],
59    )?;
60    // Accept either NCHW (PyTorch: `[e, 3, ps, ps]`) or NHWC (MLX → GGUF via
61    // sam3.cpp converter: `[e, ps, ps, 3]`). The output layout is
62    // `[pd, e]` with `pd = c * ps * ps + kh * ps + kw` (NCHW order).
63    let mut patch_proj_w = vec![0f32; e * pd];
64    if proj_shape == vec![e, 3, ps, ps] {
65        for ei in 0..e {
66            for d in 0..pd {
67                patch_proj_w[d * e + ei] = proj_raw[ei * pd + d];
68            }
69        }
70    } else if proj_shape == vec![e, ps, ps, 3] {
71        for ei in 0..e {
72            for c in 0..3 {
73                for kh in 0..ps {
74                    for kw in 0..ps {
75                        let d = c * ps * ps + kh * ps + kw;
76                        let src = ei * (ps * ps * 3) + kh * (ps * 3) + kw * 3 + c;
77                        patch_proj_w[d * e + ei] = proj_raw[src];
78                    }
79                }
80            }
81        }
82    } else {
83        anyhow::bail!(
84            "SAM3 patch_embed.proj.weight expected [{e}, 3, {ps}, {ps}] (NCHW) or [{e}, {ps}, {ps}, 3] (NHWC), got {proj_shape:?}"
85        );
86    }
87
88    let patch_proj_b = if cfg.bias_patch_embed {
89        let (data, shape) = take_first(
90            weights,
91            &[
92                "detector.backbone.vision_backbone.trunk.patch_embed.proj.bias",
93                "detector.backbone.visual.trunk.patch_embed.proj.bias",
94                "backbone.vision_backbone.trunk.patch_embed.proj.bias",
95                "backbone.visual.trunk.patch_embed.proj.bias",
96                "visual.trunk.patch_embed.proj.bias",
97                "trunk.patch_embed.proj.bias",
98            ],
99        )?;
100        ensure!(
101            shape == vec![e],
102            "SAM3 patch bias expected [{e}], got {shape:?}"
103        );
104        data
105    } else {
106        vec![0.0; e]
107    };
108
109    let pos_embed = if cfg.use_abs_pos {
110        take_optional_first(
111            weights,
112            &[
113                "detector.backbone.vision_backbone.trunk.pos_embed",
114                "detector.backbone.visual.trunk.pos_embed",
115                "backbone.vision_backbone.trunk.pos_embed",
116                "backbone.visual.trunk.pos_embed",
117                "visual.trunk.pos_embed",
118                "trunk.pos_embed",
119            ],
120        )?
121        .map(|(data, shape)| materialize_pos_embed(&data, &shape, cfg, grid, e))
122        .transpose()?
123    } else {
124        None
125    };
126
127    Ok(Sam3PreprocessWeights {
128        patch_proj_w,
129        patch_proj_b,
130        pos_embed,
131        embed_dim: e,
132        patch_size: ps,
133        grid,
134    })
135}
136
137/// Resize an RGB u8 image to fit in SAM3's square canvas, normalize, and pad.
138pub fn preprocess_image(rgb: &[u8], h_in: usize, w_in: usize) -> (Vec<f32>, (usize, usize)) {
139    let scale = (SAM3_IMG_SIZE as f32) / (h_in.max(w_in) as f32);
140    let new_h = ((h_in as f32) * scale).round() as usize;
141    let new_w = ((w_in as f32) * scale).round() as usize;
142
143    let mut resized = vec![0f32; 3 * new_h * new_w];
144    let sx = (w_in as f32 - 1.0) / (new_w.max(1) as f32 - 1.0).max(1.0);
145    let sy = (h_in as f32 - 1.0) / (new_h.max(1) as f32 - 1.0).max(1.0);
146    for y in 0..new_h {
147        let fy = y as f32 * sy;
148        let y0 = fy.floor() as usize;
149        let y1 = (y0 + 1).min(h_in - 1);
150        let dy = fy - y0 as f32;
151        for x in 0..new_w {
152            let fx = x as f32 * sx;
153            let x0 = fx.floor() as usize;
154            let x1 = (x0 + 1).min(w_in - 1);
155            let dx = fx - x0 as f32;
156            for c in 0..3 {
157                let p00 = rgb[(y0 * w_in + x0) * 3 + c] as f32 / 255.0;
158                let p01 = rgb[(y0 * w_in + x1) * 3 + c] as f32 / 255.0;
159                let p10 = rgb[(y1 * w_in + x0) * 3 + c] as f32 / 255.0;
160                let p11 = rgb[(y1 * w_in + x1) * 3 + c] as f32 / 255.0;
161                let top = p00 * (1.0 - dx) + p01 * dx;
162                let bot = p10 * (1.0 - dx) + p11 * dx;
163                let v = top * (1.0 - dy) + bot * dy;
164                resized[c * new_h * new_w + y * new_w + x] =
165                    (v - SAM3_PIXEL_MEAN[c]) / SAM3_PIXEL_STD[c];
166            }
167        }
168    }
169
170    let mut padded = vec![0f32; 3 * SAM3_IMG_SIZE * SAM3_IMG_SIZE];
171    for c in 0..3 {
172        for y in 0..new_h {
173            let src_row = c * new_h * new_w + y * new_w;
174            let dst_row = c * SAM3_IMG_SIZE * SAM3_IMG_SIZE + y * SAM3_IMG_SIZE;
175            padded[dst_row..dst_row + new_w].copy_from_slice(&resized[src_row..src_row + new_w]);
176        }
177    }
178    (padded, (new_h, new_w))
179}
180
181pub fn assemble_patch_tokens(pre: &Sam3PreprocessWeights, image_nchw: &[f32]) -> Result<Vec<f32>> {
182    let e = pre.embed_dim;
183    let ps = pre.patch_size;
184    let grid = pre.grid;
185    let pd = 3 * ps * ps;
186    ensure!(
187        image_nchw.len() == 3 * SAM3_IMG_SIZE * SAM3_IMG_SIZE,
188        "SAM3 image must be [3, {SAM3_IMG_SIZE}, {SAM3_IMG_SIZE}] NCHW, got len {}",
189        image_nchw.len()
190    );
191    ensure!(
192        grid == SAM3_PATCH_GRID,
193        "SAM3 base grid must be {SAM3_PATCH_GRID}"
194    );
195
196    let mut out = vec![0f32; grid * grid * e];
197    let mut patch_buf = vec![0f32; pd];
198    for py in 0..grid {
199        for px in 0..grid {
200            for c in 0..3 {
201                for ry in 0..ps {
202                    let src_y = py * ps + ry;
203                    for rx in 0..ps {
204                        let src_x = px * ps + rx;
205                        let src = c * SAM3_IMG_SIZE * SAM3_IMG_SIZE + src_y * SAM3_IMG_SIZE + src_x;
206                        let dst = c * ps * ps + ry * ps + rx;
207                        patch_buf[dst] = image_nchw[src];
208                    }
209                }
210            }
211            let row = py * grid + px;
212            let dst = &mut out[row * e..(row + 1) * e];
213            dst.copy_from_slice(&pre.patch_proj_b);
214            for d in 0..pd {
215                let v = patch_buf[d];
216                if v == 0.0 {
217                    continue;
218                }
219                let w_row = &pre.patch_proj_w[d * e..(d + 1) * e];
220                for k in 0..e {
221                    dst[k] += v * w_row[k];
222                }
223            }
224        }
225    }
226
227    if let Some(pos) = &pre.pos_embed {
228        ensure!(pos.len() == out.len(), "SAM3 pos_embed size mismatch");
229        for i in 0..out.len() {
230            out[i] += pos[i];
231        }
232    }
233
234    Ok(out)
235}
236
237/// Materialise the absolute positional embedding so the trunk can add it
238/// directly to the [grid, grid, embed_dim] patch tokens. Upstream stores a
239/// `[1, num_positions, embed_dim]` pretrain table: when
240/// `pretrain_use_cls_token` is set the first row is the CLS position and the
241/// rest is a `pretrain_grid x pretrain_grid` table. We then tile (or, when
242/// `tile_abs_pos=False`, bicubic-interpolate) to the deployment grid.
243fn materialize_pos_embed(
244    data: &[f32],
245    shape: &[usize],
246    cfg: &Sam3VitConfig,
247    grid: usize,
248    e: usize,
249) -> Result<Vec<f32>> {
250    if shape == [1, grid, grid, e] || shape == [grid, grid, e] {
251        return Ok(data.to_vec());
252    }
253    ensure!(
254        shape.len() == 3 && shape[0] == 1 && shape[2] == e,
255        "SAM3 pos_embed expected [1, *, {e}], got {shape:?}"
256    );
257    let num_positions = shape[1];
258    let has_cls = num_positions % 2 == 1;
259    let spatial = if has_cls {
260        num_positions - 1
261    } else {
262        num_positions
263    };
264    let pretrain_grid = (spatial as f64).sqrt().round() as usize;
265    ensure!(
266        pretrain_grid * pretrain_grid == spatial,
267        "SAM3 pos_embed spatial portion not square: {spatial} positions"
268    );
269
270    let src = if has_cls { &data[e..] } else { data };
271    let mut out = vec![0f32; grid * grid * e];
272
273    if cfg.tile_abs_pos {
274        for y in 0..grid {
275            for x in 0..grid {
276                let sy = y % pretrain_grid;
277                let sx = x % pretrain_grid;
278                let src_row = (sy * pretrain_grid + sx) * e;
279                let dst_row = (y * grid + x) * e;
280                out[dst_row..dst_row + e].copy_from_slice(&src[src_row..src_row + e]);
281            }
282        }
283    } else {
284        // Bicubic interpolation (matches torch.nn.functional.interpolate
285        // mode="bicubic", align_corners=False).
286        bicubic_interp_nhwc(src, pretrain_grid, pretrain_grid, &mut out, grid, grid, e);
287    }
288
289    Ok(out)
290}
291
292fn bicubic_interp_nhwc(
293    src: &[f32],
294    src_h: usize,
295    src_w: usize,
296    dst: &mut [f32],
297    dst_h: usize,
298    dst_w: usize,
299    c: usize,
300) {
301    // Convert to [C, H, W] for sampling, then back.
302    let mut src_chw = vec![0f32; c * src_h * src_w];
303    for y in 0..src_h {
304        for x in 0..src_w {
305            for ch in 0..c {
306                src_chw[ch * src_h * src_w + y * src_w + x] = src[(y * src_w + x) * c + ch];
307            }
308        }
309    }
310    let scale_y = src_h as f32 / dst_h as f32;
311    let scale_x = src_w as f32 / dst_w as f32;
312    for y in 0..dst_h {
313        let fy = (y as f32 + 0.5) * scale_y - 0.5;
314        let y_floor = fy.floor() as i32;
315        let dy = fy - y_floor as f32;
316        let wy = cubic_weights(dy);
317        for x in 0..dst_w {
318            let fx = (x as f32 + 0.5) * scale_x - 0.5;
319            let x_floor = fx.floor() as i32;
320            let dx = fx - x_floor as f32;
321            let wx = cubic_weights(dx);
322            for ch in 0..c {
323                let plane = &src_chw[ch * src_h * src_w..(ch + 1) * src_h * src_w];
324                let mut v = 0.0f32;
325                for j in -1..=2 {
326                    let sy = (y_floor + j).clamp(0, src_h as i32 - 1) as usize;
327                    let mut row_acc = 0.0f32;
328                    for i in -1..=2 {
329                        let sx = (x_floor + i).clamp(0, src_w as i32 - 1) as usize;
330                        row_acc += plane[sy * src_w + sx] * wx[(i + 1) as usize];
331                    }
332                    v += row_acc * wy[(j + 1) as usize];
333                }
334                dst[(y * dst_w + x) * c + ch] = v;
335            }
336        }
337    }
338}
339
340fn cubic_weights(t: f32) -> [f32; 4] {
341    // Cubic convolution kernel with a=-0.75 (matches PyTorch's bicubic).
342    let a = -0.75f32;
343    let t1 = 1.0 + t; // distance to leftmost
344    let t2 = t; // distance to next
345    let t3 = 1.0 - t; // distance to next
346    let t4 = 2.0 - t; // distance to rightmost
347    [
348        cubic_kernel(t1, a),
349        cubic_kernel(t2, a),
350        cubic_kernel(t3, a),
351        cubic_kernel(t4, a),
352    ]
353}
354
355fn cubic_kernel(x: f32, a: f32) -> f32 {
356    let x = x.abs();
357    if x < 1.0 {
358        (a + 2.0) * x * x * x - (a + 3.0) * x * x + 1.0
359    } else if x < 2.0 {
360        a * x * x * x - 5.0 * a * x * x + 8.0 * a * x - 4.0 * a
361    } else {
362        0.0
363    }
364}
365
366fn take_first(weights: &mut WeightMap, keys: &[&str]) -> Result<(Vec<f32>, Vec<usize>)> {
367    for key in keys {
368        if weights.has(key) {
369            return weights.take(key);
370        }
371    }
372    anyhow::bail!("none of the SAM3 weight keys were found: {keys:?}")
373}
374
375fn take_optional_first(
376    weights: &mut WeightMap,
377    keys: &[&str],
378) -> Result<Option<(Vec<f32>, Vec<usize>)>> {
379    for key in keys {
380        if weights.has(key) {
381            return weights.take(key).map(Some);
382        }
383    }
384    Ok(None)
385}