Skip to main content

fitscube_rs/
bounding_box.rs

1//! Bounding-box geometry for trimming blank padding from images.
2//!
3//! Port of `fitscube.bounding_box`. A [`BoundingBox`] captures the pixel extent
4//! of the finite (non-NaN) data in a 2D image so that padded regions can be
5//! clipped when building a cube. The naming follows the Python original, where
6//! `x` is the slow (row / axis-0) direction and `y` is the fast (column /
7//! axis-1) direction.
8use ndarray::ArrayView2;
9use num_traits::Float;
10
11use crate::error::{FitsCubeError, Result};
12
13/// Pixel bounds of the valid (finite) region of a 2D image.
14///
15/// `*max` bounds are exclusive and may be used directly as the upper end of a
16/// slice (matching the Python `dataclass`).
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct BoundingBox {
19    /// Minimum x pixel (row / axis-0), inclusive.
20    pub xmin: usize,
21    /// Maximum x pixel (row / axis-0), exclusive.
22    pub xmax: usize,
23    /// Minimum y pixel (column / axis-1), inclusive.
24    pub ymin: usize,
25    /// Maximum y pixel (column / axis-1), exclusive.
26    pub ymax: usize,
27    /// Shape `(nrows, ncols)` of the plane the box was built from.
28    pub original_shape: (usize, usize),
29    /// `ymax - ymin`.
30    pub y_span: usize,
31    /// `xmax - xmin`.
32    pub x_span: usize,
33}
34
35impl BoundingBox {
36    fn new(
37        xmin: usize,
38        xmax: usize,
39        ymin: usize,
40        ymax: usize,
41        original_shape: (usize, usize),
42    ) -> Self {
43        Self {
44            xmin,
45            xmax,
46            ymin,
47            ymax,
48            original_shape,
49            y_span: ymax - ymin,
50            x_span: xmax - xmin,
51        }
52    }
53}
54
55/// Build a bounding box around the finite pixels of a 2D image.
56///
57/// Returns `None` if no pixel is finite (nothing to bound). Mirrors
58/// `create_bound_box_plane`: `x` indexes rows (axis 0), `y` indexes columns
59/// (axis 1); the `*max` values are made exclusive (`+1`).
60pub fn create_bound_box_plane<T: Float>(image: &ArrayView2<T>) -> Option<BoundingBox> {
61    let (nrows, ncols) = image.dim();
62
63    let mut xmin: Option<usize> = None;
64    let mut xmax = 0usize;
65    let mut ymin: Option<usize> = None;
66    let mut ymax = 0usize;
67    let mut any_valid = false;
68
69    for r in 0..nrows {
70        for c in 0..ncols {
71            if image[(r, c)].is_finite() {
72                any_valid = true;
73                if xmin.is_none() {
74                    xmin = Some(r);
75                }
76                xmax = r;
77                if ymin.is_none_or(|y| c < y) {
78                    ymin = Some(c);
79                }
80                if c > ymax {
81                    ymax = c;
82                }
83            }
84        }
85    }
86
87    if !any_valid {
88        tracing::info!("No pixels to create bounding box for");
89        return None;
90    }
91
92    // xmin is set once we have seen any valid pixel; xmax tracks the last valid
93    // row. ymin/ymax track first/last valid column. `+1` makes the max bounds
94    // exclusive, matching numpy slicing in the Python original.
95    let xmin = xmin.expect("any_valid implies xmin set");
96    let ymin = ymin.expect("any_valid implies ymin set");
97    Some(BoundingBox::new(
98        xmin,
99        xmax + 1,
100        ymin,
101        ymax + 1,
102        (nrows, ncols),
103    ))
104}
105
106/// Smallest bounding box that encompasses all the given boxes.
107///
108/// `None` entries (returned for fully blank images) are skipped. Errors if no
109/// valid box remains or if the boxes disagree on `original_shape`. Mirrors
110/// `extract_common_bounding_box`.
111pub fn extract_common_bounding_box(boxes: &[Option<BoundingBox>]) -> Result<BoundingBox> {
112    let valid: Vec<&BoundingBox> = boxes.iter().filter_map(|b| b.as_ref()).collect();
113
114    if valid.is_empty() {
115        return Err(FitsCubeError::Other(
116            "No valid input boxes to consider".to_string(),
117        ));
118    }
119
120    let shape0 = valid[0].original_shape;
121    if !valid.iter().all(|b| b.original_shape == shape0) {
122        return Err(FitsCubeError::Other(
123            "Different shapes, and not sure this is really supported or meaningful".to_string(),
124        ));
125    }
126
127    let xmin = valid.iter().map(|b| b.xmin).min().unwrap();
128    let xmax = valid.iter().map(|b| b.xmax).max().unwrap();
129    let ymin = valid.iter().map(|b| b.ymin).min().unwrap();
130    let ymax = valid.iter().map(|b| b.ymax).max().unwrap();
131
132    Ok(BoundingBox::new(xmin, xmax, ymin, ymax, shape0))
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use ndarray::Array2;
139
140    fn nan() -> f64 {
141        f64::NAN
142    }
143
144    #[test]
145    fn full_image_is_bounded_by_itself() {
146        let img = Array2::<f64>::ones((4, 5));
147        let bb = create_bound_box_plane(&img.view()).unwrap();
148        assert_eq!(bb.xmin, 0);
149        assert_eq!(bb.xmax, 4);
150        assert_eq!(bb.ymin, 0);
151        assert_eq!(bb.ymax, 5);
152        assert_eq!(bb.x_span, 4);
153        assert_eq!(bb.y_span, 5);
154        assert_eq!(bb.original_shape, (4, 5));
155    }
156
157    #[test]
158    fn nan_border_is_trimmed() {
159        let mut img = Array2::<f64>::from_elem((5, 5), nan());
160        // Valid block rows 1..=3, cols 2..=3.
161        for r in 1..=3 {
162            for c in 2..=3 {
163                img[(r, c)] = 1.0;
164            }
165        }
166        let bb = create_bound_box_plane(&img.view()).unwrap();
167        assert_eq!((bb.xmin, bb.xmax), (1, 4));
168        assert_eq!((bb.ymin, bb.ymax), (2, 4));
169        assert_eq!(bb.x_span, 3);
170        assert_eq!(bb.y_span, 2);
171    }
172
173    #[test]
174    fn all_nan_returns_none() {
175        let img = Array2::<f64>::from_elem((3, 3), nan());
176        assert!(create_bound_box_plane(&img.view()).is_none());
177    }
178
179    #[test]
180    fn common_box_is_union() {
181        let a = create_bound_box_plane(
182            &{
183                let mut m = Array2::<f64>::from_elem((6, 6), nan());
184                m[(1, 1)] = 1.0;
185                m
186            }
187            .view(),
188        )
189        .unwrap();
190        let b = create_bound_box_plane(
191            &{
192                let mut m = Array2::<f64>::from_elem((6, 6), nan());
193                m[(4, 4)] = 1.0;
194                m
195            }
196            .view(),
197        )
198        .unwrap();
199        let common = extract_common_bounding_box(&[Some(a), None, Some(b)]).unwrap();
200        assert_eq!((common.xmin, common.xmax), (1, 5));
201        assert_eq!((common.ymin, common.ymax), (1, 5));
202    }
203
204    #[test]
205    fn no_valid_boxes_errors() {
206        assert!(extract_common_bounding_box(&[None, None]).is_err());
207    }
208
209    #[test]
210    fn shape_mismatch_errors() {
211        let a = BoundingBox::new(0, 1, 0, 1, (4, 4));
212        let b = BoundingBox::new(0, 1, 0, 1, (5, 5));
213        assert!(extract_common_bounding_box(&[Some(a), Some(b)]).is_err());
214    }
215}