///|
/// Summary statistics for a raster preview or regression fixture.
pub(all) struct RasterStats {
width : Int
height : Int
pixel_count : Int
mean_luminance : Double
minimum_luminance : Double
maximum_luminance : Double
opaque_pixels : Int
transparent_pixels : Int
} derive(Eq, Debug, ToJson)
///|
/// A small immutable convolution kernel.
pub(all) struct RasterKernel {
width : Int
height : Int
values : Array[Double]
divisor : Double
bias : Double
} derive(Eq, Debug, ToJson)
///|
pub fn RasterKernel::new(
width : Int,
height : Int,
values : Array[Double],
divisor? : Double = 1.0,
bias? : Double = 0.0,
) -> RasterKernel {
{
width: width.max(0),
height: height.max(0),
values,
divisor: if divisor == 0.0 {
1.0
} else {
divisor
},
bias,
}
}
///|
/// Creates a normalized box-blur kernel.
pub fn box_kernel(radius : Int) -> RasterKernel {
let safe_radius = radius.max(0)
let size = safe_radius * 2 + 1
let count = size * size
let values = []
for _ in 0.. RasterKernel {
let safe_radius = radius.max(0)
let size = safe_radius * 2 + 1
let weights = []
for index in 0.. Raster {
Raster::new(width, height, fill=Color::rgba(0.0, 0.0, 0.0, a=0.0))
}
///|
/// Returns a copy of the raster without sharing its pixel storage.
pub fn Raster::copy(self : Raster) -> Raster {
{ ..self, pixels: self.pixels.copy() }
}
///|
/// Computes luminance and alpha statistics in one pass.
pub fn Raster::stats(self : Raster) -> RasterStats {
if self.pixels.length() == 0 {
{
width: self.width,
height: self.height,
pixel_count: 0,
mean_luminance: 0.0,
minimum_luminance: 0.0,
maximum_luminance: 0.0,
opaque_pixels: 0,
transparent_pixels: 0,
}
} else {
let mut total = 0.0
let mut minimum = self.pixels[0].luminance()
let mut maximum = minimum
let mut solid = 0
for pixel in self.pixels {
let luminance = pixel.luminance()
total += luminance
minimum = minimum.min(luminance)
maximum = maximum.max(luminance)
if pixel.a >= 0.999 {
solid += 1
}
}
{
width: self.width,
height: self.height,
pixel_count: self.pixels.length(),
mean_luminance: total / self.pixels.length().to_double(),
minimum_luminance: minimum,
maximum_luminance: maximum,
opaque_pixels: solid,
transparent_pixels: self.pixels.length() - solid,
}
}
}
///|
/// Applies a function to every pixel and returns a new raster.
pub fn Raster::map_pixels(
self : Raster,
transform : (Int, Int, Color) -> Color,
) -> Raster {
let result = self.copy()
if self.width > 0 && self.height > 0 {
for y in 0.. Color,
) -> Raster {
self.map_pixels(fn(_, _, color) { transform(color) })
}
///|
/// Crops a raster to the intersection with a requested rectangle.
pub fn Raster::crop(self : Raster, bounds : Rect) -> Raster {
let x0 = bounds.x.to_int().max(0).min(self.width)
let y0 = bounds.y.to_int().max(0).min(self.height)
let x1 = bounds.right().to_int().max(x0).min(self.width)
let y1 = bounds.bottom().to_int().max(y0).min(self.height)
let result = Raster::new(
x1 - x0,
y1 - y0,
fill=Color::rgba(0.0, 0.0, 0.0, a=0.0),
)
for y in y0.. Color {
raster
.get(x.to_int(), y.to_int())
.unwrap_or(Color::rgba(0.0, 0.0, 0.0, a=0.0))
}
///|
/// Samples a raster with bilinear interpolation and transparent out-of-bounds.
pub fn Raster::sample_bilinear(self : Raster, x : Double, y : Double) -> Color {
if self.width == 0 || self.height == 0 {
Color::rgba(0.0, 0.0, 0.0, a=0.0)
} else {
let x0 = x.to_int()
let y0 = y.to_int()
let tx = x - x0.to_double()
let ty = y - y0.to_double()
let a = raster_sample_nearest(self, x0.to_double(), y0.to_double())
let b = raster_sample_nearest(self, (x0 + 1).to_double(), y0.to_double())
let c = raster_sample_nearest(self, x0.to_double(), (y0 + 1).to_double())
let d = raster_sample_nearest(
self,
(x0 + 1).to_double(),
(y0 + 1).to_double(),
)
a.mix(b.mix(c, ty).mix(d, ty), tx)
}
}
///|
/// Resizes a raster using nearest-neighbor sampling.
pub fn Raster::resize_nearest(
self : Raster,
width : Int,
height : Int,
) -> Raster {
let result = Raster::new(width, height)
if width > 0 && height > 0 && self.width > 0 && self.height > 0 {
for y in 0.. Raster {
let result = Raster::new(width, height)
if width > 0 && height > 0 && self.width > 0 && self.height > 0 {
for y in 0.. Raster {
let result = Raster::new(self.width, self.height)
for y in 0.. Raster {
let result = Raster::new(self.width, self.height)
for y in 0.. Raster {
let result = Raster::new(self.height, self.width)
for y in 0.. Raster {
let result = self.copy()
if self.width > 0 && self.height > 0 && kernel.width > 0 && kernel.height > 0 {
let cx = kernel.width / 2
let cy = kernel.height / 2
for y in 0.. Raster {
self.convolve(box_kernel(radius.min(32)))
}
///|
/// Applies a smooth Gaussian-like blur.
pub fn Raster::blur_gaussian(self : Raster, radius : Int) -> Raster {
self.convolve(gaussian_kernel(radius.min(16)))
}
///|
/// Enhances local edges using an unsharp-mask style kernel.
pub fn Raster::sharpen(self : Raster, amount : Double) -> Raster {
let strength = amount.max(0.0).min(4.0)
self.convolve(
RasterKernel::new(3, 3, [
0.0,
-strength,
0.0,
-strength,
1.0 + 4.0 * strength,
-strength,
0.0,
-strength,
0.0,
]),
)
}
///|
/// Converts a raster to luminance while preserving alpha.
pub fn Raster::grayscale(self : Raster) -> Raster {
self.map_colors(fn(color) {
let value = color.luminance()
Color::rgba(value, value, value, a=color.a)
})
}
///|
/// Applies a contrast curve around the midpoint.
pub fn Raster::contrast(self : Raster, amount : Double) -> Raster {
let factor = (1.0 + amount.max(-1.0).min(4.0)).max(0.0)
self.map_colors(fn(color) {
Color::rgba(
(color.r - 0.5) * factor + 0.5,
(color.g - 0.5) * factor + 0.5,
(color.b - 0.5) * factor + 0.5,
a=color.a,
)
})
}
///|
/// Adjusts brightness by adding a normalized amount.
pub fn Raster::brightness(self : Raster, amount : Double) -> Raster {
self.map_colors(fn(color) {
Color::rgba(color.r + amount, color.g + amount, color.b + amount, a=color.a)
})
}
///|
/// Posterizes each channel into a fixed number of levels.
pub fn Raster::posterize(self : Raster, levels : Int) -> Raster {
let count = levels.max(2)
self.map_colors(fn(color) {
let quantize = fn(value) {
(value * (count - 1).to_double()).to_int().to_double() /
(count - 1).to_double()
}
Color::rgba(
quantize(color.r),
quantize(color.g),
quantize(color.b),
a=color.a,
)
})
}
///|
/// Applies a two-color luminance ramp.
pub fn Raster::colorize(self : Raster, low : Color, high : Color) -> Raster {
self.map_colors(fn(color) { low.mix(high, color.luminance()) })
}
///|
/// Returns a raster where alpha is multiplied by a grayscale mask.
pub fn Raster::apply_mask(self : Raster, mask : Raster) -> Raster {
self.map_pixels(fn(x, y, color) {
let amount = mask
.get(x, y)
.map(fn(pixel) { pixel.luminance() })
.unwrap_or(0.0)
color.with_alpha(color.a * amount)
})
}
///|
/// Thresholds a raster into an opaque binary mask.
pub fn Raster::threshold(self : Raster, cutoff : Double) -> Raster {
self.map_colors(fn(color) {
let value = if color.luminance() >= cutoff { 1.0 } else { 0.0 }
Color::rgba(value, value, value, a=1.0)
})
}
///|
/// Computes a Sobel edge magnitude map.
pub fn Raster::sobel_edges(self : Raster) -> Raster {
let gx = RasterKernel::new(3, 3, [
-1.0, 0.0, 1.0, -2.0, 0.0, 2.0, -1.0, 0.0, 1.0,
])
let gy = RasterKernel::new(3, 3, [
-1.0, -2.0, -1.0, 0.0, 0.0, 0.0, 1.0, 2.0, 1.0,
])
let result = Raster::new(self.width, self.height, fill=Color::rgb8(0, 0, 0))
for y in 0.. Array[Int] {
let result = self.histogram(bins)
for index in 1.. Raster {
let result = self.copy()
let safe_radius = radius.max(0)
for y in 0.. Raster {
let result = self.copy()
let safe_radius = radius.max(0)
for y in 0.. Int {
match self.get(start_x, start_y) {
None => 0
Some(original) => {
let queue : Array[(Int, Int)] = [(start_x, start_y)]
let mut head = 0
let mut filled = 0
while head < queue.length() {
let (x, y) = queue[head]
head += 1
match self.get(x, y) {
Some(current) =>
if raster_color_distance_squared(current, original) <=
tolerance * tolerance {
let _ = self.set(x, y, replacement)
filled += 1
for neighbor in [(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)] {
match self.get(neighbor.0, neighbor.1) {
Some(candidate) =>
if raster_color_distance_squared(candidate, original) <=
tolerance * tolerance {
queue.push(neighbor)
}
None => ()
}
}
}
None => ()
}
}
filled
}
}
}
///|
fn raster_color_distance_squared(left : Color, right : Color) -> Double {
let r = left.r - right.r
let g = left.g - right.g
let b = left.b - right.b
r * r + g * g + b * b
}
///|
/// Counts connected components in a binary luminance raster.
pub fn Raster::connected_components(
self : Raster,
cutoff : Double,
) -> Array[Array[Vec2]] {
let visited = []
for _ in 0..<(self.width * self.height) {
visited.push(false)
}
let result : Array[Array[Vec2]] = []
for y in 0..= cutoff {
let component : Array[Vec2] = []
let queue : Array[(Int, Int)] = [(x, y)]
let mut head = 0
visited[index] = true
while head < queue.length() {
let (qx, qy) = queue[head]
head += 1
component.push(Vec2::new(qx.to_double(), qy.to_double()))
for
neighbor in [(qx + 1, qy), (qx - 1, qy), (qx, qy + 1), (qx, qy - 1)] {
if self.in_bounds(neighbor.0, neighbor.1) {
let ni = self.index(neighbor.0, neighbor.1)
if !visited[ni] && self.pixels[ni].luminance() >= cutoff {
visited[ni] = true
queue.push(neighbor)
}
}
}
}
result.push(component)
}
}
}
result
}
///|
/// Renders a polygon into a mask raster.
pub fn rasterize_mask(
width : Int,
height : Int,
polygons : Array[Array[Vec2]],
) -> Raster {
let result = Raster::new(width, height, fill=Color::rgb8(0, 0, 0))
for polygon in polygons {
result.fill_polygon(polygon, Color::rgb8(255, 255, 255))
}
result
}
///|
/// Composites a foreground raster over a background raster.
pub fn composite_rasters(
background : Raster,
foreground : Raster,
offset : Vec2,
) -> Raster {
let result = background.copy()
for y in 0.. {
let _ = result.blend_pixel(dx, dy, color)
}
None => ()
}
}
}
result
}