Skip to main content

scirs2_vision/detection_3d/
backbone.rs

1//! 2D CNN backbone and feature-pyramid neck for BEV feature maps.
2
3use scirs2_core::ndarray::Array2;
4
5use crate::error::{Result, VisionError};
6
7// ---------------------------------------------------------------------------
8// Conv2D (lightweight implementation for this module)
9// ---------------------------------------------------------------------------
10
11/// Minimal 2D convolution layer (single-channel, square kernel).
12#[derive(Debug, Clone)]
13struct Conv2D {
14    /// Kernel weights: (out_channels, in_channels * k * k).
15    kernel: Array2<f64>,
16    /// Bias per output channel.
17    bias: Vec<f64>,
18    /// Kernel spatial size.
19    kernel_size: usize,
20    /// Stride.
21    stride: usize,
22    /// Input channels.
23    in_channels: usize,
24    /// Output channels.
25    out_channels: usize,
26}
27
28impl Conv2D {
29    fn new(in_channels: usize, out_channels: usize, kernel_size: usize, stride: usize) -> Self {
30        let k2 = kernel_size * kernel_size;
31        let fan_in = in_channels * k2;
32        let scale = (2.0 / fan_in as f64).sqrt();
33        let mut kernel = Array2::zeros((out_channels, in_channels * k2));
34        for i in 0..out_channels {
35            for j in 0..in_channels * k2 {
36                kernel[[i, j]] = ((i * 7 + j * 13 + 5) as f64).sin() * scale;
37            }
38        }
39        Self {
40            kernel,
41            bias: vec![0.0; out_channels],
42            kernel_size,
43            stride,
44            in_channels,
45            out_channels,
46        }
47    }
48
49    /// Apply convolution on a feature map stored as `(H * W, C)`.
50    ///
51    /// `h` and `w` are the spatial dimensions of the input. Returns `(H_out *
52    /// W_out, out_channels)` together with the new `(h_out, w_out)`.
53    fn forward(
54        &self,
55        input: &Array2<f64>,
56        h: usize,
57        w: usize,
58    ) -> Result<(Array2<f64>, usize, usize)> {
59        if input.nrows() != h * w {
60            return Err(VisionError::DimensionMismatch(format!(
61                "Conv2D: input rows {} != h*w {}",
62                input.nrows(),
63                h * w
64            )));
65        }
66        if input.ncols() != self.in_channels {
67            return Err(VisionError::DimensionMismatch(format!(
68                "Conv2D: input cols {} != in_channels {}",
69                input.ncols(),
70                self.in_channels
71            )));
72        }
73
74        let pad = self.kernel_size / 2;
75        let h_out = (h + 2 * pad - self.kernel_size) / self.stride + 1;
76        let w_out = (w + 2 * pad - self.kernel_size) / self.stride + 1;
77
78        let mut out = Array2::zeros((h_out * w_out, self.out_channels));
79
80        for oy in 0..h_out {
81            for ox in 0..w_out {
82                let out_idx = oy * w_out + ox;
83                for oc in 0..self.out_channels {
84                    let mut val = self.bias[oc];
85                    for ky in 0..self.kernel_size {
86                        for kx in 0..self.kernel_size {
87                            let iy = oy * self.stride + ky;
88                            let ix = ox * self.stride + kx;
89                            // Subtract pad offset; clamp to zero-padding.
90                            let iy_s = iy as isize - pad as isize;
91                            let ix_s = ix as isize - pad as isize;
92                            if iy_s < 0 || iy_s >= h as isize || ix_s < 0 || ix_s >= w as isize {
93                                continue;
94                            }
95                            let in_idx = iy_s as usize * w + ix_s as usize;
96                            for ic in 0..self.in_channels {
97                                let k_idx = ic * self.kernel_size * self.kernel_size
98                                    + ky * self.kernel_size
99                                    + kx;
100                                val += input[[in_idx, ic]] * self.kernel[[oc, k_idx]];
101                            }
102                        }
103                    }
104                    out[[out_idx, oc]] = val;
105                }
106            }
107        }
108
109        Ok((out, h_out, w_out))
110    }
111}
112
113/// Apply ReLU in-place.
114fn relu_inplace(arr: &mut Array2<f64>) {
115    arr.mapv_inplace(|v| v.max(0.0));
116}
117
118// ---------------------------------------------------------------------------
119// SimpleBEVBackbone
120// ---------------------------------------------------------------------------
121
122/// Three-block 2D CNN backbone operating on BEV feature maps.
123///
124/// Block layout:
125/// 1. Conv2D(in, 64, 3, stride=2) + ReLU
126/// 2. Conv2D(64, 128, 3, stride=2) + ReLU
127/// 3. Conv2D(128, 256, 3, stride=2) + ReLU
128#[derive(Debug, Clone)]
129pub struct SimpleBEVBackbone {
130    conv1: Conv2D,
131    conv2: Conv2D,
132    conv3: Conv2D,
133}
134
135impl SimpleBEVBackbone {
136    /// Create a new backbone with `in_channels` input feature channels.
137    pub fn new(in_channels: usize) -> Self {
138        Self {
139            conv1: Conv2D::new(in_channels, 64, 3, 2),
140            conv2: Conv2D::new(64, 128, 3, 2),
141            conv3: Conv2D::new(128, 256, 3, 2),
142        }
143    }
144
145    /// Run the backbone. `bev_features` has shape `(H*W, C)`.
146    ///
147    /// Returns a list of three multi-scale feature maps together with their
148    /// spatial dimensions: `[(features, h, w); 3]`.
149    pub fn forward(
150        &self,
151        bev_features: &Array2<f64>,
152        h: usize,
153        w: usize,
154    ) -> Result<Vec<(Array2<f64>, usize, usize)>> {
155        let (mut f1, h1, w1) = self.conv1.forward(bev_features, h, w)?;
156        relu_inplace(&mut f1);
157
158        let (mut f2, h2, w2) = self.conv2.forward(&f1, h1, w1)?;
159        relu_inplace(&mut f2);
160
161        let (mut f3, h3, w3) = self.conv3.forward(&f2, h2, w2)?;
162        relu_inplace(&mut f3);
163
164        Ok(vec![(f1, h1, w1), (f2, h2, w2), (f3, h3, w3)])
165    }
166}
167
168// ---------------------------------------------------------------------------
169// FeaturePyramidNeck
170// ---------------------------------------------------------------------------
171
172/// Feature Pyramid Network neck: upsamples and concatenates multi-scale features
173/// from the backbone into a unified feature map at the first scale.
174#[derive(Debug, Clone)]
175pub struct FeaturePyramidNeck {
176    /// 1x1 convolutions to unify channel counts.
177    lateral2: Conv2D,
178    lateral3: Conv2D,
179    /// Target output channels.
180    out_channels: usize,
181}
182
183impl FeaturePyramidNeck {
184    /// Create a new FPN neck that maps all backbone scales to `out_channels`.
185    pub fn new(out_channels: usize) -> Self {
186        // 1x1 convs to reduce channels to out_channels.
187        Self {
188            lateral2: Conv2D::new(128, out_channels, 1, 1),
189            lateral3: Conv2D::new(256, out_channels, 1, 1),
190            out_channels,
191        }
192    }
193
194    /// Fuse multi-scale features.
195    ///
196    /// Takes the three feature maps from `SimpleBEVBackbone::forward` and
197    /// returns a single `(H1*W1, out_channels)` feature map at the first
198    /// backbone scale.
199    pub fn forward(
200        &self,
201        scales: &[(Array2<f64>, usize, usize)],
202    ) -> Result<(Array2<f64>, usize, usize)> {
203        if scales.len() < 3 {
204            return Err(VisionError::InvalidParameter(
205                "FeaturePyramidNeck expects 3 scales".to_string(),
206            ));
207        }
208
209        let (ref f1, h1, w1) = scales[0];
210        let (ref f2, h2, w2) = scales[1];
211        let (ref f3, h3, w3) = scales[2];
212
213        // Lateral projections.
214        let (l2, _, _) = self.lateral2.forward(f2, h2, w2)?;
215        let (l3, _, _) = self.lateral3.forward(f3, h3, w3)?;
216
217        // Upsample l3 to l2 size via nearest-neighbour.
218        let l3_up = nearest_upsample(&l3, h3, w3, h2, w2);
219        // Add l3_up + l2.
220        let fused2 = elementwise_add(&l3_up, &l2)?;
221
222        // Upsample fused2 to f1 size.
223        let fused2_up = nearest_upsample(&fused2, h2, w2, h1, w1);
224
225        // If f1 channels differ from out_channels, we need a lateral; for
226        // simplicity, just take the first `out_channels` columns or pad.
227        let n1_cols = f1.ncols();
228        let mut out = Array2::zeros((h1 * w1, self.out_channels));
229        let copy_cols = n1_cols.min(self.out_channels);
230        for r in 0..h1 * w1 {
231            for c in 0..copy_cols {
232                out[[r, c]] = f1[[r, c]];
233            }
234            // Add upsampled fused features.
235            for c in 0..self.out_channels {
236                out[[r, c]] += fused2_up[[r, c]];
237            }
238        }
239
240        Ok((out, h1, w1))
241    }
242}
243
244/// Nearest-neighbour upsample `(h_in * w_in, C)` → `(h_out * w_out, C)`.
245fn nearest_upsample(
246    feat: &Array2<f64>,
247    h_in: usize,
248    w_in: usize,
249    h_out: usize,
250    w_out: usize,
251) -> Array2<f64> {
252    let c = feat.ncols();
253    let mut out = Array2::zeros((h_out * w_out, c));
254    for oy in 0..h_out {
255        let iy = (oy * h_in) / h_out.max(1);
256        let iy = iy.min(h_in.saturating_sub(1));
257        for ox in 0..w_out {
258            let ix = (ox * w_in) / w_out.max(1);
259            let ix = ix.min(w_in.saturating_sub(1));
260            let src = iy * w_in + ix;
261            let dst = oy * w_out + ox;
262            for k in 0..c {
263                out[[dst, k]] = feat[[src, k]];
264            }
265        }
266    }
267    out
268}
269
270/// Element-wise addition of two arrays with identical shapes.
271fn elementwise_add(a: &Array2<f64>, b: &Array2<f64>) -> Result<Array2<f64>> {
272    if a.shape() != b.shape() {
273        return Err(VisionError::DimensionMismatch(format!(
274            "elementwise_add: shapes {:?} vs {:?}",
275            a.shape(),
276            b.shape()
277        )));
278    }
279    Ok(a + b)
280}
281
282// ---------------------------------------------------------------------------
283// Tests
284// ---------------------------------------------------------------------------
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use scirs2_core::ndarray::Array2;
290
291    #[test]
292    fn conv2d_output_shape() {
293        let conv = Conv2D::new(4, 8, 3, 2);
294        let input = Array2::zeros((16 * 16, 4));
295        let (out, h, w) = conv.forward(&input, 16, 16).expect("conv forward");
296        assert_eq!(h, 8);
297        assert_eq!(w, 8);
298        assert_eq!(out.nrows(), 64);
299        assert_eq!(out.ncols(), 8);
300    }
301
302    #[test]
303    fn backbone_forward() {
304        let bb = SimpleBEVBackbone::new(4);
305        let input = Array2::zeros((16 * 16, 4));
306        let scales = bb.forward(&input, 16, 16).expect("backbone forward");
307        assert_eq!(scales.len(), 3);
308        // Each scale halves spatial dims.
309        assert_eq!(scales[0].1, 8);
310        assert_eq!(scales[1].1, 4);
311        assert_eq!(scales[2].1, 2);
312    }
313
314    #[test]
315    fn fpn_forward() {
316        let bb = SimpleBEVBackbone::new(4);
317        let input = Array2::zeros((16 * 16, 4));
318        let scales = bb.forward(&input, 16, 16).expect("backbone forward");
319        let fpn = FeaturePyramidNeck::new(64);
320        let (out, h, w) = fpn.forward(&scales).expect("fpn forward");
321        assert_eq!(h, 8);
322        assert_eq!(w, 8);
323        assert_eq!(out.ncols(), 64);
324    }
325
326    #[test]
327    fn nearest_upsample_identity() {
328        let feat = Array2::from_elem((4, 2), 1.0);
329        let up = nearest_upsample(&feat, 2, 2, 2, 2);
330        assert_eq!(up.shape(), &[4, 2]);
331        assert!((up[[0, 0]] - 1.0).abs() < 1e-12);
332    }
333}