// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
// Making an image a different size.
//
// Five filters, because five different questions get asked. Nearest is what a
// pixel artist wants: a pixel becomes a square, and no colour that was not in
// the original appears. Bilinear is what a photograph wants when it is being
// scaled a little: two taps, cheap, and smooth enough that the result does not
// crawl. Catmull-Rom is what it wants when it is being scaled up a lot and the
// edge matters more than the ringing; Lanczos is what it wants for a still
// picture, where six taps of a windowed sinc keep the edge and pay for it with a
// little ringing; and the Gaussian is the one to reach for when the ringing
// itself is the problem, because its kernel has none.
//
// The four that reach past two taps widen their footprint when the image is
// being made smaller. A filter sampled at the output rate sees one input pixel
// in eight and throws the other seven away, which is what aliasing is: widening
// the footprint so that it covers everything the output pixel stands for is the
// whole of antialiasing. Bilinear stays at two taps in both directions — it is
// the cheap filter, and a caller who wants the footprint covered has four
// others to choose from.
//
// Alpha is resampled premultiplied even when the image is not stored that way.
// A transparent pixel usually has an arbitrary colour behind its alpha, and
// averaging that colour into its neighbours is how a sprite gets a dark fringe.
///|
/// How a scaler decides what an unknown pixel is worth.
#derive(Eq, Debug)
pub(all) enum Sampling {
/// The nearest pixel, and nothing else: no new colours.
Nearest
/// A weighted average of the two nearest, weights linear in distance.
Bilinear
/// A weighted average of the four nearest per axis, weights from the cubic
/// with a = −0.5, which is the one that interpolates and does not overshoot
/// much.
CatmullRom
/// A weighted average around the point, weights from a Gaussian with
/// σ = 0.5 truncated at three sigma. No ringing at all, at the cost of a
/// softer edge than the cubic or the windowed sinc.
Gaussian
/// A weighted average of the six nearest per axis, weights from the Lanczos
/// window with a = 3.
Lanczos
}
///|
pub extend Sampling with Eq::{equal, not_equal}
///|
pub extend Sampling with Debug::{to_repr}
///|
/// The image at a new size.
///
/// Raises when either dimension is not positive, and when the result would be
/// larger than `Limits::default()` — a scaler is easy to point at a huge size by
/// accident.
pub fn Image::scale(
self : Image,
width : Int32,
height : Int32,
sampling? : Sampling,
) -> Image raise Refused {
if width <= 0 || height <= 0 {
raise Unexpected(
at=0,
got="a size with no pixels",
want="a width and a height above zero",
)
}
Limits::default().check(width, height)
let sampling = sampling.unwrap_or(Bilinear)
if width == self.width && height == self.height {
return self
}
match sampling {
Nearest => self.scale_nearest(width, height)
Bilinear => self.scale_bilinear(width, height)
_ => self.scale_weighted(width, height, sampling)
}
}
///|
/// Nearest neighbour, copied byte by byte: no arithmetic, so a palette stays a
/// palette.
///
/// The source pixel is the one whose centre the output pixel's centre falls in,
/// which is the same rule every other filter uses and therefore the one that
/// makes nearest the limit of the others as their footprints shrink.
fn Image::scale_nearest(self : Image, width : Int32, height : Int32) -> Image {
let pixel = self.format.bytes()
let stride = width * pixel
let buffer : FixedArray[Byte] = FixedArray::make(stride * height, b'\x00')
for y in 0.. Int32 {
let chosen = ((index.to_double() + 0.5) *
source.to_double() /
target.to_double()).to_int()
if chosen < 0 {
0
} else if chosen >= source {
source - 1
} else {
chosen
}
}
///|
/// Bilinear: the two samples the output position lands between, weighted by how
/// far it has gone past the first.
fn Image::scale_bilinear(self : Image, width : Int32, height : Int32) -> Image {
let source = self.working_copy()
let across = resample(source, self.width, self.height, width, true, Bilinear)
let down = resample(across, width, self.height, height, false, Bilinear)
self.collect(down, width, height)
}
///|
/// Catmull-Rom, Gaussian or Lanczos, one axis at a time.
///
/// Two passes, because all three filters are separable: a two-dimensional
/// Lanczos over six taps is thirty-six samples per pixel, and two one-dimensional
/// ones are twelve.
///
/// The working copy is `Double`, not `Float` and not an eight-bit image. A
/// filter that averages 200 and 255 lands on 227.5, and 227.5 is exactly the
/// value that a narrower working type rounds down: one step of brightness lost
/// on every boundary, which is visible as banding in a gradient.
fn Image::scale_weighted(
self : Image,
width : Int32,
height : Int32,
sampling : Sampling,
) -> Image {
let source = self.working_copy()
let across = resample(source, self.width, self.height, width, true, sampling)
let down = resample(across, width, self.height, height, false, sampling)
self.collect(down, width, height)
}
///|
/// Four samples per pixel of the result, written back in the image's own format.
fn Image::collect(
self : Image,
down : FixedArray[Double],
width : Int32,
height : Int32,
) -> Image {
let pixel = self.format.bytes()
let stride = width * pixel
let buffer : FixedArray[Byte] = FixedArray::make(stride * height, b'\x00')
for y in 0.. FixedArray[Double] {
let pixels : FixedArray[Double] = FixedArray::make(
self.width * self.height * 4,
0.0,
)
let bpp = self.format.bytes()
let multiplied = self.premultiplied || !self.format.has_alpha()
for y in 0.. FixedArray[Double] {
let out_width = if horizontal { length } else { width }
let out_height = if horizontal { height } else { length }
let taps = axis_weights(
if horizontal {
width
} else {
height
},
length,
sampling,
)
let pixels : FixedArray[Double] = FixedArray::make(
out_width * out_height * 4,
0.0,
)
for y in 0.. Double {
if value < 0.0 {
0.0
} else if value > 1.0 {
1.0
} else {
value
}
}
///|
/// The taps each output position reads, and how much of each.
///
/// The footprint is the filter's own support, widened by the ratio when the
/// image is being made smaller. Positions that fall outside the image are
/// clamped to its edge, and the weights are renormalised afterwards: a filter
/// that sums to less than one at the border darkens it, which is the visible
/// edge artefact of a naive scaler.
fn axis_weights(
source : Int32,
target : Int32,
sampling : Sampling,
) -> Array[Array[(Int32, Double)]] {
let ratio = source.to_double() / target.to_double()
let wide = !(sampling == Bilinear)
let filterscale = if wide && ratio > 1.0 { ratio } else { 1.0 }
let support = (match sampling {
Nearest => 0.5
Bilinear => 1.0
CatmullRom => 2.0
Gaussian => 1.5
Lanczos => 3.0
}) *
filterscale
let taps : Array[Array[(Int32, Double)]] = []
for i in 0.. Double {
match sampling {
CatmullRom => catmull_rom(x)
Gaussian =>
// Three sigma of a kernel whose standard deviation is half a pixel: the
// narrowest Gaussian that still covers the pixel it is centred on.
if x <= -1.5 || x >= 1.5 {
0.0
} else {
@math.exp(-2.0 * x * x)
}
_ => lanczos(x)
}
}
///|
/// The cubic with a = −0.5, in one dimension.
fn catmull_rom(x : Double) -> Double {
let distance = if x < 0.0 { -x } else { x }
if distance < 1.0 {
1.5 * distance * distance * distance - 2.5 * distance * distance + 1.0
} else if distance < 2.0 {
-0.5 * distance * distance * distance +
2.5 * distance * distance -
4.0 * distance +
2.0
} else {
0.0
}
}
///|
/// One tap, kept only if it falls on a sample.
///
/// A tap outside the image is dropped rather than folded onto the edge. Folding
/// is the cheaper mistake and the more visible one: at a strong downscale the
/// footprint is wider than the picture, and every tap outside it would pile onto
/// the first column, darkening or brightening the whole border.
fn push_weight(
taps : Array[(Int32, Double)],
index : Int32,
weight : Double,
source : Int32,
) -> Unit {
if index >= 0 && index < source {
taps.push((index, weight))
}
}
///|
fn sum_weights(taps : Array[(Int32, Double)]) -> Double {
let mut total = 0.0
for tap in taps {
total = total + tap.1
}
total
}
///|
/// The Lanczos window with a = 3, in one dimension.
fn lanczos(x : Double) -> Double {
let distance = if x < 0.0 { -x } else { x }
if distance < 1.0e-9 {
1.0
} else if distance >= 3.0 {
0.0
} else {
let pi = 3.141592653589793
let px = pi * distance
3.0 * @math.sin(px) * @math.sin(px / 3.0) / (px * px)
}
}