Skip to main content

hrx/image/
resize.rs

1use super::*;
2
3/// RGB resize into a rectangle on a black canvas. Uses half-pixel bilinear
4/// interpolation, clamped borders, and separable integer rounding (vertical
5/// before horizontal). No antialias filter is applied when shrinking.
6#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
7pub struct RgbResize {
8    /// Canvas height.
9    pub height: usize,
10    /// Canvas width.
11    pub width: usize,
12    /// Destination rectangle `[left, top, width, height]`.
13    pub region: [usize; 4],
14}
15impl RgbResize {
16    /// Resize to the entire canvas.
17    pub fn new(height: usize, width: usize) -> Self {
18        Self {
19            height,
20            width,
21            region: [0, 0, width, height],
22        }
23    }
24
25    /// Fit within a canvas, using integer aspect-ratio truncation. Padding is
26    /// bottom/right, or split equally (with the odd pixel on bottom/right).
27    pub fn letterbox(
28        source_height: usize,
29        source_width: usize,
30        height: usize,
31        width: usize,
32        centered: bool,
33    ) -> Result<Self> {
34        if [source_height, source_width, height, width].contains(&0) {
35            return Err(Error::Message("resize dimensions must be nonzero".into()));
36        }
37        let horizontal = (width as u128) * source_height as u128;
38        let vertical = (height as u128) * source_width as u128;
39        let (w, h) = if horizontal <= vertical {
40            (width, (horizontal / source_width as u128) as usize)
41        } else {
42            ((vertical / source_height as u128) as usize, height)
43        };
44        if w == 0 || h == 0 {
45            return Err(Error::Message("image aspect ratio is too extreme".into()));
46        }
47        Ok(Self {
48            height,
49            width,
50            region: [
51                if centered { (width - w) / 2 } else { 0 },
52                if centered { (height - h) / 2 } else { 0 },
53                w,
54                h,
55            ],
56        })
57    }
58}
59
60// Half-pixel triangle weights normalized at boundaries. Integer coefficient
61// precision and pass rounding match fast_image_resize's U8 bilinear contract.
62// These are shape constants, prepared once, not per-image CPU processing.
63fn coefficients(source: usize, output: usize) -> (Vec<u8>, usize) {
64    let weights: Vec<_> = (0..output)
65        .map(|i| {
66            let x = (i as f64 + 0.5) * (source as f64 / output as f64) - 0.5;
67            let start = x.floor();
68            if start < 0. {
69                (0, 0, 1., 0.)
70            } else if start >= (source - 1) as f64 {
71                (source - 1, source - 1, 1., 0.)
72            } else {
73                (
74                    start as usize,
75                    start as usize + 1,
76                    1. - (x - start),
77                    x - start,
78                )
79            }
80        })
81        .collect();
82    let max = weights.iter().map(|w| w.2.max(w.3)).fold(0f64, f64::max);
83    let precision = (0..22)
84        .find(|p| (max * (1u32 << (p + 1)) as f64).round() >= 32768.)
85        .unwrap_or(21);
86    let scale = (1u32 << precision) as f64;
87    let bytes = weights
88        .iter()
89        .flat_map(|&(a, b, x, y)| {
90            [
91                a as i32,
92                b as i32,
93                (x * scale).round() as i32,
94                (y * scale).round() as i32,
95            ]
96            .into_iter()
97            .flat_map(i32::to_le_bytes)
98        })
99        .collect();
100    (bytes, precision)
101}
102
103impl ImageOps {
104    /// Prepare a reusable three-slot RGB resize/letterbox plan for contiguous
105    /// U8 NHWC `[batch,height,width,3]`. Only image bytes are runtime inputs.
106    pub fn prepare_resize_rgb(
107        &self,
108        input: &TensorDesc,
109        resize: RgbResize,
110    ) -> Result<Arc<PreparedModel>> {
111        self.resize_plans
112            .get_or_prepare((input.clone(), resize), || {
113                self.resize_rgb_fragment(input, resize)?.prepare(3)
114            })
115    }
116
117    /// Recordable resize/letterbox for direct composition with a model graph.
118    pub fn resize_rgb_fragment(
119        &self,
120        input: &TensorDesc,
121        resize: RgbResize,
122    ) -> Result<ModelFragment> {
123        let shape = input.shape();
124        let [left, top, width, height] = resize.region;
125        if input.dtype() != DType::U8
126            || input.layout() != Layout::Nhwc
127            || !input.is_contiguous()
128            || input.is_empty()
129            || shape.len() != 4
130            || shape[3] != 3
131            || input.elements() > i32::MAX as usize
132            || width == 0
133            || height == 0
134            || left.checked_add(width).is_none_or(|v| v > resize.width)
135            || top.checked_add(height).is_none_or(|v| v > resize.height)
136        {
137            return Err(Error::Message(
138                "invalid NHWC RGB resize dimensions or rectangle".into(),
139            ));
140        }
141        let output = TensorDesc::new(DType::U8, vec![shape[0], resize.height, resize.width, 3])?
142            .with_layout(Layout::Nhwc)?;
143        let intermediate = TensorDesc::new(DType::U8, vec![shape[0], height, shape[2], 3])?;
144        if output.elements() > i32::MAX as usize || intermediate.elements() > i32::MAX as usize {
145            return Err(Error::Message(
146                "resize exceeds kernel indexing limits".into(),
147            ));
148        }
149        let mut model = ModelSession::in_context(&self.context)?;
150        let src = model.allocate(input.bytes())?;
151        let tmp = model.allocate(intermediate.bytes())?;
152        let dst = model.allocate(output.bytes())?;
153        let mut commands = Vec::new();
154        for (vertical, from, to, sh, sw, dh, dw, x, y, innerw, innerh, elements) in [
155            (
156                true,
157                src,
158                tmp,
159                shape[1],
160                shape[2],
161                height,
162                shape[2],
163                0,
164                0,
165                shape[2],
166                height,
167                intermediate.elements(),
168            ),
169            (
170                false,
171                tmp,
172                dst,
173                height,
174                shape[2],
175                resize.height,
176                resize.width,
177                left,
178                top,
179                width,
180                height,
181                output.elements(),
182            ),
183        ] {
184            let axis = if vertical { innerh } else { innerw };
185            let (bytes, precision) = coefficients(if vertical { sh } else { sw }, axis);
186            let table = model.weight(&bytes)?;
187            let mut source = include_str!("resize_rgb.loom")
188                .replace("@VERTICAL@", if vertical { "true" } else { "false" });
189            for (name, value) in [
190                ("COUNT", elements),
191                ("LAST", elements - 1),
192                ("INPUT", shape[0] * sh * sw * 3),
193                ("INPUT_LAST", shape[0] * sh * sw * 3 - 1),
194                ("COEFFS", axis * 4),
195                ("COEFFS_LAST", axis * 4 - 1),
196                ("AXIS_LAST", if vertical { sh - 1 } else { sw - 1 }),
197                ("GRID", elements.div_ceil(256)),
198                ("SH", sh),
199                ("SW", sw),
200                ("DH", dh),
201                ("DW", dw),
202                ("LEFT", x),
203                ("TOP", y),
204                ("RIGHT", x + innerw),
205                ("BOTTOM", y + innerh),
206                ("PRECISION", precision),
207                ("ROUND", 1 << (precision - 1)),
208            ] {
209                source = source.replace(&format!("@{name}@"), &value.to_string());
210            }
211            // All table entries are generated within the source extent;
212            // output coordinates are guarded before addressing the table.
213            let kernel =
214                unsafe { model.compile(&[(&source, Specialization::new("resize_rgb"))])? }[0];
215            commands.push(Command::Dispatch(Dispatch::indices(
216                kernel,
217                [0],
218                [elements.div_ceil(256) as u32, 1, 1],
219                vec![from.read(), table.read(), to.write()],
220            )));
221        }
222        unsafe {
223            model.freeze(&self.context)?.fragment(
224                &commands,
225                &[(src, input.clone())],
226                &[(dst, output)],
227            )
228        }
229    }
230
231    /// Resize a resident RGB batch; output padding is initialized to black on
232    /// every replay. Retained outputs apply bounded per-shape backpressure.
233    pub fn resize_rgb(&self, image: &DeviceTensor, resize: RgbResize) -> Result<Inference> {
234        self.context.validate(image)?;
235        self.prepare_resize_rgb(image.desc(), resize)?
236            .submit(std::slice::from_ref(image))
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    #[test]
244    fn letterbox_geometry_is_checked() {
245        assert_eq!(
246            RgbResize::letterbox(100, 200, 640, 640, false)
247                .unwrap()
248                .region,
249            [0, 0, 640, 320]
250        );
251        assert_eq!(
252            RgbResize::letterbox(200, 100, 640, 640, true)
253                .unwrap()
254                .region,
255            [160, 0, 320, 640]
256        );
257        assert!(RgbResize::letterbox(0, 100, 640, 640, false).is_err());
258        assert!(RgbResize::letterbox(1, 10000, 1, 1, false).is_err());
259    }
260}