use std::sync::Arc;
use otf_pixels_core::{
ChannelLayout, ImageDescriptor, Op, PixelFormat, PixelsError, Region, Result, Tile, TileMut,
};
use crate::convert::{from_rgba, to_rgba};
fn luma([r, g, b, _]: [f32; 4]) -> f32 {
0.299 * r + 0.587 * g + 0.114 * b
}
const fn with_colour(format: PixelFormat) -> PixelFormat {
match format {
PixelFormat::Gray8 => PixelFormat::Rgb8,
PixelFormat::GrayA8 => PixelFormat::Rgba8,
PixelFormat::Gray16 => PixelFormat::Rgb16,
other => other,
}
}
trait PixelMap: Send + Sync + std::fmt::Debug {
fn name(&self) -> &'static str;
fn output(&self, input: PixelFormat) -> Result<PixelFormat> {
Ok(input)
}
fn map(&self, rgba: [f32; 4]) -> [f32; 4];
}
fn compute<M: PixelMap + ?Sized>(
map: &M,
inputs: &[Tile<'_>],
output: &mut TileMut<'_>,
) -> Result<()> {
let input = inputs
.first()
.ok_or_else(|| PixelsError::graph(format!("`{}` takes one input tile", map.name())))?;
let (from, to) = (input.pixel(), output.pixel());
let (in_bytes, out_bytes) = (from.bytes_per_pixel(), to.bytes_per_pixel());
let region = output.region();
for y in region.y..region.y + region.height {
let (Some(source), Some(target)) = (input.row(y), output.row_mut(y)) else {
continue;
};
for (pixel, slot) in source
.chunks_exact(in_bytes)
.zip(target.chunks_exact_mut(out_bytes))
{
from_rgba(map.map(to_rgba(pixel, from)), slot, to);
}
}
Ok(())
}
macro_rules! pointwise_op {
($type:ty) => {
impl Op for $type {
fn name(&self) -> &'static str {
PixelMap::name(self)
}
fn rescaled(&self) -> Option<Arc<dyn Op>> {
Some(Arc::new(self.clone()))
}
fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
let [input] = inputs else {
return Err(PixelsError::graph(format!(
"`{}` takes exactly one input",
PixelMap::name(self)
)));
};
let mut out = *input;
out.pixel = self.output(input.pixel)?;
Ok(out)
}
fn input_regions(
&self,
output: Region,
_inputs: &[ImageDescriptor],
) -> Result<Vec<Region>> {
Ok(vec![output])
}
fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
compute(self, inputs, output)
}
}
};
}
fn finite(name: &'static str, value: f32) -> Result<f32> {
if value.is_finite() {
Ok(value)
} else {
Err(PixelsError::invalid_argument(
name,
format!("must be finite, got {value}"),
))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Negate {
alpha: bool,
}
impl Negate {
#[must_use]
pub const fn new(alpha: bool) -> Self {
Self { alpha }
}
}
impl PixelMap for Negate {
fn name(&self) -> &'static str {
"negate"
}
fn map(&self, [r, g, b, a]: [f32; 4]) -> [f32; 4] {
[
1.0 - r,
1.0 - g,
1.0 - b,
if self.alpha { 1.0 - a } else { a },
]
}
}
pointwise_op!(Negate);
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Gamma {
exponent: f32,
}
impl Gamma {
pub fn new(exponent: f32) -> Result<Self> {
if finite("exponent", exponent)? <= 0.0 {
return Err(PixelsError::invalid_argument(
"exponent",
format!("must be positive, got {exponent}"),
));
}
Ok(Self { exponent })
}
}
impl PixelMap for Gamma {
fn name(&self) -> &'static str {
"gamma"
}
fn map(&self, [r, g, b, a]: [f32; 4]) -> [f32; 4] {
let power = 1.0 / self.exponent;
let apply = |v: f32| v.max(0.0).powf(power);
[apply(r), apply(g), apply(b), a]
}
}
pointwise_op!(Gamma);
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Linear {
multiply: [f32; 3],
add: [f32; 3],
}
impl Linear {
pub fn new(a: f32, b: f32) -> Result<Self> {
Self::per_channel([a; 3], [b; 3])
}
pub fn per_channel(a: [f32; 3], b: [f32; 3]) -> Result<Self> {
for value in a {
finite("a", value)?;
}
for value in b {
finite("b", value)?;
}
Ok(Self {
multiply: a,
add: b.map(|v| v / 255.0),
})
}
}
impl PixelMap for Linear {
fn name(&self) -> &'static str {
"linear"
}
fn map(&self, [r, g, b, a]: [f32; 4]) -> [f32; 4] {
let [mr, mg, mb] = self.multiply;
let [ar, ag, ab] = self.add;
[r * mr + ar, g * mg + ag, b * mb + ab, a]
}
}
pointwise_op!(Linear);
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Threshold {
level: f32,
greyscale: bool,
}
impl Threshold {
#[must_use]
pub fn new(level: u8, greyscale: bool) -> Self {
Self {
level: f32::from(level) / 255.0,
greyscale,
}
}
}
impl PixelMap for Threshold {
fn name(&self) -> &'static str {
"threshold"
}
fn map(&self, rgba: [f32; 4]) -> [f32; 4] {
let step = |v: f32| {
if (v * 255.0).round() >= (self.level * 255.0).round() {
1.0
} else {
0.0
}
};
let [r, g, b, a] = rgba;
if self.greyscale {
let value = step(luma(rgba));
[value, value, value, a]
} else {
[step(r), step(g), step(b), a]
}
}
}
pointwise_op!(Threshold);
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Tint {
chroma: [f32; 2],
}
impl Tint {
#[must_use]
pub fn new(red: u8, green: u8, blue: u8) -> Self {
let rgba = [red, green, blue].map(|v| f32::from(v) / 255.0);
let [r, g, b] = rgba;
let y = luma([r, g, b, 1.0]);
Self {
chroma: [(b - y) * 0.564, (r - y) * 0.713],
}
}
}
impl PixelMap for Tint {
fn name(&self) -> &'static str {
"tint"
}
fn output(&self, input: PixelFormat) -> Result<PixelFormat> {
Ok(with_colour(input))
}
fn map(&self, rgba: [f32; 4]) -> [f32; 4] {
let y = luma(rgba);
let [cb, cr] = self.chroma;
[
y + 1.403 * cr,
y - 0.344 * cb - 0.714 * cr,
y + 1.773 * cb,
rgba[3],
]
}
}
pointwise_op!(Tint);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Greyscale;
impl PixelMap for Greyscale {
fn name(&self) -> &'static str {
"greyscale"
}
fn map(&self, rgba: [f32; 4]) -> [f32; 4] {
let y = luma(rgba);
[y, y, y, rgba[3]]
}
}
pointwise_op!(Greyscale);
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Recomb {
matrix: [[f32; 4]; 4],
alpha: bool,
}
impl Recomb {
pub fn rgb(matrix: [[f32; 3]; 3]) -> Result<Self> {
let mut full = [[0.0; 4]; 4];
for (row, from) in full.iter_mut().zip(matrix) {
for (slot, value) in row.iter_mut().zip(from) {
*slot = finite("matrix", value)?;
}
}
full[3][3] = 1.0;
Ok(Self {
matrix: full,
alpha: false,
})
}
pub fn rgba(matrix: [[f32; 4]; 4]) -> Result<Self> {
for value in matrix.iter().flatten() {
finite("matrix", *value)?;
}
Ok(Self {
matrix,
alpha: true,
})
}
}
impl PixelMap for Recomb {
fn name(&self) -> &'static str {
"recomb"
}
fn output(&self, input: PixelFormat) -> Result<PixelFormat> {
let colour = with_colour(input);
Ok(if self.alpha {
with_alpha(colour)
} else {
colour
})
}
fn map(&self, rgba: [f32; 4]) -> [f32; 4] {
self.matrix.map(|row| {
row.iter()
.zip(rgba)
.map(|(weight, value)| weight * value)
.sum()
})
}
}
pointwise_op!(Recomb);
const fn with_alpha(format: PixelFormat) -> PixelFormat {
match format {
PixelFormat::Gray8 => PixelFormat::GrayA8,
PixelFormat::Rgb8 => PixelFormat::Rgba8,
PixelFormat::Rgb16 => PixelFormat::Rgba16,
PixelFormat::RgbF32 => PixelFormat::RgbaF32,
PixelFormat::Gray16 => PixelFormat::Rgba16,
other => other,
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EnsureAlpha {
level: f32,
}
impl EnsureAlpha {
pub fn new(level: f32) -> Result<Self> {
if !(0.0..=1.0).contains(&finite("level", level)?) {
return Err(PixelsError::invalid_argument(
"level",
format!("must be between 0 and 1, got {level}"),
));
}
Ok(Self { level })
}
}
impl PixelMap for EnsureAlpha {
fn name(&self) -> &'static str {
"ensure_alpha"
}
fn output(&self, input: PixelFormat) -> Result<PixelFormat> {
Ok(with_alpha(input))
}
fn map(&self, rgba: [f32; 4]) -> [f32; 4] {
rgba
}
}
impl Op for EnsureAlpha {
fn name(&self) -> &'static str {
PixelMap::name(self)
}
fn rescaled(&self) -> Option<Arc<dyn Op>> {
Some(Arc::new(*self))
}
fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
let [input] = inputs else {
return Err(PixelsError::graph("`ensure_alpha` takes exactly one input"));
};
let mut out = *input;
out.pixel = self.output(input.pixel)?;
Ok(out)
}
fn input_regions(&self, output: Region, _inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
Ok(vec![output])
}
fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
let had_alpha = inputs.first().is_some_and(|tile| {
matches!(
tile.pixel().layout(),
ChannelLayout::GrayAlpha | ChannelLayout::Rgba
)
});
if had_alpha {
return compute(self, inputs, output);
}
let level = self.level;
compute(&WithLevel(level), inputs, output)
}
}
#[derive(Debug)]
struct WithLevel(f32);
impl PixelMap for WithLevel {
fn name(&self) -> &'static str {
"ensure_alpha"
}
fn map(&self, [r, g, b, _]: [f32; 4]) -> [f32; 4] {
[r, g, b, self.0]
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::indexing_slicing,
reason = "tests operate on known-good values and assert shapes directly"
)]
mod tests {
use super::*;
use otf_pixels_core::TileBuf;
fn run(op: &dyn Op, format: PixelFormat, width: u32, bytes: &[u8]) -> (PixelFormat, Vec<u8>) {
let height = (bytes.len() / format.bytes_per_pixel()) as u32 / width;
let input = ImageDescriptor::new(width, height, format).unwrap();
let out = op.output_descriptor(&[input]).unwrap();
let source = TileBuf::from_vec(input.region(), format, bytes.to_vec()).unwrap();
let mut target = TileBuf::for_image(&out).unwrap();
op.compute(
&[source.as_tile().unwrap()],
&mut target.as_tile_mut().unwrap(),
)
.unwrap();
(out.pixel, target.into_bytes())
}
#[test]
fn negate_inverts_colour_and_optionally_alpha() {
let (_, out) = run(
&Negate::new(true),
PixelFormat::Rgba8,
1,
&[0, 100, 255, 40],
);
assert_eq!(out, [255, 155, 0, 215]);
let (_, out) = run(
&Negate::new(false),
PixelFormat::Rgba8,
1,
&[0, 100, 255, 40],
);
assert_eq!(out, [255, 155, 0, 40]);
let (_, out) = run(
&Negate::new(true),
PixelFormat::Gray16,
1,
&1000_u16.to_ne_bytes(),
);
assert_eq!(u16::from_ne_bytes([out[0], out[1]]), 64535);
}
#[test]
fn gamma_brightens_above_one_and_leaves_the_ends_fixed() {
let op = Gamma::new(2.0).unwrap();
let (_, out) = run(&op, PixelFormat::Rgb8, 1, &[0, 64, 255]);
assert_eq!(out, [0, 128, 255]);
assert!(Gamma::new(0.0).is_err());
assert!(Gamma::new(f32::NAN).is_err());
}
#[test]
fn linear_multiplies_and_offsets_in_8_bit_units_and_clamps() {
let op = Linear::per_channel([2.0, 1.0, 0.5], [10.0, -20.0, 0.0]).unwrap();
let (_, out) = run(&op, PixelFormat::Rgba8, 1, &[100, 10, 200, 77]);
assert_eq!(out, [210, 0, 100, 77]);
let (_, out) = run(
&Linear::new(1.0, 255.0).unwrap(),
PixelFormat::Gray8,
1,
&[3],
);
assert_eq!(out, [255]);
}
#[test]
fn threshold_uses_luma_or_each_channel() {
let pixel = [200, 50, 50];
let (_, out) = run(&Threshold::new(128, true), PixelFormat::Rgb8, 1, &pixel);
assert_eq!(out, [0, 0, 0]);
let (_, out) = run(&Threshold::new(128, false), PixelFormat::Rgb8, 1, &pixel);
assert_eq!(out, [255, 0, 0]);
let (_, out) = run(
&Threshold::new(128, false),
PixelFormat::Gray8,
2,
&[127, 128],
);
assert_eq!(out, [0, 255]);
let half = 0x8080_u16.to_ne_bytes();
let (_, out) = run(&Threshold::new(128, false), PixelFormat::Gray16, 1, &half);
assert_eq!(out, [255, 255]);
}
#[test]
fn greyscale_keeps_three_equal_channels_and_alpha() {
let (format, out) = run(&Greyscale, PixelFormat::Rgba8, 1, &[200, 50, 50, 9]);
assert_eq!(format, PixelFormat::Rgba8);
assert_eq!(out, [95, 95, 95, 9]);
}
#[test]
fn tint_keeps_luma_and_takes_the_tints_hue() {
let (format, out) = run(&Tint::new(200, 100, 100), PixelFormat::Gray8, 1, &[128]);
assert_eq!(format, PixelFormat::Rgb8);
let [r, g, b] = [out[0], out[1], out[2]];
assert!(r > g && r > b, "a red tint should be red: {out:?}");
let y = 0.299 * f32::from(r) + 0.587 * f32::from(g) + 0.114 * f32::from(b);
assert!((y - 128.0).abs() <= 2.0, "luma moved to {y}");
let (_, out) = run(&Tint::new(90, 90, 90), PixelFormat::Rgb8, 1, &[70, 70, 70]);
assert_eq!(out, [70, 70, 70]);
}
#[test]
fn recomb_applies_the_matrix() {
let swap = Recomb::rgb([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]]).unwrap();
let (_, out) = run(&swap, PixelFormat::Rgba8, 1, &[10, 20, 30, 40]);
assert_eq!(out, [30, 20, 10, 40]);
let alpha_from_red = Recomb::rgba([
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[1.0, 0.0, 0.0, 0.0],
])
.unwrap();
let (format, out) = run(&alpha_from_red, PixelFormat::Rgb8, 1, &[10, 20, 30]);
assert_eq!(format, PixelFormat::Rgba8);
assert_eq!(out, [10, 20, 30, 10]);
assert!(Recomb::rgb([[f32::INFINITY; 3]; 3]).is_err());
}
#[test]
fn ensure_alpha_adds_a_level_only_when_missing() {
let op = EnsureAlpha::new(0.5).unwrap();
let (format, out) = run(&op, PixelFormat::Rgb8, 1, &[1, 2, 3]);
assert_eq!(format, PixelFormat::Rgba8);
assert_eq!(out, [1, 2, 3, 128]);
let (format, out) = run(&op, PixelFormat::Rgba8, 1, &[1, 2, 3, 4]);
assert_eq!(format, PixelFormat::Rgba8);
assert_eq!(out, [1, 2, 3, 4]);
let (format, _) = run(&op, PixelFormat::Gray16, 1, &[0, 0]);
assert_eq!(format, PixelFormat::Rgba16);
assert!(EnsureAlpha::new(1.5).is_err());
}
}