// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
// Making an image a different size.
//
// Three filters, because the three answer different questions. 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: cheap, and smooth enough that the result does not crawl.
// Lanczos is what it wants when it is being scaled a lot or viewed still: six
// taps per axis, sharp edges, and a small amount of ringing that is the price of
// the sharpness.
//
// 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.
pub(all) enum Sampling {
/// The nearest pixel, and nothing else: no new colours.
Nearest
/// A weighted average of the four nearest, weights linear in distance.
Bilinear
/// A weighted average of the six nearest per axis, weights from the Lanczos
/// window with a = 3.
Lanczos
} derive(Eq, Debug)
///|
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)
_ => self.scale_weighted(width, height, sampling)
}
}
///|
/// Nearest neighbour, copied byte by byte: no arithmetic, so a palette stays a
/// palette.
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.. 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)
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.
///
/// 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 taps : Array[Array[(Int32, Double)]] = []
for i in 0.. Unit {
let clamped = if index < 0 {
0
} else if index >= source {
source - 1
} else {
index
}
taps.push((clamped, 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)
}
}