Skip to main content

box_image_pyramid/
pyramid.rs

1//! Image pyramid construction using fixed 2x box-filter downsampling.
2//!
3//! The API is allocation-friendly: construct a [`PyramidBuffers`] once, then
4//! reuse it to build pyramids for successive frames without re-allocating
5//! intermediate levels. When both the `par_pyramid` and `simd` features are
6//! enabled, the 2x box downsample uses portable SIMD for higher throughput.
7
8use crate::imageview::{ImageBuffer, ImageView};
9#[cfg(feature = "tracing")]
10use tracing::instrument;
11
12/// Reusable backing storage for pyramid construction.
13///
14/// Typically you construct a [`PyramidBuffers`] once (for example with
15/// [`PyramidBuffers::with_capacity`]) and reuse it across frames by
16/// passing a mutable reference into [`build_pyramid`]. The internal level
17/// buffers are resized on demand to match the requested pyramid shape.
18pub struct PyramidBuffers {
19    levels: Vec<ImageBuffer>,
20}
21
22impl Default for PyramidBuffers {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28impl PyramidBuffers {
29    /// Create an empty buffer set.
30    pub fn new() -> Self {
31        Self { levels: Vec::new() }
32    }
33
34    /// Create a buffer set with capacity reserved for `num_levels`.
35    pub fn with_capacity(num_levels: u8) -> Self {
36        Self {
37            levels: Vec::with_capacity(num_levels.saturating_sub(1) as usize),
38        }
39    }
40
41    fn ensure_level_shape(&mut self, idx: usize, w: usize, h: usize) {
42        if idx >= self.levels.len() {
43            self.levels.resize_with(idx + 1, || ImageBuffer::new(w, h));
44        }
45
46        let level = &mut self.levels[idx];
47        if level.width != w || level.height != h {
48            *level = ImageBuffer::new(w, h);
49        }
50    }
51}
52
53/// A single level of a [`Pyramid`].
54#[non_exhaustive]
55pub struct PyramidLevel<'a> {
56    /// Image data for this level. Level 0 borrows the caller-supplied
57    /// base; subsequent levels are views into [`PyramidBuffers`].
58    pub img: ImageView<'a>,
59    /// Scale of this level relative to the base image. Level 0 is
60    /// always `1.0`; each subsequent level is half the previous
61    /// (i.e. `0.5`, `0.25`, …).
62    pub scale: f32,
63}
64
65/// A top-down image pyramid produced by [`build_pyramid`].
66///
67/// `levels[0]` is always the base (full resolution, `scale = 1.0`).
68/// Each subsequent level is a 2× box-filter downsample of the previous.
69/// The number of levels is determined by [`PyramidParams::num_levels`]
70/// and [`PyramidParams::min_size`].
71#[non_exhaustive]
72pub struct Pyramid<'a> {
73    /// Ordered pyramid levels from base (index 0) to coarsest.
74    pub levels: Vec<PyramidLevel<'a>>,
75}
76
77/// Parameters controlling pyramid generation.
78#[derive(Clone, Debug)]
79#[non_exhaustive]
80pub struct PyramidParams {
81    /// Maximum number of levels (including the base).
82    pub num_levels: u8,
83    /// Stop building when either dimension falls below this value.
84    pub min_size: usize,
85}
86
87impl Default for PyramidParams {
88    fn default() -> Self {
89        Self {
90            num_levels: 1,
91            min_size: 128,
92        }
93    }
94}
95
96/// Build a top-down image pyramid using fixed 2x downsampling.
97///
98/// The base image is always included as level 0. Each subsequent level is a
99/// 2x downsampled copy (box filter) written into `buffers`. Construction stops
100/// when:
101/// - either dimension would fall below `min_size`, or
102/// - `num_levels` is reached.
103#[cfg_attr(
104    feature = "tracing",
105    instrument(
106        level = "info",
107        skip(base, params, buffers),
108        fields(levels = params.num_levels, min_size = params.min_size)
109    )
110)]
111pub fn build_pyramid<'a>(
112    base: ImageView<'a>,
113    params: &PyramidParams,
114    buffers: &'a mut PyramidBuffers,
115) -> Pyramid<'a> {
116    if params.num_levels == 0 || base.width < params.min_size || base.height < params.min_size {
117        return Pyramid { levels: Vec::new() };
118    }
119
120    #[derive(Clone, Copy)]
121    enum LevelSource {
122        Base,
123        Buffer(usize),
124    }
125
126    let mut sources: Vec<(LevelSource, f32)> = Vec::with_capacity(params.num_levels as usize);
127    sources.push((LevelSource::Base, 1.0));
128
129    let mut current_src = LevelSource::Base;
130    let mut current_w = base.width;
131    let mut current_h = base.height;
132    let mut scale = 1.0f32;
133
134    for level_idx in 1..params.num_levels {
135        let w2 = current_w / 2;
136        let h2 = current_h / 2;
137
138        if w2 == 0 || h2 == 0 || w2 < params.min_size || h2 < params.min_size {
139            break;
140        }
141
142        let buf_idx = (level_idx - 1) as usize;
143        buffers.ensure_level_shape(buf_idx, w2, h2);
144
145        let (src_img, dst): (ImageView<'_>, &mut ImageBuffer) = match current_src {
146            LevelSource::Base => (base, &mut buffers.levels[buf_idx]),
147            LevelSource::Buffer(src_idx) => {
148                debug_assert!(src_idx < buf_idx);
149                let (head, tail) = buffers.levels.split_at_mut(buf_idx);
150                (head[src_idx].as_view(), &mut tail[0])
151            }
152        };
153
154        downsample_2x_box(src_img, dst);
155
156        scale *= 0.5;
157        current_src = LevelSource::Buffer(buf_idx);
158        current_w = w2;
159        current_h = h2;
160        sources.push((current_src, scale));
161    }
162
163    let mut levels = Vec::with_capacity(sources.len());
164    for (source, lvl_scale) in sources {
165        let img = match source {
166            LevelSource::Base => base,
167            LevelSource::Buffer(idx) => buffers.levels[idx].as_view(),
168        };
169        levels.push(PyramidLevel {
170            img,
171            scale: lvl_scale,
172        });
173    }
174
175    Pyramid { levels }
176}
177
178/// Fast 2x downsample with a 2x2 box filter into a pre-allocated destination.
179///
180/// Uses SIMD and/or `rayon` specializations when the `par_pyramid`
181/// feature is enabled alongside the relevant flags.
182#[inline]
183fn downsample_2x_box(src: ImageView<'_>, dst: &mut ImageBuffer) {
184    #[cfg(all(feature = "par_pyramid", feature = "rayon", feature = "simd"))]
185    return downsample_2x_box_parallel_simd(src, dst);
186
187    #[cfg(all(feature = "par_pyramid", feature = "rayon", not(feature = "simd")))]
188    return downsample_2x_box_parallel_scalar(src, dst);
189
190    #[cfg(all(feature = "par_pyramid", not(feature = "rayon"), feature = "simd"))]
191    return downsample_2x_box_simd(src, dst);
192
193    #[cfg(all(feature = "par_pyramid", not(feature = "rayon"), not(feature = "simd")))]
194    return downsample_2x_box_scalar(src, dst);
195
196    #[cfg(not(feature = "par_pyramid"))]
197    return downsample_2x_box_scalar(src, dst);
198}
199
200#[inline]
201#[cfg_attr(
202    all(feature = "par_pyramid", any(feature = "rayon", feature = "simd")),
203    allow(dead_code)
204)]
205fn downsample_2x_box_scalar(src: ImageView<'_>, dst: &mut ImageBuffer) {
206    debug_assert_eq!(src.width / 2, dst.width);
207    debug_assert_eq!(src.height / 2, dst.height);
208
209    let src_w = src.width;
210    let dst_w = dst.width;
211    let dst_h = dst.height;
212
213    for y in 0..dst_h {
214        let row0 = (y * 2) * src_w;
215        let row1 = row0 + src_w;
216
217        downsample_row_scalar(
218            &src.data[row0..row0 + src_w],
219            &src.data[row1..row1 + src_w],
220            &mut dst.data[y * dst_w..(y + 1) * dst_w],
221        );
222    }
223}
224
225#[cfg(all(feature = "par_pyramid", not(feature = "rayon"), feature = "simd"))]
226fn downsample_2x_box_simd(src: ImageView<'_>, dst: &mut ImageBuffer) {
227    debug_assert_eq!(src.width / 2, dst.width);
228    debug_assert_eq!(src.height / 2, dst.height);
229
230    let src_w = src.width;
231    let dst_w = dst.width;
232    let dst_h = dst.height;
233
234    for y_out in 0..dst_h {
235        let y0 = 2 * y_out;
236        let y1 = y0 + 1;
237
238        let row0 = &src.data[y0 * src_w..(y0 + 1) * src_w];
239        let row1 = &src.data[y1 * src_w..(y1 + 1) * src_w];
240
241        let dst_row = &mut dst.data[y_out * dst_w..(y_out + 1) * dst_w];
242
243        downsample_row_simd(row0, row1, dst_row);
244    }
245}
246
247#[cfg(all(feature = "par_pyramid", feature = "rayon", not(feature = "simd")))]
248fn downsample_2x_box_parallel_scalar(src: ImageView<'_>, dst: &mut ImageBuffer) {
249    use rayon::prelude::*;
250
251    debug_assert_eq!(src.width / 2, dst.width);
252    debug_assert_eq!(src.height / 2, dst.height);
253
254    let src_w = src.width;
255    let dst_w = dst.width;
256
257    dst.data
258        .par_chunks_mut(dst_w)
259        .enumerate()
260        .for_each(|(y_out, dst_row)| {
261            let y0 = 2 * y_out;
262            let y1 = y0 + 1;
263
264            let row0 = &src.data[y0 * src_w..(y0 + 1) * src_w];
265            let row1 = &src.data[y1 * src_w..(y1 + 1) * src_w];
266
267            downsample_row_scalar(row0, row1, dst_row);
268        });
269}
270
271#[cfg(all(feature = "par_pyramid", feature = "rayon", feature = "simd"))]
272fn downsample_2x_box_parallel_simd(src: ImageView<'_>, dst: &mut ImageBuffer) {
273    use rayon::prelude::*;
274
275    debug_assert_eq!(src.width / 2, dst.width);
276    debug_assert_eq!(src.height / 2, dst.height);
277
278    let src_w = src.width;
279    let dst_w = dst.width;
280
281    dst.data
282        .par_chunks_mut(dst_w)
283        .enumerate()
284        .for_each(|(y_out, dst_row)| {
285            let y0 = 2 * y_out;
286            let y1 = y0 + 1;
287
288            let row0 = &src.data[y0 * src_w..(y0 + 1) * src_w];
289            let row1 = &src.data[y1 * src_w..(y1 + 1) * src_w];
290
291            downsample_row_simd(row0, row1, dst_row);
292        });
293}
294
295#[inline]
296fn downsample_row_scalar(row0: &[u8], row1: &[u8], dst_row: &mut [u8]) {
297    let dst_w = dst_row.len();
298
299    for (x, item) in dst_row.iter_mut().enumerate().take(dst_w) {
300        let sx = x * 2;
301        let p00 = row0[sx] as u16;
302        let p01 = row0[sx + 1] as u16;
303        let p10 = row1[sx] as u16;
304        let p11 = row1[sx + 1] as u16;
305        let sum = p00 + p01 + p10 + p11;
306        *item = ((sum + 2) >> 2) as u8;
307    }
308}
309
310#[cfg(all(feature = "par_pyramid", feature = "simd"))]
311fn downsample_row_simd(row0: &[u8], row1: &[u8], dst_row: &mut [u8]) {
312    use std::ops::Shr;
313    use std::simd::num::SimdUint;
314    use std::simd::{u16x16, u8x16};
315
316    const LANES: usize = 16;
317    let mut x_out = 0usize;
318
319    while x_out + LANES <= dst_row.len() {
320        let mut p00 = [0u8; LANES];
321        let mut p01 = [0u8; LANES];
322        let mut p10 = [0u8; LANES];
323        let mut p11 = [0u8; LANES];
324
325        for lane in 0..LANES {
326            let x = x_out + lane;
327            let sx = 2 * x;
328            p00[lane] = row0[sx];
329            p01[lane] = row0[sx + 1];
330            p10[lane] = row1[sx];
331            p11[lane] = row1[sx + 1];
332        }
333
334        let p00v = u8x16::from_array(p00).cast::<u16>();
335        let p01v = u8x16::from_array(p01).cast::<u16>();
336        let p10v = u8x16::from_array(p10).cast::<u16>();
337        let p11v = u8x16::from_array(p11).cast::<u16>();
338
339        let sum = p00v + p01v + p10v + p11v;
340        let avg = (sum + u16x16::splat(2)).shr(2);
341        let out = avg.cast::<u8>();
342
343        dst_row[x_out..x_out + LANES].copy_from_slice(out.as_array());
344        x_out += LANES;
345    }
346
347    // Tail
348    if x_out < dst_row.len() {
349        downsample_row_scalar(row0, row1, &mut dst_row[x_out..]);
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    fn reference_downsample(src: &ImageBuffer) -> ImageBuffer {
358        let mut dst = ImageBuffer::new(src.width / 2, src.height / 2);
359        downsample_2x_box_scalar(src.as_view(), &mut dst);
360        dst
361    }
362
363    fn gray_to_buffer(w: u32, h: u32, data: Vec<u8>) -> ImageBuffer {
364        ImageBuffer {
365            width: w as usize,
366            height: h as usize,
367            data,
368        }
369    }
370
371    fn make_checker(w: u32, h: u32, a: u8, b: u8) -> ImageBuffer {
372        let mut data = vec![0u8; (w * h) as usize];
373        for y in 0..h {
374            for x in 0..w {
375                data[(y * w + x) as usize] = if (x + y) % 2 == 0 { a } else { b };
376            }
377        }
378        gray_to_buffer(w, h, data)
379    }
380
381    #[test]
382    fn downsample_matches_reference() {
383        let mut src = ImageBuffer::new(8, 8);
384        for (i, p) in src.data.iter_mut().enumerate() {
385            *p = (i % 251) as u8;
386        }
387        let mut dst = ImageBuffer::new(4, 4);
388        downsample_2x_box(src.as_view(), &mut dst);
389        let expected = reference_downsample(&src);
390        assert_eq!(dst.data, expected.data);
391    }
392
393    #[test]
394    fn downsample_matches_reference_on_checker() {
395        let src = make_checker(16, 14, 0, 255);
396        let mut dst = ImageBuffer::new(src.width / 2, src.height / 2);
397        downsample_2x_box(src.as_view(), &mut dst);
398        let expected = reference_downsample(&src);
399        assert_eq!(dst.data, expected.data);
400    }
401
402    #[test]
403    fn build_pyramid_single_level() {
404        let img = ImageBuffer::new(64, 64);
405        let params = PyramidParams {
406            num_levels: 1,
407            min_size: 16,
408        };
409        let mut buffers = PyramidBuffers::new();
410        let pyramid = build_pyramid(img.as_view(), &params, &mut buffers);
411        assert_eq!(pyramid.levels.len(), 1);
412        assert_eq!(pyramid.levels[0].scale, 1.0);
413    }
414
415    #[test]
416    fn build_pyramid_multiple_levels() {
417        let img = ImageBuffer::new(128, 128);
418        let params = PyramidParams {
419            num_levels: 4,
420            min_size: 16,
421        };
422        let mut buffers = PyramidBuffers::new();
423        let pyramid = build_pyramid(img.as_view(), &params, &mut buffers);
424        assert_eq!(pyramid.levels.len(), 4);
425        assert_eq!(pyramid.levels[0].img.width, 128);
426        assert_eq!(pyramid.levels[1].img.width, 64);
427        assert_eq!(pyramid.levels[2].img.width, 32);
428        assert_eq!(pyramid.levels[3].img.width, 16);
429        assert!((pyramid.levels[3].scale - 0.125).abs() < 1e-6);
430    }
431
432    #[test]
433    fn build_pyramid_stops_at_min_size() {
434        let img = ImageBuffer::new(64, 64);
435        let params = PyramidParams {
436            num_levels: 10,
437            min_size: 32,
438        };
439        let mut buffers = PyramidBuffers::new();
440        let pyramid = build_pyramid(img.as_view(), &params, &mut buffers);
441        assert_eq!(pyramid.levels.len(), 2); // 64 -> 32, stops before 16
442    }
443}