mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 19:17:02 -06:00
392 lines
15 KiB
Rust
392 lines
15 KiB
Rust
//! Image buffers and basic raster operations for LightCraft.
|
||
//!
|
||
//! - [`Image<T>`]: interleaved, row-major pixels (`Rgb32f = Image<[f32; 3]>`, `Rgba8 = Image<[u8; 4]>`,
|
||
//! `Plane = Image<f32>`).
|
||
//! - Resampling ([`resample`]): separable Lanczos-3 / Mitchell / box, rayon-parallel; point sampling for warps
|
||
//! ([`sample`]: Lanczos-3 held to its neighbours' range).
|
||
//! - Blur ([`blur`]): separable Gaussian via repeated box filters (O(1) per pixel in the radius).
|
||
//! - Morphology ([`morph`]): square min / max filters (van Herk / Gil-Werman, O(1) per pixel in the radius).
|
||
//! - [`Histogram`] of display-encoded RGB + luminance.
|
||
#![forbid(unsafe_code)]
|
||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||
|
||
pub mod blur;
|
||
pub mod histogram;
|
||
pub mod morph;
|
||
pub mod resample;
|
||
pub mod sample;
|
||
|
||
pub use histogram::{HdrBins, Histogram};
|
||
|
||
/// Run `f` over row chunks, in parallel when the `parallel` feature is on.
|
||
pub fn par_rows<T: Send>(data: &mut [T], row_len: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) {
|
||
if row_len == 0 {
|
||
return;
|
||
}
|
||
#[cfg(feature = "parallel")]
|
||
{
|
||
use rayon::prelude::*;
|
||
data.par_chunks_mut(row_len).enumerate().for_each(|(y, row)| f(y, row));
|
||
}
|
||
#[cfg(not(feature = "parallel"))]
|
||
{
|
||
data.chunks_mut(row_len).enumerate().for_each(|(y, row)| f(y, row));
|
||
}
|
||
}
|
||
|
||
/// Run `a` and `b` potentially in parallel (sequentially without the `parallel` feature). For
|
||
/// independent passes that each scale poorly on their own (small images, short rows).
|
||
pub fn par_join<A: Send, B: Send>(a: impl FnOnce() -> A + Send, b: impl FnOnce() -> B + Send) -> (A, B) {
|
||
#[cfg(feature = "parallel")]
|
||
{
|
||
rayon::join(a, b)
|
||
}
|
||
#[cfg(not(feature = "parallel"))]
|
||
{
|
||
(a(), b())
|
||
}
|
||
}
|
||
|
||
/// Interleaved row-major image.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Image<T> {
|
||
pub width: usize,
|
||
pub height: usize,
|
||
pub data: Vec<T>,
|
||
}
|
||
|
||
pub type Rgb32f = Image<[f32; 3]>;
|
||
pub type Rgba8 = Image<[u8; 4]>;
|
||
pub type Plane = Image<f32>;
|
||
|
||
impl<T: Copy + Default> Image<T> {
|
||
pub fn new(width: usize, height: usize) -> Self {
|
||
Self { width, height, data: vec![T::default(); width * height] }
|
||
}
|
||
pub fn filled(width: usize, height: usize, v: T) -> Self {
|
||
Self { width, height, data: vec![v; width * height] }
|
||
}
|
||
pub fn from_fn(width: usize, height: usize, f: impl Fn(usize, usize) -> T) -> Self {
|
||
let mut data = Vec::with_capacity(width * height);
|
||
for y in 0..height {
|
||
for x in 0..width {
|
||
data.push(f(x, y));
|
||
}
|
||
}
|
||
Self { width, height, data }
|
||
}
|
||
#[inline]
|
||
pub fn get(&self, x: usize, y: usize) -> T {
|
||
self.data[y * self.width + x]
|
||
}
|
||
/// Clamped access (edge extension).
|
||
#[inline]
|
||
pub fn get_clamped(&self, x: isize, y: isize) -> T {
|
||
let x = x.clamp(0, self.width as isize - 1) as usize;
|
||
let y = y.clamp(0, self.height as isize - 1) as usize;
|
||
self.data[y * self.width + x]
|
||
}
|
||
#[inline]
|
||
pub fn set(&mut self, x: usize, y: usize, v: T) {
|
||
self.data[y * self.width + x] = v;
|
||
}
|
||
pub fn row(&self, y: usize) -> &[T] {
|
||
&self.data[y * self.width..(y + 1) * self.width]
|
||
}
|
||
pub fn row_mut(&mut self, y: usize) -> &mut [T] {
|
||
&mut self.data[y * self.width..(y + 1) * self.width]
|
||
}
|
||
pub fn len(&self) -> usize {
|
||
self.data.len()
|
||
}
|
||
pub fn is_empty(&self) -> bool {
|
||
self.data.is_empty()
|
||
}
|
||
/// The image mirrored about its main diagonal (`width` and `height` swap; row-parallel). A
|
||
/// buffer shorter than its size reads as `T::default()` where it runs out.
|
||
pub fn transpose(&self) -> Self
|
||
where
|
||
T: Send + Sync,
|
||
{
|
||
let (w, h) = (self.width, self.height);
|
||
let mut t = Image::<T>::new(h, w);
|
||
par_rows(&mut t.data, h, |x, row| {
|
||
for (y, v) in row.iter_mut().enumerate() {
|
||
*v = self.data.get(y * w + x).copied().unwrap_or_default();
|
||
}
|
||
});
|
||
t
|
||
}
|
||
/// [`Self::map`] into the same buffer (no second image in memory).
|
||
pub fn map_in_place(&mut self, f: impl Fn(T) -> T + Sync + Send)
|
||
where
|
||
T: Send,
|
||
{
|
||
let w = self.width;
|
||
par_rows(&mut self.data, w, |_, row| {
|
||
for p in row.iter_mut() {
|
||
*p = f(*p);
|
||
}
|
||
});
|
||
}
|
||
|
||
pub fn map<U: Copy + Default + Send>(&self, f: impl Fn(T) -> U + Sync + Send) -> Image<U>
|
||
where
|
||
T: Sync,
|
||
{
|
||
let mut out = Image::<U>::new(self.width, self.height);
|
||
let w = self.width;
|
||
par_rows(&mut out.data, w, |y, row| {
|
||
let src = &self.data[y * w..(y + 1) * w];
|
||
for (o, s) in row.iter_mut().zip(src) {
|
||
*o = f(*s);
|
||
}
|
||
});
|
||
out
|
||
}
|
||
/// Per-pixel combination of two images of the same size (row-parallel).
|
||
pub fn zip_map<U: Copy + Default + Sync, V: Copy + Default + Send>(&self, other: &Image<U>, f: impl Fn(T, U) -> V + Sync + Send) -> Image<V>
|
||
where
|
||
T: Sync,
|
||
{
|
||
assert_eq!((self.width, self.height), (other.width, other.height), "zip_map: size mismatch");
|
||
let mut out = Image::<V>::new(self.width, self.height);
|
||
let w = self.width;
|
||
par_rows(&mut out.data, w, |y, row| {
|
||
let (a, b) = (&self.data[y * w..(y + 1) * w], &other.data[y * w..(y + 1) * w]);
|
||
for ((o, s), t) in row.iter_mut().zip(a).zip(b) {
|
||
*o = f(*s, *t);
|
||
}
|
||
});
|
||
out
|
||
}
|
||
/// Crop to `[x0, x0+w) × [y0, y0+h)` (clamped to the image).
|
||
pub fn crop(&self, x0: usize, y0: usize, w: usize, h: usize) -> Self {
|
||
let x0 = x0.min(self.width);
|
||
let y0 = y0.min(self.height);
|
||
let w = w.min(self.width - x0);
|
||
let h = h.min(self.height - y0);
|
||
let mut data = Vec::with_capacity(w * h);
|
||
for y in y0..y0 + h {
|
||
data.extend_from_slice(&self.data[y * self.width + x0..y * self.width + x0 + w]);
|
||
}
|
||
Self { width: w, height: h, data }
|
||
}
|
||
/// [`Self::crop`] in place: rows move to the front of the same buffer (no second image).
|
||
pub fn into_crop(mut self, x0: usize, y0: usize, w: usize, h: usize) -> Self {
|
||
let x0 = x0.min(self.width);
|
||
let y0 = y0.min(self.height);
|
||
let w = w.min(self.width - x0);
|
||
let h = h.min(self.height - y0);
|
||
for (i, y) in (y0..y0 + h).enumerate() {
|
||
let src = y * self.width + x0;
|
||
self.data.copy_within(src..src + w, i * w);
|
||
}
|
||
self.data.truncate(w * h);
|
||
Self { width: w, height: h, data: self.data }
|
||
}
|
||
/// Rotate 90° clockwise.
|
||
pub fn rotate_cw(&self) -> Self {
|
||
let (w, h) = (self.width, self.height);
|
||
Image::from_fn(h, w, |x, y| self.get(y, h - 1 - x))
|
||
}
|
||
pub fn rotate_180(&self) -> Self {
|
||
let mut d = self.data.clone();
|
||
d.reverse();
|
||
Self { width: self.width, height: self.height, data: d }
|
||
}
|
||
pub fn flip_h(&self) -> Self {
|
||
let mut out = self.clone();
|
||
for y in 0..self.height {
|
||
out.row_mut(y).reverse();
|
||
}
|
||
out
|
||
}
|
||
/// Apply an EXIF orientation (stored → displayed).
|
||
/// [`Self::oriented`] without a copy when `o` is the identity.
|
||
pub fn into_oriented(self, o: lightcraft_geom::Orientation) -> Self {
|
||
if o == lightcraft_geom::Orientation::Normal { self } else { self.oriented(o) }
|
||
}
|
||
|
||
pub fn oriented(&self, o: lightcraft_geom::Orientation) -> Self {
|
||
let (flip, turns) = o.to_parts();
|
||
let mut img = if flip { self.flip_h() } else { self.clone() };
|
||
match turns {
|
||
1 => img = img.rotate_cw(),
|
||
2 => img = img.rotate_180(),
|
||
3 => img = img.rotate_180().rotate_cw(),
|
||
_ => {}
|
||
}
|
||
img
|
||
}
|
||
}
|
||
|
||
impl Rgb32f {
|
||
/// Bilinear sample at continuous pixel coordinates (pixel centres at +0.5).
|
||
#[inline]
|
||
pub fn sample_bilinear(&self, x: f32, y: f32) -> [f32; 3] {
|
||
// a pixel past the edge reads the edge pixel, so a coordinate held a pixel outside gives
|
||
// the same sample; an infinite one (a crop of no size's inverse mapping) then reads the
|
||
// edge too, instead of a NaN weight and a neighbour index past isize::MAX
|
||
let fx = x.clamp(-1.0, self.width as f32 + 1.0) - 0.5;
|
||
let fy = y.clamp(-1.0, self.height as f32 + 1.0) - 0.5;
|
||
let x0 = fx.floor();
|
||
let y0 = fy.floor();
|
||
let tx = fx - x0;
|
||
let ty = fy - y0;
|
||
let (x0, y0) = (x0 as isize, y0 as isize);
|
||
let a = self.get_clamped(x0, y0);
|
||
let b = self.get_clamped(x0 + 1, y0);
|
||
let c = self.get_clamped(x0, y0 + 1);
|
||
let d = self.get_clamped(x0 + 1, y0 + 1);
|
||
std::array::from_fn(|i| {
|
||
let top = a[i] + (b[i] - a[i]) * tx;
|
||
let bot = c[i] + (d[i] - c[i]) * tx;
|
||
top + (bot - top) * ty
|
||
})
|
||
}
|
||
|
||
pub fn luminance(&self) -> Plane {
|
||
self.map(lightcraft_color::luminance_2020)
|
||
}
|
||
|
||
/// Encode to 8-bit sRGB from linear sRGB values (no gamut conversion).
|
||
pub fn to_srgb8(&self) -> Rgba8 {
|
||
use lightcraft_color::transfer::encode_srgb8;
|
||
self.map(|p| [encode_srgb8(p[0]), encode_srgb8(p[1]), encode_srgb8(p[2]), 255])
|
||
}
|
||
}
|
||
|
||
impl Rgba8 {
|
||
/// Raw bytes (RGBA, row-major).
|
||
pub fn as_bytes(&self) -> Vec<u8> {
|
||
self.data.iter().flat_map(|p| *p).collect()
|
||
}
|
||
pub fn from_bytes(width: usize, height: usize, bytes: &[u8]) -> Option<Self> {
|
||
if bytes.len() != width * height * 4 {
|
||
return None;
|
||
}
|
||
Some(Self { width, height, data: bytes.as_chunks::<4>().0.iter().map(|c| [c[0], c[1], c[2], c[3]]).collect() })
|
||
}
|
||
|
||
/// Build an image from exactly `width * height` RGBA pixels.
|
||
///
|
||
/// This copies each pixel once, straight into the image's own buffer, so a caller that already
|
||
/// holds a pixel array (an egui `ColorImage`, say) does not need a second full byte vector.
|
||
/// `None` for a zero or overflowing size, or a pixel count that is not exactly `width * height`.
|
||
pub fn from_pixels(width: usize, height: usize, pixels: impl IntoIterator<Item = [u8; 4]>) -> Option<Self> {
|
||
if width == 0 || height == 0 {
|
||
return None;
|
||
}
|
||
let expected = width.checked_mul(height)?;
|
||
let mut data = Vec::new();
|
||
data.try_reserve_exact(expected).ok()?;
|
||
for (i, pixel) in pixels.into_iter().enumerate() {
|
||
if i == expected {
|
||
return None;
|
||
}
|
||
data.push(pixel);
|
||
}
|
||
(data.len() == expected).then_some(Self { width, height, data })
|
||
}
|
||
/// Linear Rec.709/sRGB primaries float image from 8-bit sRGB.
|
||
pub fn to_linear(&self) -> Rgb32f {
|
||
use lightcraft_color::transfer::decode_srgb8;
|
||
self.map(|p| [decode_srgb8(p[0]), decode_srgb8(p[1]), decode_srgb8(p[2])])
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use lightcraft_geom::Orientation;
|
||
|
||
/// A sample at an infinite coordinate (the inverse mapping of a crop of no size) cast to
|
||
/// isize::MAX, and its right / lower neighbour overflowed (a panic where overflow is checked):
|
||
/// the neighbours saturate and the edge pixel comes back.
|
||
#[test]
|
||
fn bilinear_samples_at_infinite_coordinates_read_the_edge() {
|
||
let img = Rgb32f::from_fn(3, 2, |x, y| [x as f32, y as f32, 1.0]);
|
||
assert_eq!(img.sample_bilinear(f32::INFINITY, f32::INFINITY), [2.0, 1.0, 1.0]);
|
||
assert_eq!(img.sample_bilinear(f32::MAX, 0.0), [2.0, 0.0, 1.0]);
|
||
assert_eq!(img.sample_bilinear(f32::NEG_INFINITY, f32::NEG_INFINITY), [0.0, 0.0, 1.0]);
|
||
assert_eq!(img.sample_bilinear(1.5, 0.5), [1.0, 0.0, 1.0], "an ordinary sample is as before");
|
||
}
|
||
|
||
#[test]
|
||
fn transpose_swaps_the_axes() {
|
||
let p = Plane::from_fn(5, 3, |x, y| (y * 10 + x) as f32);
|
||
let t = p.transpose();
|
||
assert_eq!((t.width, t.height), (3, 5));
|
||
assert!((0..3).all(|y| (0..5).all(|x| t.get(y, x) == p.get(x, y))));
|
||
assert_eq!(t.transpose(), p);
|
||
// a buffer shorter than its size reads as zero where it runs out, never a panic
|
||
let short = Plane { width: 3, height: 2, data: vec![1.0; 4] };
|
||
assert_eq!(short.transpose().data, [1.0, 1.0, 1.0, 0.0, 1.0, 0.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn crop_and_rotate() {
|
||
let img = Image::<u32>::from_fn(4, 3, |x, y| (y * 10 + x) as u32);
|
||
let c = img.crop(1, 1, 2, 2);
|
||
assert_eq!(c.data, vec![11, 12, 21, 22]);
|
||
// the in-place variants equal the copying ones
|
||
for (x, y, w, h) in [(1, 1, 2, 2), (0, 0, 4, 3), (2, 0, 9, 9), (0, 2, 4, 1), (3, 2, 1, 1)] {
|
||
assert_eq!(img.clone().into_crop(x, y, w, h), img.crop(x, y, w, h));
|
||
}
|
||
for v in 1..=8 {
|
||
let o = Orientation::from_exif(v);
|
||
assert_eq!(img.clone().into_oriented(o), img.oriented(o));
|
||
}
|
||
let mut m = img.clone();
|
||
m.map_in_place(|p| p * 2 + 1);
|
||
assert_eq!(m, img.map(|p| p * 2 + 1));
|
||
let r = img.rotate_cw();
|
||
assert_eq!((r.width, r.height), (3, 4));
|
||
// top-left of rotated = bottom-left of original
|
||
assert_eq!(r.get(0, 0), 20);
|
||
assert_eq!(r.get(2, 0), 0);
|
||
assert_eq!(img.rotate_cw().rotate_cw().rotate_cw().rotate_cw(), img);
|
||
assert_eq!(img.rotate_cw().rotate_cw(), img.rotate_180());
|
||
}
|
||
|
||
#[test]
|
||
fn orientation_matches_geom_mapping() {
|
||
let img = Image::<u32>::from_fn(5, 3, |x, y| (y * 10 + x) as u32);
|
||
for v in 1..=8 {
|
||
let o = Orientation::from_exif(v);
|
||
let out = img.oriented(o);
|
||
for (sx, sy) in [(0usize, 0usize), (4, 0), (2, 1), (0, 2)] {
|
||
let (dx, dy) = o.map(sx as f64 + 0.5, sy as f64 + 0.5, 5.0, 3.0);
|
||
assert_eq!(out.get(dx as usize, dy as usize), img.get(sx, sy), "orientation {v}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn bilinear_at_centres_is_exact() {
|
||
let img = Rgb32f::from_fn(3, 3, |x, y| [x as f32, y as f32, 1.0]);
|
||
assert_eq!(img.sample_bilinear(1.5, 2.5), [1.0, 2.0, 1.0]);
|
||
let m = img.sample_bilinear(1.0, 1.5);
|
||
assert!((m[0] - 0.5).abs() < 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn srgb8_roundtrip() {
|
||
let img = Rgba8::from_fn(16, 16, |x, y| [(x * 16) as u8, (y * 16) as u8, 7, 255]);
|
||
assert_eq!(img.to_linear().to_srgb8(), img);
|
||
assert_eq!(Rgba8::from_bytes(16, 16, &img.as_bytes()).unwrap(), img);
|
||
}
|
||
|
||
#[test]
|
||
fn from_pixels_requires_exactly_the_stated_pixels() {
|
||
let img = Rgba8::from_fn(4, 3, |x, y| [x as u8, y as u8, 9, 255]);
|
||
let rebuilt = Rgba8::from_pixels(4, 3, img.as_bytes().as_chunks::<4>().0.iter().map(|p| [p[0], p[1], p[2], p[3]])).unwrap();
|
||
assert_eq!(rebuilt, img);
|
||
assert!(Rgba8::from_pixels(4, 3, vec![[0, 0, 0, 0]; 11]).is_none(), "too few pixels");
|
||
assert!(Rgba8::from_pixels(4, 3, vec![[0, 0, 0, 0]; 13]).is_none(), "too many pixels");
|
||
assert!(Rgba8::from_pixels(0, 3, std::iter::empty()).is_none());
|
||
assert!(Rgba8::from_pixels(usize::MAX, 2, std::iter::empty()).is_none());
|
||
}
|
||
}
|