use crate::border::Mirror;
use crate::error::Error;
use crate::image::{
Dyadic, Image, ImageView, ImageViewMut, LevelChain, OriginOffset, PlacedImage, PlacedPyramid,
Pyramid, PyramidLevel, RasterImage, ScaledImage, ScaledPyramid, SeparableKernel,
};
use crate::pixel::{FromLinear, LinearPixel, LinearSpace, ZeroablePixel};
use crate::transform::convolve_separable::convolve_separable;
use crate::{PixelDistance, Sigma, Size};
const PYR_UP_WEIGHTS: [f32; 5] = [0.125, 0.5, 0.75, 0.5, 0.125];
const PYR_UP_WEIGHTS_UNDOUBLED: [f32; 5] = [0.0625, 0.25, 0.375, 0.25, 0.0625];
#[must_use]
pub fn pyr_down<I, P, Acc>(image: &I) -> Image<P>
where
I: RasterImage<Pixel = P>,
P: LinearPixel<f32, Accumulator = Acc> + LinearSpace + ZeroablePixel + FromLinear<Acc>,
Acc: Copy
+ Default
+ ZeroablePixel
+ LinearPixel<f32, Accumulator = Acc>
+ std::ops::Add<Output = Acc>,
{
let blurred: Image<P> = convolve_separable(image, &SeparableKernel::gaussian_5(), &Mirror);
let out_width = image.width().div_ceil(2);
let out_height = image.height().div_ceil(2);
Image::generate(out_width, out_height, |x, y| blurred.pixel_at(2 * x, 2 * y))
}
pub fn pyr_up<I, P, Acc>(image: &I, target: Size) -> Result<Image<P>, Error>
where
I: RasterImage<Pixel = P>,
P: LinearPixel<f32, Accumulator = Acc> + LinearSpace + ZeroablePixel + FromLinear<Acc>,
Acc: Copy
+ Default
+ ZeroablePixel
+ LinearPixel<f32, Accumulator = Acc>
+ std::ops::Add<Output = Acc>,
{
fn doubles(target: usize, dim: usize) -> bool {
match dim.checked_mul(2) {
Some(two) => target == two || target.checked_add(1) == Some(two),
None => false,
}
}
let (w, h) = (image.width(), image.height());
if !doubles(target.width, w) || !doubles(target.height, h) {
return Err(Error::InvalidPyrUpTarget {
source: image.size(),
target,
});
}
let mut upsampled = Image::<P>::zero(target.width, target.height);
for y in 0..h {
let row = image.row(y);
for (x, &pixel) in row.iter().enumerate() {
*upsampled.pixel_at_mut(2 * x, 2 * y) = pixel;
}
}
let h_weights = if target.width == w {
PYR_UP_WEIGHTS_UNDOUBLED
} else {
PYR_UP_WEIGHTS
};
let v_weights = if target.height == h {
PYR_UP_WEIGHTS_UNDOUBLED
} else {
PYR_UP_WEIGHTS
};
let kernel = SeparableKernel::new(h_weights, v_weights);
Ok(convolve_separable(&upsampled, &kernel, &Mirror))
}
impl<C: Pyramid> Dyadic<C> {
pub fn expand<P, Acc>(&self, child: usize) -> Option<Image<P>>
where
C::Level: PyramidLevel<Pixel = P>,
P: LinearPixel<f32, Accumulator = Acc> + LinearSpace + ZeroablePixel + FromLinear<Acc>,
Acc: Copy
+ Default
+ ZeroablePixel
+ LinearPixel<f32, Accumulator = Acc>
+ std::ops::Add<Output = Acc>,
{
let source = self.get(child)?;
let parent = self.get(child.checked_sub(1)?)?;
Some(
pyr_up(source.as_image(), parent.as_image().size())
.expect("Dyadic guarantees the parent is a valid pyr_up target"),
)
}
}
pub trait PyramidMethod<P: Copy> {
type Output: Pyramid;
fn build<I>(&self, image: &I, max_depth: usize) -> Self::Output
where
I: RasterImage<Pixel = P>;
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Gaussian;
impl Gaussian {
#[must_use]
pub fn assuming_input_sigma(self, input_sigma: Sigma) -> ScaledGaussian {
ScaledGaussian { input_sigma }
}
}
impl<P, Acc> PyramidMethod<P> for Gaussian
where
P: LinearPixel<f32, Accumulator = Acc> + LinearSpace + ZeroablePixel + FromLinear<Acc>,
Acc: Copy
+ Default
+ ZeroablePixel
+ LinearPixel<f32, Accumulator = Acc>
+ std::ops::Add<Output = Acc>,
{
type Output = PlacedPyramid<P>;
fn build<I>(&self, image: &I, max_depth: usize) -> Self::Output
where
I: RasterImage<Pixel = P>,
{
let levels = gaussian_levels(image, max_depth)
.into_iter()
.enumerate()
.map(|(index, image)| {
PlacedImage::new(image, level_pixel_distance(index), OriginOffset::ZERO)
})
.collect();
finish(levels)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ScaledGaussian {
input_sigma: Sigma,
}
impl ScaledGaussian {
#[must_use]
pub fn input_sigma(&self) -> Sigma {
self.input_sigma
}
}
impl<P, Acc> PyramidMethod<P> for ScaledGaussian
where
P: LinearPixel<f32, Accumulator = Acc> + LinearSpace + ZeroablePixel + FromLinear<Acc>,
Acc: Copy
+ Default
+ ZeroablePixel
+ LinearPixel<f32, Accumulator = Acc>
+ std::ops::Add<Output = Acc>,
{
type Output = ScaledPyramid<P>;
fn build<I>(&self, image: &I, max_depth: usize) -> Self::Output
where
I: RasterImage<Pixel = P>,
{
let mut variance = f64::from(self.input_sigma.get()).powi(2);
let mut added = 1.0_f64;
let levels = gaussian_levels(image, max_depth)
.into_iter()
.enumerate()
.map(|(index, image)| {
if index > 0 {
variance += added;
added *= 4.0;
}
let sigma = Sigma::try_new(variance.sqrt() as f32)
.expect("a positive input sigma stays positive and finite along the ladder");
ScaledImage::new(
image,
level_pixel_distance(index),
OriginOffset::ZERO,
sigma,
)
})
.collect();
finish(levels)
}
}
fn level_pixel_distance(index: usize) -> PixelDistance {
let spacing = (1_u64 << index.min(63)) as f64;
PixelDistance::try_new(spacing).expect("a power of two is finite and strictly positive")
}
fn finish<L: PyramidLevel>(levels: Vec<L>) -> Dyadic<LevelChain<L>> {
let chain = LevelChain::try_from_levels(levels)
.expect("Gaussian construction produces non-empty, non-growing levels");
Dyadic::new_unchecked(chain)
}
fn gaussian_levels<I, P, Acc>(image: &I, max_depth: usize) -> Vec<Image<P>>
where
I: RasterImage<Pixel = P>,
P: LinearPixel<f32, Accumulator = Acc> + LinearSpace + ZeroablePixel + FromLinear<Acc>,
Acc: Copy
+ Default
+ ZeroablePixel
+ LinearPixel<f32, Accumulator = Acc>
+ std::ops::Add<Output = Acc>,
{
let resolved = max_depth.max(1);
let base = {
let mut data = Vec::with_capacity(image.width() * image.height());
for y in 0..image.height() {
data.extend_from_slice(image.row(y));
}
Image::from_vec(image.width(), image.height(), data)
.expect("rows fill width * height exactly")
};
let mut levels = vec![base];
while levels.len() < resolved {
let prev = levels.last().expect("levels start non-empty");
let Size { width, height } = prev.size();
if width <= 1 && height <= 1 || width == 0 || height == 0 {
break;
}
let next = pyr_down(prev);
levels.push(next);
}
levels
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CoordinateF64;
use crate::image::{Decimated, PyramidLevel, ScaleLevel};
use crate::pixel::{Mono8, MonoF32};
use crate::{pixel_distance, sigma};
#[test]
fn pyr_down_even_dimensions_halve() {
let src = Image::fill(8, 6, MonoF32::new(0.0));
let out: Image<MonoF32> = pyr_down(&src);
assert_eq!(out.size(), Size::new(4, 3));
}
#[test]
fn pyr_down_odd_dimensions_use_ceiling_division() {
let src = Image::fill(7, 5, MonoF32::new(0.0));
let out: Image<MonoF32> = pyr_down(&src);
assert_eq!(out.size(), Size::new(4, 3));
}
#[test]
fn pyr_down_one_pixel_image_stays_one_pixel() {
let src = Image::fill(1, 1, MonoF32::new(0.3));
let out: Image<MonoF32> = pyr_down(&src);
assert_eq!(out.size(), Size::new(1, 1));
assert!((out.pixel_at(0, 0).0 - 0.3).abs() < 1e-6);
}
#[test]
fn pyr_down_single_row_and_column() {
let row = Image::fill(9, 1, MonoF32::new(0.5));
let out: Image<MonoF32> = pyr_down(&row);
assert_eq!(out.size(), Size::new(5, 1));
let col = Image::fill(1, 8, MonoF32::new(0.5));
let out: Image<MonoF32> = pyr_down(&col);
assert_eq!(out.size(), Size::new(1, 4));
}
#[test]
fn pyr_down_flat_image_preserves_brightness() {
let src = Image::fill(10, 10, MonoF32::new(0.7));
let out: Image<MonoF32> = pyr_down(&src);
for y in 0..out.height() {
for x in 0..out.width() {
assert!(
(out.pixel_at(x, y).0 - 0.7).abs() < 1e-6,
"flat value drifted at ({x}, {y}): {}",
out.pixel_at(x, y).0
);
}
}
}
#[test]
fn pyr_down_flat_mono8_preserves_brightness() {
let src = Image::fill(12, 8, Mono8::new(100));
let out: Image<Mono8> = pyr_down(&src);
for y in 0..out.height() {
for x in 0..out.width() {
assert_eq!(out.pixel_at(x, y), Mono8::new(100));
}
}
}
#[test]
fn pyr_down_keeps_even_samples() {
let src = Image::generate(16, 16, |x, _| MonoF32::new(x as f32));
let out: Image<MonoF32> = pyr_down(&src);
for y in 2..out.height() - 2 {
for x in 2..out.width() - 2 {
assert!(
(out.pixel_at(x, y).0 - 2.0 * x as f32).abs() < 1e-4,
"expected even sample 2·{x} at ({x}, {y}), got {}",
out.pixel_at(x, y).0
);
}
}
}
#[test]
fn pyr_down_impulse_center_weight() {
let src = Image::generate(9, 9, |x, y| {
if x == 4 && y == 4 {
MonoF32::new(1.0)
} else {
MonoF32::new(0.0)
}
});
let out: Image<MonoF32> = pyr_down(&src);
assert!((out.pixel_at(2, 2).0 - 0.140625).abs() < 1e-6);
}
#[test]
fn pyr_up_accepts_both_valid_widths() {
let src = Image::fill(4, 4, MonoF32::new(0.5));
let a: Image<MonoF32> = pyr_up(&src, Size::new(8, 8)).unwrap();
assert_eq!(a.size(), Size::new(8, 8));
let b: Image<MonoF32> = pyr_up(&src, Size::new(7, 7)).unwrap();
assert_eq!(b.size(), Size::new(7, 7));
}
#[test]
fn pyr_up_handles_one_pixel_sources() {
let dot = Image::fill(1, 1, MonoF32::new(0.5));
let up: Image<MonoF32> = pyr_up(&dot, Size::new(2, 2)).unwrap();
assert_eq!(up.size(), Size::new(2, 2));
let same: Image<MonoF32> = pyr_up(&dot, Size::new(1, 1)).unwrap();
assert_eq!(same.size(), Size::new(1, 1));
let bar = Image::fill(1, 4, MonoF32::new(0.5));
let up: Image<MonoF32> = pyr_up(&bar, Size::new(2, 8)).unwrap();
assert_eq!(up.size(), Size::new(2, 8));
let odd: Image<MonoF32> = pyr_up(&bar, Size::new(1, 7)).unwrap();
assert_eq!(odd.size(), Size::new(1, 7));
for y in 0..odd.height() {
let v = odd.pixel_at(0, y).0;
assert!((0.0..=0.5 + 1e-6).contains(&v), "({y}) = {v}");
}
}
#[test]
fn pyr_up_rejects_extreme_sizes_without_overflowing() {
let wide: Image<MonoF32> = Image::zero(usize::MAX, 0);
let result: Result<Image<MonoF32>, Error> = pyr_up(&wide, Size::new(4, 4));
assert!(result.is_err());
let src = Image::fill(4, 4, MonoF32::new(0.5));
let result: Result<Image<MonoF32>, Error> = pyr_up(&src, Size::new(usize::MAX, usize::MAX));
assert!(result.is_err());
}
#[test]
fn pyr_up_rejects_invalid_targets() {
let src = Image::fill(4, 4, MonoF32::new(0.5));
for target in [Size::new(9, 8), Size::new(6, 8), Size::new(8, 10)] {
let result: Result<Image<MonoF32>, Error> = pyr_up(&src, target);
assert_eq!(
result.unwrap_err(),
Error::InvalidPyrUpTarget {
source: Size::new(4, 4),
target,
},
"target {target:?} must be rejected"
);
}
}
#[test]
fn pyr_up_round_trips_odd_sizes() {
let src = Image::fill(9, 7, MonoF32::new(0.25));
let half: Image<MonoF32> = pyr_down(&src);
assert_eq!(half.size(), Size::new(5, 4));
let restored: Image<MonoF32> = pyr_up(&half, src.size()).unwrap();
assert_eq!(restored.size(), src.size());
}
#[test]
fn pyr_up_flat_image_preserves_brightness() {
let src = Image::fill(5, 4, MonoF32::new(0.6));
for target in [Size::new(10, 8), Size::new(9, 7)] {
let out: Image<MonoF32> = pyr_up(&src, target).unwrap();
for y in 0..out.height() {
for x in 0..out.width() {
assert!(
(out.pixel_at(x, y).0 - 0.6).abs() < 1e-6,
"flat value drifted at ({x}, {y}) for target {target:?}: {}",
out.pixel_at(x, y).0
);
}
}
}
}
#[test]
fn pyr_up_impulse_spreads_interpolation_weights() {
let src = Image::generate(3, 3, |x, y| {
if x == 1 && y == 1 {
MonoF32::new(1.0)
} else {
MonoF32::new(0.0)
}
});
let out: Image<MonoF32> = pyr_up(&src, Size::new(6, 6)).unwrap();
assert!((out.pixel_at(2, 2).0 - 0.5625).abs() < 1e-6);
assert!((out.pixel_at(3, 2).0 - 0.375).abs() < 1e-6);
assert!((out.pixel_at(3, 3).0 - 0.25).abs() < 1e-6);
}
#[test]
fn pyr_up_mono8_flat() {
let src = Image::fill(6, 6, Mono8::new(80));
let out: Image<Mono8> = pyr_up(&src, Size::new(12, 12)).unwrap();
for y in 0..out.height() {
for x in 0..out.width() {
assert_eq!(out.pixel_at(x, y), Mono8::new(80));
}
}
}
#[test]
fn gaussian_build_level_zero_is_the_input() {
let src = Image::generate(8, 8, |x, y| MonoF32::new((x + y) as f32));
let pyramid = Gaussian.build(&src, 3);
let level0 = pyramid.finest();
for y in 0..src.height() {
for x in 0..src.width() {
assert_eq!(level0.pixel_at(x, y), src.pixel_at(x, y));
}
}
}
#[test]
fn gaussian_build_halves_each_level() {
let src = Image::fill(20, 12, MonoF32::new(0.5));
let pyramid = Gaussian.build(&src, 3);
let sizes: Vec<Size> = pyramid.iter().map(|l| l.size()).collect();
assert_eq!(
sizes,
[Size::new(20, 12), Size::new(10, 6), Size::new(5, 3)]
);
}
#[test]
fn gaussian_build_clamps_depth_on_small_images() {
let src = Image::fill(4, 4, MonoF32::new(0.5));
let pyramid = Gaussian.build(&src, 100);
assert_eq!(pyramid.depth(), 3);
assert_eq!(pyramid.coarsest().size(), Size::new(1, 1));
}
#[test]
fn gaussian_build_max_depth_zero_yields_base_level() {
let src = Image::fill(8, 8, MonoF32::new(0.5));
let pyramid = Gaussian.build(&src, 0);
assert_eq!(pyramid.depth(), 1);
assert_eq!(pyramid.finest().size(), Size::new(8, 8));
}
#[test]
fn gaussian_build_respects_requested_depth() {
let src = Image::fill(64, 64, MonoF32::new(0.5));
let pyramid = Gaussian.build(&src, 3);
assert_eq!(pyramid.depth(), 3);
}
#[test]
fn gaussian_build_flat_stays_flat_at_every_level() {
let src = Image::fill(16, 16, MonoF32::new(0.4));
let pyramid = Gaussian.build(&src, 5);
for (i, level) in pyramid.iter().enumerate() {
for y in 0..level.height() {
for x in 0..level.width() {
assert!(
(level.pixel_at(x, y).0 - 0.4).abs() < 1e-5,
"level {i} drifted at ({x}, {y})"
);
}
}
}
}
#[test]
fn gaussian_build_mono8() {
let src = Image::fill(16, 12, Mono8::new(200));
let pyramid = Gaussian.build(&src, 3);
assert_eq!(pyramid.depth(), 3);
assert_eq!(pyramid.coarsest().size(), Size::new(4, 3));
assert_eq!(pyramid.coarsest().pixel_at(0, 0), Mono8::new(200));
}
#[test]
fn expand_recovers_every_parent_size() {
let src = Image::fill(21, 13, MonoF32::new(0.5));
let pyramid = Gaussian.build(&src, 4);
for child in 1..pyramid.depth() {
let raised: Image<MonoF32> = pyramid.expand(child).expect("child has a parent");
assert_eq!(raised.size(), pyramid.level(child - 1).size());
}
}
#[test]
fn expand_matches_pyr_up_with_the_parent_size() {
let src = Image::fill(20, 12, MonoF32::new(0.25));
let pyramid = Gaussian.build(&src, 3);
let by_hand: Image<MonoF32> =
pyr_up(pyramid.level(2), pyramid.level(1).size()).expect("valid target");
let raised: Image<MonoF32> = pyramid.expand(2).expect("level 2 has a parent");
assert_eq!(raised.size(), by_hand.size());
for y in 0..raised.height() {
for x in 0..raised.width() {
assert_eq!(raised.pixel_at(x, y), by_hand.pixel_at(x, y));
}
}
}
#[test]
fn expand_has_no_parent_for_the_finest_level() {
let src = Image::fill(16, 16, MonoF32::new(0.5));
let pyramid = Gaussian.build(&src, 3);
assert!(pyramid.expand::<MonoF32, _>(0).is_none());
}
#[test]
fn expand_has_no_level_past_the_depth() {
let src = Image::fill(16, 16, MonoF32::new(0.5));
let pyramid = Gaussian.build(&src, 3);
assert!(pyramid.expand::<MonoF32, _>(3).is_none());
assert!(pyramid.expand::<MonoF32, _>(99).is_none());
}
#[test]
fn expand_works_on_an_imported_chain() {
let chain = LevelChain::try_from_levels(vec![
Image::fill(9, 7, MonoF32::new(0.5)),
Image::fill(5, 4, MonoF32::new(0.5)),
])
.unwrap();
let pyramid = Dyadic::try_new(chain).unwrap();
let raised: Image<MonoF32> = pyramid.expand(1).expect("level 1 has a parent");
assert_eq!(raised.size(), Size::new(9, 7));
}
#[test]
fn decimated_lift_recovers_base_position() {
let (cx, cy) = (12.0f32, 8.0f32);
let src = Image::generate(33, 25, |x, y| {
let dx = x as f32 - cx;
let dy = y as f32 - cy;
MonoF32::new((-(dx * dx + dy * dy) / 18.0).exp())
});
let pyramid: PlacedPyramid<MonoF32> = Gaussian.build(&src, 3);
let scaled = pyramid.level(2);
assert_eq!(scaled.pixel_distance(), pixel_distance!(4.0));
let img = scaled.as_image();
let mut best = (0usize, 0usize, f32::MIN);
for y in 0..img.height() {
for x in 0..img.width() {
let v = img.pixel_at(x, y).0;
if v > best.2 {
best = (x, y, v);
}
}
}
let lifted = scaled.to_base(CoordinateF64::new(best.0 as f64, best.1 as f64));
assert_eq!(lifted, CoordinateF64::new(f64::from(cx), f64::from(cy)));
}
#[test]
fn gaussian_levels_carry_their_sampling_grid() {
let src = Image::fill(32, 20, MonoF32::new(0.5));
let pyramid: PlacedPyramid<MonoF32> = Gaussian.build(&src, 4);
let distances: Vec<f64> = pyramid.iter().map(|l| l.pixel_distance().get()).collect();
assert_eq!(distances, [1.0, 2.0, 4.0, 8.0]);
for level in pyramid.iter() {
assert_eq!(level.origin_offset(), CoordinateF64::new(0.0, 0.0));
}
}
#[test]
fn a_coarse_position_lifts_without_the_caller_naming_the_grid() {
let src = Image::fill(32, 32, MonoF32::new(0.5));
let pyramid: PlacedPyramid<MonoF32> = Gaussian.build(&src, 3);
assert_eq!(
pyramid.level(2).to_base(CoordinateF64::new(3.0, 1.0)),
CoordinateF64::new(12.0, 4.0)
);
}
#[test]
fn the_sigma_ladder_matches_the_published_numbers_for_lowes_assumption() {
let src = Image::fill(64, 64, MonoF32::new(0.5));
let pyramid: ScaledPyramid<MonoF32> =
Gaussian.assuming_input_sigma(sigma!(0.5)).build(&src, 4);
let sigmas: Vec<f32> = pyramid.iter().map(|l| l.sigma().get()).collect();
for (actual, expected) in sigmas.iter().zip([0.5, 1.118, 2.291, 4.610]) {
assert!(
(actual - expected).abs() < 1e-3,
"sigma ladder: got {sigmas:?}"
);
}
}
#[test]
fn the_sigma_ladder_matches_the_published_numbers_for_a_sharp_input() {
let src = Image::fill(64, 64, MonoF32::new(0.5));
let pyramid: ScaledPyramid<MonoF32> =
Gaussian.assuming_input_sigma(sigma!(0.001)).build(&src, 4);
let sigmas: Vec<f32> = pyramid.iter().skip(1).map(|l| l.sigma().get()).collect();
for (actual, expected) in sigmas.iter().zip([1.000, 2.236, 4.583]) {
assert!(
(actual - expected).abs() < 1e-3,
"sigma ladder: got {sigmas:?}"
);
}
}
#[test]
fn the_naive_extrapolation_is_the_error_the_ladder_prevents() {
let src = Image::fill(64, 64, MonoF32::new(0.5));
let pyramid: ScaledPyramid<MonoF32> =
Gaussian.assuming_input_sigma(sigma!(0.5)).build(&src, 4);
let level = pyramid.level(3);
let naive = 0.5 * level.pixel_distance().get() as f32;
let truth = level.sigma().get();
assert!((naive - 4.0).abs() < 1e-6, "naive guess {naive}");
assert!(
((truth - naive) / truth - 0.132).abs() < 5e-3,
"truth {truth}, naive {naive}"
);
}
#[test]
fn scaled_levels_keep_the_same_grid_as_placed_ones() {
let src = Image::fill(32, 20, MonoF32::new(0.5));
let placed: PlacedPyramid<MonoF32> = Gaussian.build(&src, 4);
let scaled: ScaledPyramid<MonoF32> =
Gaussian.assuming_input_sigma(sigma!(0.5)).build(&src, 4);
assert_eq!(placed.depth(), scaled.depth());
for index in 0..placed.depth() {
assert_eq!(placed.level(index).size(), scaled.level(index).size());
assert_eq!(
placed.level(index).pixel_distance(),
scaled.level(index).pixel_distance()
);
}
}
#[test]
fn the_input_sigma_is_readable_on_the_method_and_nowhere_else() {
let method = Gaussian.assuming_input_sigma(sigma!(0.5));
assert_eq!(method.input_sigma(), sigma!(0.5));
}
#[test]
fn both_builders_produce_dyadic_pyramids_that_expand() {
let src = Image::fill(21, 13, MonoF32::new(0.5));
let placed: PlacedPyramid<MonoF32> = Gaussian.build(&src, 3);
let scaled: ScaledPyramid<MonoF32> =
Gaussian.assuming_input_sigma(sigma!(0.5)).build(&src, 3);
let from_placed: Image<MonoF32> = placed.expand(2).expect("level 2 has a parent");
let from_scaled: Image<MonoF32> = scaled.expand(2).expect("level 2 has a parent");
assert_eq!(from_placed.size(), placed.level(1).size());
assert_eq!(from_scaled.size(), scaled.level(1).size());
}
}