Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions bicubic.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
///|
/// Bicubic resize using the Catmull-Rom spline: each output pixel blends a
/// 4x4 source neighborhood with cubic weights, giving noticeably smoother
/// results than bilinear on photographic content while staying local. Uses
/// the same center-aligned sampling and clamp-to-edge policy as the other
/// resizers, so a same-size resize stays an identity up to rounding.

///|
/// Catmull-Rom kernel weight for the four taps around fractional offset
/// `t` in [0, 1): returns the weights of taps at offsets -1, 0, +1, +2.
fn catmull_weights(t : Double) -> (Double, Double, Double, Double) {
let t2 = t * t
let t3 = t2 * t
// Standard Catmull-Rom (a = -0.5) expanded per tap.
let w0 = -0.5 * t3 + t2 - 0.5 * t
let w1 = 1.5 * t3 - 2.5 * t2 + 1.0
let w2 = -1.5 * t3 + 2.0 * t2 + 0.5 * t
let w3 = 0.5 * t3 - 0.5 * t2
(w0, w1, w2, w3)
}

///|
/// Bicubic (Catmull-Rom) resize to `new_width` x `new_height` (both clamped
/// to at least 1). All four channels including alpha are interpolated; the
/// cubic can overshoot near edges, so results are clamped back to 0..255.
pub fn Image::resize_bicubic(
self : Image,
new_width : Int,
new_height : Int,
) -> Image {
let w = if new_width < 1 { 1 } else { new_width }
let h = if new_height < 1 { 1 } else { new_height }
let out = Image::new(w, h)
for y in 0..<h {
let fy = (y.to_double() + 0.5) * self.height.to_double() / h.to_double() -
0.5
let y0 = ifloor(fy)
let ty = fy - y0.to_double()
let (wy0, wy1, wy2, wy3) = catmull_weights(ty)
let sy = [
clamp_coord(y0 - 1, self.height),
clamp_coord(y0, self.height),
clamp_coord(y0 + 1, self.height),
clamp_coord(y0 + 2, self.height),
]
let wy = [wy0, wy1, wy2, wy3]
for x in 0..<w {
let fx = (x.to_double() + 0.5) * self.width.to_double() / w.to_double() -
0.5
let x0 = ifloor(fx)
let tx = fx - x0.to_double()
let (wx0, wx1, wx2, wx3) = catmull_weights(tx)
let sx = [
clamp_coord(x0 - 1, self.width),
clamp_coord(x0, self.width),
clamp_coord(x0 + 1, self.width),
clamp_coord(x0 + 2, self.width),
]
let wx = [wx0, wx1, wx2, wx3]
let dst = (y * w + x) * 4
for c in 0..<4 {
let mut acc = 0.0
for j in 0..<4 {
let mut row = 0.0
for i in 0..<4 {
row = row +
self.data[(sy[j] * self.width + sx[i]) * 4 + c]
.to_int()
.to_double() *
wx[i]
}
acc = acc + row * wy[j]
}
out.data[dst + c] = clamp_byte((acc + 0.5).to_int())
}
}
}
out
}
78 changes: 78 additions & 0 deletions bicubic_test.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
///|
/// Bicubic resize tests: identity, flat-field invariance, interpolation
/// smoothness versus nearest-neighbor, and size clamping.

///|
test "bicubic same-size resize is an identity" {
// Center-aligned sampling puts every sample exactly on a source pixel,
// where Catmull-Rom weights collapse to (0, 1, 0, 0) — exact copy.
let img = @pixelforge.Image::new(5, 4)
for y in 0..<4 {
for x in 0..<5 {
img.set_pixel(
x,
y,
@pixelforge.clamp_byte(x * 47),
@pixelforge.clamp_byte(y * 61),
@pixelforge.clamp_byte((x * y * 13 + 9) % 256),
@pixelforge.clamp_byte(200 - x),
)
}
}
let out = img.resize_bicubic(5, 4)
for i in 0..<(5 * 4 * 4) {
assert_eq(out.data[i].to_int(), img.data[i].to_int())
}
}

///|
test "bicubic keeps a flat field unchanged at any scale" {
let img = solid(6, 6, 88, 144, 21, 255)
for dims in [(3, 3), (12, 12), (7, 5)] {
let (nw, nh) = dims
let out = img.resize_bicubic(nw, nh)
assert_eq(out.width, nw)
assert_eq(out.height, nh)
for y in 0..<nh {
for x in 0..<nw {
let (r, g, b, a) = out.get_pixel(x, y)
assert_eq(r.to_int(), 88)
assert_eq(g.to_int(), 144)
assert_eq(b.to_int(), 21)
assert_eq(a.to_int(), 255)
}
}
}
}

///|
test "bicubic upscale of a step is smoother than nearest-neighbor" {
// A hard 0|255 step upscaled 4x: nearest keeps only {0, 255}, bicubic
// must produce at least one intermediate value across the transition.
let img = @pixelforge.Image::new(4, 1)
img.set_pixel(0, 0, b'\x00', b'\x00', b'\x00', b'\xFF')
img.set_pixel(1, 0, b'\x00', b'\x00', b'\x00', b'\xFF')
img.set_pixel(2, 0, b'\xFF', b'\xFF', b'\xFF', b'\xFF')
img.set_pixel(3, 0, b'\xFF', b'\xFF', b'\xFF', b'\xFF')
let cubic = img.resize_bicubic(16, 1)
let mut has_mid = false
for x in 0..<16 {
let v = cubic.get_pixel(x, 0).0.to_int()
if v > 10 && v < 245 {
has_mid = true
}
}
assert_true(has_mid)
// Endpoints stay at the extremes.
assert_eq(cubic.get_pixel(0, 0).0.to_int(), 0)
assert_eq(cubic.get_pixel(15, 0).0.to_int(), 255)
}

///|
test "bicubic clamps degenerate target sizes to 1x1" {
let img = solid(4, 4, 50, 60, 70, 255)
let out = img.resize_bicubic(0, -3)
assert_eq(out.width, 1)
assert_eq(out.height, 1)
assert_eq(out.get_pixel(0, 0).0.to_int(), 50)
}
1 change: 1 addition & 0 deletions pkg.generated.mbti
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ pub fn Image::pad(Self, Int, Int, Int, Int, Byte, Byte, Byte, Byte) -> Self
pub fn Image::pixel_count(Self) -> Int
pub fn Image::pixelate(Self, Int) -> Self
pub fn Image::posterize(Self, Int) -> Self
pub fn Image::resize_bicubic(Self, Int, Int) -> Self
pub fn Image::resize_bilinear(Self, Int, Int) -> Self
pub fn Image::resize_nearest(Self, Int, Int) -> Self
pub fn Image::rotate(Self, Double) -> Self
Expand Down
Loading