Skip to main content

scirs2_vision/prompt_segmentation/
image_encoder.rs

1//! Lightweight multi-scale CNN image encoder.
2//!
3//! Unlike the original SAM which uses a heavy ViT backbone, this encoder
4//! provides a practical pure-Rust alternative using a 3-stage convolutional
5//! network with residual connections.  Each stage halves the spatial resolution
6//! while increasing the channel count, producing feature maps at 1/4, 1/8 and
7//! 1/16 of the input size.
8
9use crate::error::{Result, VisionError};
10use scirs2_core::ndarray::{Array1, Array2};
11
12use super::types::SAMConfig;
13
14// ---------------------------------------------------------------------------
15// Patch embedding (stride-2 convolution)
16// ---------------------------------------------------------------------------
17
18/// A single convolutional "patch embedding" that halves spatial resolution.
19///
20/// Conceptually equivalent to `Conv2D(in_ch, out_ch, kernel=3, stride=2, pad=1)`.
21/// Weights are stored as a flattened `[out_ch, in_ch * k * k]` matrix.
22#[derive(Debug, Clone)]
23pub struct PatchEmbedding {
24    /// Weight matrix `[out_ch, in_ch * kernel * kernel]`.
25    weights: Array2<f64>,
26    /// Bias vector `[out_ch]`.
27    bias: Array1<f64>,
28    /// Input channel count.
29    in_channels: usize,
30    /// Output channel count.
31    out_channels: usize,
32    /// Kernel size (square).
33    kernel_size: usize,
34    /// Stride (always 2 for down-sampling).
35    stride: usize,
36}
37
38impl PatchEmbedding {
39    /// Create a new patch embedding with He-initialised weights.
40    pub fn new(in_channels: usize, out_channels: usize, kernel_size: usize, stride: usize) -> Self {
41        let fan_in = in_channels * kernel_size * kernel_size;
42        let std_dev = (2.0 / fan_in as f64).sqrt();
43
44        // Deterministic pseudo-random initialisation (good enough for inference
45        // placeholders; real training would replace these).
46        let total = out_channels * fan_in;
47        let mut weights_vec = Vec::with_capacity(total);
48        let mut seed: u64 = 42;
49        for _ in 0..total {
50            seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
51            let u = (seed >> 33) as f64 / (1u64 << 31) as f64 - 1.0;
52            weights_vec.push(u * std_dev);
53        }
54
55        let weights = Array2::from_shape_vec((out_channels, fan_in), weights_vec)
56            .unwrap_or_else(|_| Array2::zeros((out_channels, fan_in)));
57        let bias = Array1::zeros(out_channels);
58
59        Self {
60            weights,
61            bias,
62            in_channels,
63            out_channels,
64            kernel_size,
65            stride,
66        }
67    }
68
69    /// Apply the convolution to a multi-channel feature map stored as
70    /// `[H * W, channels]` with known `(height, width)`.
71    ///
72    /// Returns `(out_h, out_w, Array2<f64>[out_h * out_w, out_channels])`.
73    pub fn forward(
74        &self,
75        input: &Array2<f64>,
76        height: usize,
77        width: usize,
78    ) -> Result<(usize, usize, Array2<f64>)> {
79        let (n_pixels, in_ch) = input.dim();
80        if n_pixels != height * width {
81            return Err(VisionError::InvalidParameter(format!(
82                "PatchEmbedding: pixel count {n_pixels} != H*W = {}",
83                height * width,
84            )));
85        }
86        if in_ch != self.in_channels {
87            return Err(VisionError::InvalidParameter(format!(
88                "PatchEmbedding: expected {}, got {in_ch} input channels",
89                self.in_channels,
90            )));
91        }
92
93        let pad = self.kernel_size / 2;
94        let out_h = (height + 2 * pad - self.kernel_size) / self.stride + 1;
95        let out_w = (width + 2 * pad - self.kernel_size) / self.stride + 1;
96
97        let mut output = Array2::zeros((out_h * out_w, self.out_channels));
98
99        for oy in 0..out_h {
100            for ox in 0..out_w {
101                let iy_start = oy * self.stride;
102                let ix_start = ox * self.stride;
103
104                // Gather the patch into a flat vector.
105                let patch_len = self.in_channels * self.kernel_size * self.kernel_size;
106                let mut patch = vec![0.0f64; patch_len];
107                let mut idx = 0;
108                for c in 0..self.in_channels {
109                    for ky in 0..self.kernel_size {
110                        for kx in 0..self.kernel_size {
111                            let iy = iy_start + ky;
112                            let ix = ix_start + kx;
113                            // Account for padding: pixel coords are shifted by `pad`.
114                            let sy = iy as isize - pad as isize;
115                            let sx = ix as isize - pad as isize;
116                            if sy >= 0 && (sy as usize) < height && sx >= 0 && (sx as usize) < width
117                            {
118                                let pixel_idx = sy as usize * width + sx as usize;
119                                patch[idx] = input[[pixel_idx, c]];
120                            }
121                            // else: zero-pad (already 0.0)
122                            idx += 1;
123                        }
124                    }
125                }
126
127                // Matrix-vector multiply: output[oy*out_w+ox, :] = W @ patch + b
128                let out_idx = oy * out_w + ox;
129                for oc in 0..self.out_channels {
130                    let mut val = self.bias[oc];
131                    for (pi, &patch_val) in patch.iter().enumerate().take(patch_len) {
132                        val += self.weights[[oc, pi]] * patch_val;
133                    }
134                    output[[out_idx, oc]] = val;
135                }
136            }
137        }
138
139        Ok((out_h, out_w, output))
140    }
141}
142
143// ---------------------------------------------------------------------------
144// Layer normalisation
145// ---------------------------------------------------------------------------
146
147/// Channel-wise layer normalisation over the last axis.
148fn layer_norm(input: &mut Array2<f64>, eps: f64) {
149    let (rows, cols) = input.dim();
150    if cols == 0 {
151        return;
152    }
153    for r in 0..rows {
154        let mut mean = 0.0f64;
155        for c in 0..cols {
156            mean += input[[r, c]];
157        }
158        mean /= cols as f64;
159
160        let mut var = 0.0f64;
161        for c in 0..cols {
162            let diff = input[[r, c]] - mean;
163            var += diff * diff;
164        }
165        var /= cols as f64;
166
167        let inv_std = 1.0 / (var + eps).sqrt();
168        for c in 0..cols {
169            input[[r, c]] = (input[[r, c]] - mean) * inv_std;
170        }
171    }
172}
173
174/// Element-wise ReLU activation.
175fn relu_inplace(arr: &mut Array2<f64>) {
176    arr.mapv_inplace(|v| v.max(0.0));
177}
178
179// ---------------------------------------------------------------------------
180// Encoder stage (conv -> layernorm -> relu -> conv -> residual)
181// ---------------------------------------------------------------------------
182
183/// One down-sampling stage of the encoder.
184#[derive(Debug, Clone)]
185struct EncoderStage {
186    /// Stride-2 down-sampling convolution.
187    down_conv: PatchEmbedding,
188    /// 1x1 convolution for channel projection in the residual path.
189    proj_conv: PatchEmbedding,
190    /// Stride-1 "refine" convolution.
191    refine_conv: PatchEmbedding,
192}
193
194impl EncoderStage {
195    fn new(in_channels: usize, out_channels: usize) -> Self {
196        Self {
197            down_conv: PatchEmbedding::new(in_channels, out_channels, 3, 2),
198            proj_conv: PatchEmbedding::new(in_channels, out_channels, 1, 2),
199            refine_conv: PatchEmbedding::new(out_channels, out_channels, 3, 1),
200        }
201    }
202
203    /// Run one stage: returns `(out_h, out_w, features)`.
204    fn forward(
205        &self,
206        input: &Array2<f64>,
207        h: usize,
208        w: usize,
209    ) -> Result<(usize, usize, Array2<f64>)> {
210        // Main path: down_conv -> layernorm -> relu -> refine_conv
211        let (h1, w1, mut main) = self.down_conv.forward(input, h, w)?;
212        layer_norm(&mut main, 1e-5);
213        relu_inplace(&mut main);
214
215        let (_h2, _w2, refined) = self.refine_conv.forward(&main, h1, w1)?;
216
217        // Residual path: 1x1 stride-2 projection
218        let (_rh, _rw, residual) = self.proj_conv.forward(input, h, w)?;
219
220        // Add residual
221        let (rows, cols) = refined.dim();
222        let (rrows, rcols) = residual.dim();
223        let min_rows = rows.min(rrows);
224        let min_cols = cols.min(rcols);
225        let mut out = refined;
226        for r in 0..min_rows {
227            for c in 0..min_cols {
228                out[[r, c]] += residual[[r, c]];
229            }
230        }
231
232        Ok((h1, w1, out))
233    }
234}
235
236// ---------------------------------------------------------------------------
237// SimpleImageEncoder
238// ---------------------------------------------------------------------------
239
240/// A lightweight multi-scale CNN encoder that produces feature maps at three
241/// spatial scales (1/4, 1/8, 1/16 of the input).
242///
243/// This replaces SAM's ViT-H/ViT-L backbone with a practical pure-Rust
244/// convolutional architecture suitable for CPU inference.
245#[derive(Debug, Clone)]
246pub struct SimpleImageEncoder {
247    /// Initial patch embedding (stride-2 conv, input -> embed_dim/4).
248    initial_embed: PatchEmbedding,
249    /// Per-stage down-sampling blocks.
250    stages: Vec<EncoderStage>,
251    /// Configuration.
252    config: SAMConfig,
253}
254
255impl SimpleImageEncoder {
256    /// Build a new encoder from the given configuration.
257    pub fn new(config: &SAMConfig) -> Self {
258        let base_ch = config.embed_dim / 4; // e.g. 64
259
260        // Initial embedding: 1 -> base_ch, stride 2 (halves resolution once)
261        let initial_embed = PatchEmbedding::new(1, base_ch, 3, 2);
262
263        // Build encoder stages. Each stage doubles channels and halves resolution.
264        let mut stages = Vec::with_capacity(config.encoder_stages);
265        let mut ch = base_ch;
266        for _ in 0..config.encoder_stages {
267            let next_ch = (ch * 2).min(config.embed_dim);
268            stages.push(EncoderStage::new(ch, next_ch));
269            ch = next_ch;
270        }
271
272        Self {
273            initial_embed,
274            stages,
275            config: config.clone(),
276        }
277    }
278
279    /// Encode a single-channel image into multi-scale feature maps.
280    ///
281    /// # Arguments
282    ///
283    /// * `image` – Grayscale image `[H, W]` with values in `[0, 1]`.
284    ///
285    /// # Returns
286    ///
287    /// A `Vec` of feature maps, one per encoder stage. Each feature map is
288    /// `[h_i * w_i, channels_i]` (flattened spatial dims). The first entry
289    /// corresponds to the coarsest (highest-level) features.
290    pub fn encode(&self, image: &Array2<f64>) -> Result<Vec<Array2<f64>>> {
291        let (img_h, img_w) = image.dim();
292        if img_h == 0 || img_w == 0 {
293            return Err(VisionError::InvalidParameter(
294                "image_encoder: image must have non-zero dimensions".into(),
295            ));
296        }
297
298        // Reshape image to [H*W, 1] for the first convolution.
299        let flat_len = img_h * img_w;
300        let mut flat = Array2::zeros((flat_len, 1));
301        for r in 0..img_h {
302            for c in 0..img_w {
303                flat[[r * img_w + c, 0]] = image[[r, c]];
304            }
305        }
306
307        // Initial embedding (stride 2 -> resolution / 2).
308        let (mut h, mut w, mut features) = self.initial_embed.forward(&flat, img_h, img_w)?;
309        layer_norm(&mut features, 1e-5);
310        relu_inplace(&mut features);
311
312        // Run each stage, collecting multi-scale features.
313        let mut multi_scale: Vec<Array2<f64>> = Vec::with_capacity(self.config.encoder_stages);
314        for stage in &self.stages {
315            let (nh, nw, new_feat) = stage.forward(&features, h, w)?;
316            multi_scale.push(new_feat.clone());
317            h = nh;
318            w = nw;
319            features = new_feat;
320        }
321
322        Ok(multi_scale)
323    }
324
325    /// Return the expected number of output channels at each stage.
326    pub fn stage_channels(&self) -> Vec<usize> {
327        let base_ch = self.config.embed_dim / 4;
328        let mut channels = Vec::with_capacity(self.config.encoder_stages);
329        let mut ch = base_ch;
330        for _ in 0..self.config.encoder_stages {
331            ch = (ch * 2).min(self.config.embed_dim);
332            channels.push(ch);
333        }
334        channels
335    }
336}
337
338// ---------------------------------------------------------------------------
339// Tests
340// ---------------------------------------------------------------------------
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use scirs2_core::ndarray::Array2;
346
347    #[test]
348    fn test_patch_embedding_forward() {
349        let pe = PatchEmbedding::new(1, 4, 3, 2);
350        let input = Array2::ones((16, 1)); // 4x4 image, 1 channel
351        let (oh, ow, out) = pe.forward(&input, 4, 4).expect("forward failed");
352        assert_eq!(oh, 2);
353        assert_eq!(ow, 2);
354        assert_eq!(out.dim(), (4, 4));
355    }
356
357    #[test]
358    fn test_layer_norm() {
359        let mut arr = Array2::from_shape_vec((2, 4), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0])
360            .expect("shape");
361        layer_norm(&mut arr, 1e-5);
362        // After layer norm each row should have ~zero mean.
363        let row_mean: f64 = (0..4).map(|c| arr[[0, c]]).sum::<f64>() / 4.0;
364        assert!(row_mean.abs() < 1e-6);
365    }
366
367    #[test]
368    fn test_simple_image_encoder_smoke() {
369        let cfg = SAMConfig {
370            image_size: 32,
371            embed_dim: 16,
372            num_mask_outputs: 3,
373            iou_head_hidden: 16,
374            encoder_stages: 2,
375        };
376        let encoder = SimpleImageEncoder::new(&cfg);
377        let image = Array2::from_elem((8, 8), 0.5);
378        let features = encoder.encode(&image).expect("encode failed");
379        assert_eq!(features.len(), 2);
380        // Each feature map should have > 0 rows.
381        for f in &features {
382            assert!(f.dim().0 > 0);
383            assert!(f.dim().1 > 0);
384        }
385    }
386
387    #[test]
388    fn test_encoder_stage_channels() {
389        let cfg = SAMConfig {
390            embed_dim: 64,
391            encoder_stages: 3,
392            ..SAMConfig::default()
393        };
394        let enc = SimpleImageEncoder::new(&cfg);
395        let chs = enc.stage_channels();
396        assert_eq!(chs, vec![32, 64, 64]);
397    }
398
399    #[test]
400    fn test_patch_embedding_channel_mismatch() {
401        let pe = PatchEmbedding::new(3, 8, 3, 2);
402        let input = Array2::ones((16, 1)); // wrong channel count
403        let err = pe.forward(&input, 4, 4);
404        assert!(err.is_err());
405    }
406
407    #[test]
408    fn test_encoder_empty_image() {
409        let cfg = SAMConfig::default();
410        let enc = SimpleImageEncoder::new(&cfg);
411        let img = Array2::<f64>::zeros((0, 0));
412        assert!(enc.encode(&img).is_err());
413    }
414}