Skip to main content

tract_core/ops/nn/
resize.rs

1use crate::internal::*;
2use crate::ops::array::Tile;
3
4/// Maps an output coordinate back to the input axis. The ONNX coordinate
5/// transformation modes that have a well-defined inverse without an input ROI.
6#[derive(Clone, Debug, Hash, PartialEq, Eq)]
7pub enum CoordTransformer {
8    HalfPixel,
9    AlignCorners,
10    Asymmetric,
11    PytorchHalfPixel,
12    HalfPixelSymmetric,
13    TfHalfPixelForNn,
14}
15
16impl CoordTransformer {
17    pub fn transform(&self, x_out: usize, scale: f32, len_in: usize, len_out: usize) -> f32 {
18        match self {
19            CoordTransformer::HalfPixel => (x_out as f32 + 0.5) / scale - 0.5,
20            CoordTransformer::AlignCorners => {
21                let output_width = scale * len_in as f32;
22                if output_width == 1.0 {
23                    0.0
24                } else {
25                    (x_out as f32 * (len_in as f32 - 1.0)) / (output_width - 1.0)
26                }
27            }
28            CoordTransformer::Asymmetric => (x_out as f32) / scale,
29            CoordTransformer::PytorchHalfPixel => {
30                if len_out > 1 {
31                    (x_out as f32 + 0.5) / scale - 0.5
32                } else {
33                    -0.5
34                }
35            }
36            CoordTransformer::HalfPixelSymmetric => {
37                let adjustment = len_out as f32 / (scale * len_in as f32);
38                let offset = len_in as f32 / 2.0 * (1.0 - adjustment);
39                offset + (x_out as f32 + 0.5) / scale - 0.5
40            }
41            CoordTransformer::TfHalfPixelForNn => (x_out as f32 + 0.5) / scale,
42        }
43    }
44
45    pub fn as_str(&self) -> &'static str {
46        match self {
47            CoordTransformer::HalfPixel => "half_pixel",
48            CoordTransformer::AlignCorners => "align_corners",
49            CoordTransformer::Asymmetric => "asymmetric",
50            CoordTransformer::PytorchHalfPixel => "pytorch_half_pixel",
51            CoordTransformer::HalfPixelSymmetric => "half_pixel_symmetric",
52            CoordTransformer::TfHalfPixelForNn => "tf_half_pixel_for_nn",
53        }
54    }
55
56    pub fn parse(s: &str) -> TractResult<Self> {
57        Ok(match s {
58            "half_pixel" => CoordTransformer::HalfPixel,
59            "align_corners" => CoordTransformer::AlignCorners,
60            "asymmetric" => CoordTransformer::Asymmetric,
61            "pytorch_half_pixel" => CoordTransformer::PytorchHalfPixel,
62            "half_pixel_symmetric" => CoordTransformer::HalfPixelSymmetric,
63            "tf_half_pixel_for_nn" => CoordTransformer::TfHalfPixelForNn,
64            s => bail!("coordinate_transformation_mode: {s}"),
65        })
66    }
67}
68
69/// Interpolation kernel. `Linear` and `Nearest` use a 2-tap path; `Cubic` uses
70/// a 4-tap kernel with the standard `a = -0.75` coefficient.
71#[derive(Clone, Debug, Hash, PartialEq, Eq)]
72pub enum Interpolator {
73    Linear,
74    Nearest,
75    Cubic,
76}
77
78impl Interpolator {
79    pub fn as_str(&self) -> &'static str {
80        match self {
81            Interpolator::Linear => "linear",
82            Interpolator::Nearest => "nearest",
83            Interpolator::Cubic => "cubic",
84        }
85    }
86
87    pub fn parse(s: &str) -> TractResult<Self> {
88        Ok(match s {
89            "linear" => Interpolator::Linear,
90            "nearest" => Interpolator::Nearest,
91            "cubic" => Interpolator::Cubic,
92            s => bail!("mode: {s}"),
93        })
94    }
95}
96
97/// Number of input taps feeding one output element. Antialiasing widens the
98/// footprint of `Linear` and `Cubic` by `1/scale` when downscaling, which is
99/// what turns the interpolation into a low-pass filter.
100pub fn window_size(interpolator: &Interpolator, antialias: bool, scale: f32) -> usize {
101    let support = match interpolator {
102        Interpolator::Nearest | Interpolator::Linear => 1.0f32,
103        Interpolator::Cubic => 2.0,
104    };
105    if !antialias || scale >= 1.0 || matches!(interpolator, Interpolator::Nearest) {
106        return 2 * support as usize;
107    }
108    let first = (-support / scale).floor() as isize + 1;
109    (2 - 2 * first) as usize
110}
111
112/// Triangular kernel, low-passed by `scale` when antialiasing.
113pub fn linear_weights(r: f32, scale: f32, antialias: bool, weights: &mut [f32]) {
114    let scale = if antialias { scale.min(1.0) } else { 1.0 };
115    fill_weights(r, scale, weights, |x| (1.0 - x.abs()).clamp(0.0, 1.0));
116}
117
118/// Cubic convolution kernel of coefficient `a`, low-passed by `scale` when
119/// antialiasing.
120pub fn cubic_weights(r: f32, scale: f32, a: f32, antialias: bool, weights: &mut [f32]) {
121    let scale = if antialias { scale.min(1.0) } else { 1.0 };
122    fill_weights(r, scale, weights, |x| cubic_kernel(x, a));
123}
124
125/// Evaluates `kernel` at the offset of each tap in the window from the source
126/// coordinate, then renormalizes when the kernel was stretched by `scale`.
127fn fill_weights(r: f32, scale: f32, weights: &mut [f32], kernel: impl Fn(f32) -> f32) {
128    let first = 1.0 - (weights.len() / 2) as f32;
129    for (k, w) in weights.iter_mut().enumerate() {
130        *w = kernel((first + k as f32 - r) * scale);
131    }
132    if scale != 1.0 {
133        let sum: f32 = weights.iter().sum();
134        weights.iter_mut().for_each(|w| *w /= sum);
135    }
136}
137
138/// Standard Catmull-Rom-family cubic convolution kernel with coefficient `a`.
139pub fn cubic_kernel(s: f32, a: f32) -> f32 {
140    let abs_s = s.abs();
141    if abs_s <= 1.0 {
142        (a + 2.0) * abs_s * abs_s * abs_s - (a + 3.0) * abs_s * abs_s + 1.0
143    } else if abs_s <= 2.0 {
144        a * abs_s * abs_s * abs_s - 5.0 * a * abs_s * abs_s + 8.0 * a * abs_s - 4.0 * a
145    } else {
146        0.0
147    }
148}
149
150/// Nearest-neighbour tie-breaking. Restricted to the two modes tract-core
151/// supports: `Floor` (PyTorch `upsample_nearest`) and `RoundPreferCeil`
152/// (`_upsample_nearest_exact`). The other ONNX modes stay in the ONNX op.
153#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
154pub enum Nearest {
155    Floor,
156    RoundPreferCeil,
157}
158
159impl Nearest {
160    /// True when the right (ceil) neighbour wins for a fractional offset.
161    pub fn prefers_right(&self, x_ratio: f32) -> bool {
162        match self {
163            Nearest::Floor => false,
164            Nearest::RoundPreferCeil => x_ratio >= 0.5,
165        }
166    }
167
168    pub fn as_str(&self) -> &'static str {
169        match self {
170            Nearest::Floor => "floor",
171            Nearest::RoundPreferCeil => "round_prefer_ceil",
172        }
173    }
174
175    pub fn parse(s: &str) -> TractResult<Self> {
176        Ok(match s {
177            "floor" => Nearest::Floor,
178            "round_prefer_ceil" => Nearest::RoundPreferCeil,
179            s => bail!("nearest_mode: {s}"),
180        })
181    }
182}
183
184/// Resampling plan for one axis: for each output index, the `window` input taps
185/// it reads (already clamped into the axis) and their weights. `extrapolated`
186/// flags output indices that map outside the input, which are filled with a
187/// constant instead of being interpolated.
188#[derive(Clone, Debug)]
189pub struct AxisPlan {
190    pub window: usize,
191    pub indices: Vec<usize>,
192    pub weights: Vec<f32>,
193    pub extrapolated: Vec<bool>,
194}
195
196/// Builds the resampling plan for one axis. `coord` maps an output index to a
197/// source coordinate, or `None` when it falls outside the input. `weights`
198/// fills a window for a fractional offset in `(0, 1]`, the tap at index `k`
199/// sitting `k + 1 - window / 2` cells right of the source cell. With
200/// `exclude_outside` the taps falling off the axis are dropped and the
201/// remaining weights renormalized; otherwise they read the edge value.
202pub fn plan_axis(
203    len_in: usize,
204    len_out: usize,
205    window: usize,
206    exclude_outside: bool,
207    coord: impl Fn(usize) -> Option<f32>,
208    weights: impl Fn(f32, &mut [f32]),
209) -> AxisPlan {
210    let mut plan = AxisPlan {
211        window,
212        indices: vec![0; window * len_out],
213        weights: vec![0.0; window * len_out],
214        extrapolated: vec![false; len_out],
215    };
216    for x in 0..len_out {
217        let Some(x_in) = coord(x) else {
218            plan.extrapolated[x] = true;
219            continue;
220        };
221        let cell = x_in.ceil() - 1.0;
222        let taps = &mut plan.weights[x * window..][..window];
223        weights(x_in - cell, taps);
224        let first = cell as isize + 1 - (window / 2) as isize;
225        for (k, tap) in taps.iter_mut().enumerate() {
226            let raw = first + k as isize;
227            if exclude_outside && (raw < 0 || raw >= len_in as isize) {
228                *tap = 0.0;
229            }
230            plan.indices[x * window + k] = raw.clamp(0, len_in as isize - 1) as usize;
231        }
232        if exclude_outside {
233            let sum: f32 = taps.iter().sum();
234            if sum != 0.0 {
235                taps.iter_mut().for_each(|w| *w /= sum);
236            }
237        }
238    }
239    plan
240}
241
242/// Whether `plan` is exactly pixel replication — every output reading the
243/// single input `x / scale` — which is the pattern
244/// [`lower_nearest_integer_upsample`] produces. Only some coordinate transform
245/// and tie-break pairs round that way, so the plan itself is the arbiter.
246pub fn is_pixel_replication(plan: &AxisPlan, scale: usize) -> bool {
247    !plan.extrapolated.contains(&true)
248        && plan
249            .indices
250            .chunks_exact(plan.window)
251            .zip(plan.weights.chunks_exact(plan.window))
252            .enumerate()
253            .all(|(x, (indices, weights))| {
254                let mut taps = indices.iter().zip(weights).filter(|(_, w)| **w != 0.0);
255                taps.next().is_some_and(|(i, w)| *w == 1.0 && *i == x / scale)
256                    && taps.next().is_none()
257            })
258}
259
260/// Resamples `axis` of a row-major `input` of shape `shape` into `output`,
261/// which must be sized for the same shape with `axis` set to the plan length.
262pub fn resample_axis(
263    input: &[f32],
264    shape: &[usize],
265    axis: usize,
266    plan: &AxisPlan,
267    extrapolation_value: f32,
268    output: &mut [f32],
269) {
270    let len_in = shape[axis];
271    let len_out = plan.extrapolated.len();
272    let inner: usize = shape[axis + 1..].iter().product();
273    let window = plan.window;
274    if len_in * inner == 0 || len_out * inner == 0 {
275        return;
276    }
277    for (src, dst) in
278        input.chunks_exact(len_in * inner).zip(output.chunks_exact_mut(len_out * inner))
279    {
280        for (x, dst) in dst.chunks_exact_mut(inner).enumerate() {
281            if plan.extrapolated[x] {
282                dst.fill(extrapolation_value);
283                continue;
284            }
285            dst.fill(0.0);
286            let indices = &plan.indices[x * window..][..window];
287            let weights = &plan.weights[x * window..][..window];
288            for (&i, &w) in indices.iter().zip(weights) {
289                if w == 0.0 {
290                    continue;
291                }
292                for (d, s) in dst.iter_mut().zip(&src[i * inner..][..inner]) {
293                    *d += w * s;
294                }
295            }
296        }
297    }
298}
299
300/// Resamples `input` along the axes given by `scales`/`sizes`, the clean subset
301/// of ONNX Resize: `interpolator` × `coord_transformer` × `nearest`, fixed
302/// `cubic_coeff_a = -0.75`, no ROI and no `exclude_outside`. The ONNX op carries
303/// the remaining edge cases and decltters into this op when it fits the subset.
304#[derive(Clone, Debug, Hash, PartialEq, Eq)]
305pub struct Resize {
306    pub coord_transformer: CoordTransformer,
307    pub interpolator: Interpolator,
308    pub nearest: Nearest,
309    pub optional_scales_input: Option<usize>,
310    pub optional_sizes_input: Option<usize>,
311}
312
313impl Resize {
314    pub fn compute_output_shape<D: DimLike>(
315        &self,
316        input_shape: &[D],
317        input_scale: Option<&Tensor>,
318        input_sizes: Option<&Tensor>,
319    ) -> TractResult<TVec<D>> {
320        if let Some(scale) = input_scale
321            && scale.len() == input_shape.len()
322        {
323            let mut shape = tvec!();
324            for (i, s) in input_shape
325                .iter()
326                .zip(scale.cast_to::<f32>()?.try_as_plain()?.as_slice::<f32>()?.iter())
327            {
328                if s.round() == *s {
329                    shape.push(i.clone() * (*s as usize));
330                } else if let Ok(i) = i.to_usize() {
331                    shape.push(((i as f32 * s) as usize).into());
332                } else {
333                    bail!(
334                        "Can not compute output shape. inputs are {input_shape:?} and scale {scale:?}"
335                    )
336                }
337            }
338            return Ok(shape);
339        }
340        if let Some(sizes) = input_sizes
341            && sizes.len() == input_shape.len()
342        {
343            return sizes
344                .cast_to::<TDim>()?
345                .try_as_plain()?
346                .as_slice::<TDim>()?
347                .iter()
348                .map(|i| i.try_into())
349                .collect();
350        }
351        bail!(
352            "Neither sizes nor scales makes sense: input_shape: {:?}, scale: {:?}, sizes: {:?}",
353            input_shape,
354            input_scale,
355            input_sizes,
356        );
357    }
358
359    /// The resampling plan for one axis under this op's interpolator.
360    pub fn plan_axis(&self, scale: f32, len_in: usize, len_out: usize) -> AxisPlan {
361        let window = window_size(&self.interpolator, false, scale);
362        let coord = |x| Some(self.coord_transformer.transform(x, scale, len_in, len_out));
363        match self.interpolator {
364            Interpolator::Linear => plan_axis(len_in, len_out, window, false, coord, |r, w| {
365                linear_weights(r, scale, false, w)
366            }),
367            Interpolator::Cubic => plan_axis(len_in, len_out, window, false, coord, |r, w| {
368                cubic_weights(r, scale, -0.75, false, w)
369            }),
370            Interpolator::Nearest => plan_axis(len_in, len_out, window, false, coord, |r, w| {
371                let right = r == 1.0 || self.nearest.prefers_right(r);
372                w[0] = !right as u8 as f32;
373                w[1] = right as u8 as f32;
374            }),
375        }
376    }
377}
378
379impl Op for Resize {
380    fn name(&self) -> StaticName {
381        "Resize".into()
382    }
383
384    op_as_typed_op!();
385}
386
387impl EvalOp for Resize {
388    fn is_stateless(&self) -> bool {
389        true
390    }
391
392    fn eval(&self, mut inputs: TVec<TValue>) -> TractResult<TVec<TValue>> {
393        let input_dt = inputs[0].datum_type();
394        let scales = self.optional_scales_input.and_then(|ix| inputs.get(ix));
395        let sizes = self.optional_sizes_input.and_then(|ix| inputs.get(ix));
396        let output_shape = self.compute_output_shape(
397            inputs[0].shape(),
398            scales.map(|t| &**t),
399            sizes.map(|t| &**t),
400        )?;
401        let scales: TVec<f32> = if let Some(scales) = scales.filter(|s| s.len() == inputs[0].rank())
402        {
403            scales.try_as_plain()?.as_slice::<f32>()?.into()
404        } else {
405            output_shape.iter().zip(inputs[0].shape()).map(|(o, i)| *o as f32 / *i as f32).collect()
406        };
407        let input = inputs.remove(0).into_tensor();
408        let input = input.cast_to::<f32>()?;
409        let mut shape: TVec<usize> = input.shape().into();
410        let mut data: Vec<f32> = input.try_as_plain()?.as_slice::<f32>()?.to_vec();
411        for (axis, scale) in scales.into_iter().enumerate() {
412            let (len_in, len_out) = (shape[axis], output_shape[axis]);
413            if len_in == len_out && scale == 1.0 {
414                continue;
415            }
416            let plan = self.plan_axis(scale, len_in, len_out);
417            let mut resampled = vec![0f32; data.len() / len_in * len_out];
418            resample_axis(&data, &shape, axis, &plan, 0.0, &mut resampled);
419            data = resampled;
420            shape[axis] = len_out;
421        }
422        let out = tract_ndarray::ArrayD::from_shape_vec(&*shape, data)?.into_tensor();
423        let out =
424            if out.datum_type() == input_dt { out } else { out.cast_to_dt(input_dt)?.into_owned() };
425        Ok(tvec!(out.into_tvalue()))
426    }
427}
428
429impl TypedOp for Resize {
430    as_op!();
431
432    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
433        let scales = self.optional_scales_input.and_then(|ix| inputs.get(ix));
434        let sizes = self.optional_sizes_input.and_then(|ix| inputs.get(ix));
435        let output_shape = self.compute_output_shape(
436            &inputs[0].shape,
437            scales.and_then(|f| f.konst.as_deref()),
438            sizes.and_then(|f| f.konst.as_deref()),
439        )?;
440        Ok(tvec!(inputs[0].datum_type.fact(&output_shape)))
441    }
442
443    fn declutter(
444        &self,
445        model: &TypedModel,
446        node: &TypedNode,
447    ) -> TractResult<Option<TypedModelPatch>> {
448        rule_if!(matches!(self.interpolator, Interpolator::Nearest));
449        rule_if_some!(scales_input = self.optional_scales_input);
450        let scales_fact = model.outlet_fact(node.inputs[scales_input])?;
451        rule_if_some!(scales_tensor = &scales_fact.konst);
452        let scales: Vec<f32> =
453            scales_tensor.cast_to::<f32>()?.try_as_plain()?.as_slice::<f32>()?.to_vec();
454        let int_scales: Vec<usize> = scales.iter().map(|&s| s.round() as usize).collect();
455        rule_if!(
456            scales.iter().zip(&int_scales).all(|(&s, &i)| (s - i as f32).abs() <= 1e-5 && i != 0)
457        );
458        rule_if!(int_scales.iter().any(|&s| s != 1));
459        let input_shape = &model.outlet_fact(node.inputs[0])?.shape;
460        for (axis, &scale) in int_scales.iter().enumerate().filter(|&(_, &s)| s > 1) {
461            let Some(len_in) = probe_length(&self.coord_transformer, &input_shape[axis]) else {
462                return Ok(None);
463            };
464            rule_if!(is_pixel_replication(
465                &self.plan_axis(scale as f32, len_in, len_in * scale),
466                scale
467            ));
468        }
469
470        lower_nearest_integer_upsample(model, node, &int_scales)
471    }
472}
473
474/// An axis length to build a probe plan on. `HalfPixel`, `Asymmetric` and
475/// `TfHalfPixelForNn` map coordinates without consulting the axis lengths, so a
476/// symbolic axis can still be probed on a stand-in; the others cannot.
477pub fn probe_length(coord_transformer: &CoordTransformer, len: &TDim) -> Option<usize> {
478    len.to_usize().ok().or(match coord_transformer {
479        CoordTransformer::HalfPixel
480        | CoordTransformer::Asymmetric
481        | CoordTransformer::TfHalfPixelForNn => Some(4),
482        _ => None,
483    })
484}
485
486/// Lowers a nearest-neighbour integer upsample to Reshape → Tile → Reshape: each
487/// upsampled axis is split into a size-1 axis, tiled by its scale, then merged
488/// back. Shared by the core and ONNX Resize declutters.
489pub fn lower_nearest_integer_upsample(
490    model: &TypedModel,
491    node: &TypedNode,
492    int_scales: &[usize],
493) -> TractResult<Option<TypedModelPatch>> {
494    let input_fact = model.outlet_fact(node.inputs[0])?;
495    let input_shape = &input_fact.shape;
496
497    let mut patch = TypedModelPatch::default();
498    let mut wire = patch.tap_model(model, node.inputs[0])?;
499
500    let mut from_dims: TVec<TDim> = tvec![];
501    let mut to_dims: TVec<TDim> = tvec![];
502    let mut tile_multipliers: TVec<TDim> = tvec![];
503    let mut first_upsampled = None;
504
505    for (i, &scale) in int_scales.iter().enumerate() {
506        from_dims.push(input_shape[i].clone());
507        to_dims.push(input_shape[i].clone());
508        tile_multipliers.push(1.into());
509        if scale > 1 {
510            if first_upsampled.is_none() {
511                first_upsampled = Some(i);
512            }
513            to_dims.push(1.into());
514            tile_multipliers.push(scale.into());
515        }
516    }
517
518    if to_dims.len() > from_dims.len() {
519        let first = first_upsampled.unwrap();
520        wire = patch.wire_node(
521            format!("{}.reshape_pre", node.name),
522            AxisOp::Reshape(first, from_dims[first..].into(), to_dims[first..].into()),
523            &[wire],
524        )?[0];
525    }
526
527    wire = patch.wire_node(
528        format!("{}.tile", node.name),
529        Tile { multipliers: tile_multipliers },
530        &[wire],
531    )?[0];
532
533    let tiled_shape: TVec<TDim> = to_dims
534        .iter()
535        .zip(int_scales.iter().flat_map(|&s| if s > 1 { vec![1usize, s] } else { vec![1] }))
536        .map(|(d, s)| d.clone() * s)
537        .collect();
538    let mut final_dims: TVec<TDim> = tvec![];
539    let mut idx = 0;
540    for &scale in int_scales {
541        if scale > 1 {
542            final_dims.push(tiled_shape[idx].clone() * tiled_shape[idx + 1].clone());
543            idx += 2;
544        } else {
545            final_dims.push(tiled_shape[idx].clone());
546            idx += 1;
547        }
548    }
549
550    if tiled_shape.len() > final_dims.len() {
551        let first = first_upsampled.unwrap();
552        wire = patch.wire_node(
553            format!("{}.reshape_post", node.name),
554            AxisOp::Reshape(first, tiled_shape[first..].into(), final_dims[first..].into()),
555            &[wire],
556        )?[0];
557    }
558
559    patch.shunt_outside(model, node.id.into(), wire)?;
560    Ok(Some(patch))
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    #[test]
568    fn cubic_kernel_properties() {
569        let a = -0.75f32;
570        assert!((cubic_kernel(0.0, a) - 1.0).abs() < 1e-6);
571        assert!(cubic_kernel(2.0, a).abs() < 1e-6);
572        assert!(cubic_kernel(3.0, a).abs() < 1e-6);
573
574        for t_int in 0..=100 {
575            let t = t_int as f32 / 100.0;
576            let sum = cubic_kernel(t + 1.0, a)
577                + cubic_kernel(t, a)
578                + cubic_kernel(1.0 - t, a)
579                + cubic_kernel(2.0 - t, a);
580            assert!((sum - 1.0).abs() < 1e-5, "kernel weights must sum to 1.0, got {sum} at t={t}");
581        }
582    }
583
584    fn cubic_resize(input: Tensor, scales: &[f32]) -> Tensor {
585        let scales = tract_ndarray::Array1::from(scales.to_vec()).into_tensor();
586        let op = Resize {
587            coord_transformer: CoordTransformer::HalfPixel,
588            interpolator: Interpolator::Cubic,
589            nearest: Nearest::Floor,
590            optional_scales_input: Some(1),
591            optional_sizes_input: None,
592        };
593        op.eval(tvec!(input.into_tvalue(), scales.into_tvalue())).unwrap().remove(0).into_tensor()
594    }
595
596    #[test]
597    fn cubic_resize_1d_upsample() {
598        let out = cubic_resize(tract_ndarray::arr1(&[0.0f32, 1.0, 2.0, 3.0]).into_tensor(), &[2.0]);
599        let plain = out.try_as_plain().unwrap();
600        let output = plain.as_slice::<f32>().unwrap();
601        assert_eq!(output.len(), 8);
602        assert!((output[0] - (-0.10546875)).abs() < 1e-4, "got {}", output[0]);
603    }
604
605    #[test]
606    fn cubic_resize_2d_upsample() {
607        let out = cubic_resize(
608            tract_ndarray::arr2(&[[1.0f32, 2.0], [3.0, 4.0]]).into_tensor(),
609            &[2.0, 2.0],
610        );
611        assert_eq!(out.shape(), &[4, 4]);
612    }
613
614    fn replicates(coord_transformer: CoordTransformer, nearest: Nearest, scale: usize) -> bool {
615        let op = Resize {
616            coord_transformer,
617            interpolator: Interpolator::Nearest,
618            nearest,
619            optional_scales_input: Some(1),
620            optional_sizes_input: None,
621        };
622        is_pixel_replication(&op.plan_axis(scale as f32, 4, 4 * scale), scale)
623    }
624
625    #[test]
626    fn only_some_nearest_modes_replicate_pixels() {
627        assert!(replicates(CoordTransformer::Asymmetric, Nearest::Floor, 2));
628        assert!(replicates(CoordTransformer::HalfPixel, Nearest::RoundPreferCeil, 2));
629        assert!(replicates(CoordTransformer::HalfPixel, Nearest::RoundPreferCeil, 3));
630        assert!(!replicates(CoordTransformer::HalfPixel, Nearest::Floor, 2));
631        assert!(!replicates(CoordTransformer::Asymmetric, Nearest::RoundPreferCeil, 2));
632    }
633}