// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
// One pixel, the formats it can be kept in, and the arithmetic between them.
//
// The library keeps pixels in the format the file used, because converting on
// the way in throws away precision a caller may have wanted, and converting on
// the way out costs a pass over the whole image. Everything that has to know
// what a pixel *is* — a scaler, a colour mix — reads through `Pixel`, which is
// four samples in 0–1, and writes back through the same door.
//
// Eight-bit formats hold 0–255, sixteen-bit 0–65535, and `RgbaF32` holds the
// sample as a float with no scaling at all. Alpha is straight, not premultiplied,
// unless the image says otherwise.
///|
/// How the samples of an image are laid out.
///
/// The names are the usual ones: `Gray8` is one byte per pixel, `Rgba8` four,
/// `Rgba16` eight with each channel big-endian, `RgbaF32` four floats.
pub(all) enum PixelFormat {
/// One sample, 8 bits.
Gray8
/// Grey and alpha, 8 bits each.
GrayAlpha8
/// Three samples, 8 bits each, red first.
Rgb8
/// Four samples, 8 bits each, red first, alpha last.
Rgba8
/// One sample, 16 bits, big-endian.
Gray16
/// Grey and alpha, 16 bits each.
GrayAlpha16
/// Three samples, 16 bits each, big-endian.
Rgb16
/// Four samples, 16 bits each, big-endian.
Rgba16
/// Four samples as `Float`, 0–1, little-endian.
RgbaF32
} derive(Eq, Debug)
///|
pub extend PixelFormat with Eq::{equal, not_equal}
///|
pub extend PixelFormat with Debug::{to_repr}
///|
/// The layout spelled the way the type spells it: `Rgba8`, `Gray16`.
///
/// A log line and an error message both want to say which layout was in play,
/// and neither wants a sentence about it.
pub impl Show for PixelFormat with fn output(self, logger) {
logger.write_string(
match self {
Gray8 => "Gray8"
GrayAlpha8 => "GrayAlpha8"
Rgb8 => "Rgb8"
Rgba8 => "Rgba8"
Gray16 => "Gray16"
GrayAlpha16 => "GrayAlpha16"
Rgb16 => "Rgb16"
Rgba16 => "Rgba16"
RgbaF32 => "RgbaF32"
},
)
}
///|
pub extend PixelFormat with Show::{to_string, output}
///|
/// How many samples a pixel of this format has.
pub fn PixelFormat::channels(self : PixelFormat) -> Int32 {
match self {
Gray8 | Gray16 => 1
GrayAlpha8 | GrayAlpha16 => 2
Rgb8 | Rgb16 => 3
Rgba8 | Rgba16 | RgbaF32 => 4
}
}
///|
/// How many bytes a pixel of this format takes.
pub fn PixelFormat::bytes(self : PixelFormat) -> Int32 {
match self {
Gray8 => 1
GrayAlpha8 => 2
Rgb8 => 3
Rgba8 => 4
Gray16 => 2
GrayAlpha16 => 4
Rgb16 => 6
Rgba16 => 8
RgbaF32 => 16
}
}
///|
/// How many bits one sample takes. For `RgbaF32` this is 32, which says how wide
/// the sample is, not that it is an integer.
pub fn PixelFormat::bits(self : PixelFormat) -> Int32 {
match self {
Gray8 | GrayAlpha8 | Rgb8 | Rgba8 => 8
Gray16 | GrayAlpha16 | Rgb16 | Rgba16 => 16
RgbaF32 => 32
}
}
///|
/// Whether the image carries colour at all.
pub fn PixelFormat::is_gray(self : PixelFormat) -> Bool {
match self {
Gray8 | GrayAlpha8 | Gray16 | GrayAlpha16 => true
_ => false
}
}
///|
/// Whether the image carries alpha.
pub fn PixelFormat::has_alpha(self : PixelFormat) -> Bool {
match self {
GrayAlpha8 | Rgba8 | GrayAlpha16 | Rgba16 | RgbaF32 => true
_ => false
}
}
///|
/// Whether the samples are floats rather than integers.
pub fn PixelFormat::is_float(self : PixelFormat) -> Bool {
self == RgbaF32
}
///|
/// One pixel, in the units every operation in the library works in: four
/// samples from 0 to 1, alpha straight.
///
/// A grey pixel has `r`, `g` and `b` equal, which is why there is no grey
/// variant: a reader that has to branch on whether a pixel is grey is a reader
/// that will forget to inflate one of the three channels.
pub(all) struct Pixel {
r : Double
g : Double
b : Double
a : Double
} derive(Eq, Debug)
///|
pub extend Pixel with Eq::{equal, not_equal}
///|
pub extend Pixel with Debug::{to_repr}
///|
/// A pixel from the eight-bit values an editor shows.
pub fn Pixel::of_rgba8(r : Int32, g : Int32, b : Int32, a? : Int32) -> Pixel {
{
r: byte_to_unit(r),
g: byte_to_unit(g),
b: byte_to_unit(b),
a: byte_to_unit(a.unwrap_or(255)),
}
}
///|
/// The pixel as bytes an editor shows: rounded, clamped, alpha last.
pub fn Pixel::rgba8(self : Pixel) -> (Int32, Int32, Int32, Int32) {
(
unit_to_byte(self.r),
unit_to_byte(self.g),
unit_to_byte(self.b),
unit_to_byte(self.a),
)
}
///|
/// The pixel as sixteen-bit samples, which is what a PNG stores when its bit
/// depth is sixteen.
pub fn Pixel::rgba16(self : Pixel) -> (Int32, Int32, Int32, Int32) {
(
unit_to_word(self.r),
unit_to_word(self.g),
unit_to_word(self.b),
unit_to_word(self.a),
)
}
///|
/// A sample from 0 to 1 as a byte. Rounding, not truncation: 0.5 is 128, and a
/// round trip through `Rgba8` then back is the identity for every byte.
fn unit_to_byte(x : Double) -> Int32 {
if x <= 0.0 {
0
} else if x >= 1.0 {
255
} else {
// The epsilon is there for the exact halves. A filter that averages 200 and
// 255 reaches 0.892156862745098…, whose product with 255 is a hair under
// 227.5 in binary, and without it the answer would be 227 rather than 228.
(x * 255.0 + 0.5 + 1.0e-9).to_int()
}
}
///|
fn byte_to_unit(x : Int32) -> Double {
x.to_double() / 255.0
}
///|
fn unit_to_word(x : Double) -> Int32 {
if x <= 0.0 {
0
} else if x >= 1.0 {
65535
} else {
(x * 65535.0 + 0.5 + 1.0e-9).to_int()
}
}
///|
/// The pixel stored at `offset` in `data`, in this format.
///
/// Not public: a caller reads pixels through `Image::pixel`, which checks the
/// coordinates once instead of every sample.
fn read_pixel(data : BytesView, offset : Int32, format : PixelFormat) -> Pixel {
match format {
Gray8 => {
let v = byte_to_unit(data[offset].to_int())
{ r: v, g: v, b: v, a: 1.0, }
}
GrayAlpha8 => {
let v = byte_to_unit(data[offset].to_int())
{ r: v, g: v, b: v, a: byte_to_unit(data[offset + 1].to_int()), }
}
Rgb8 =>
{
r: byte_to_unit(data[offset].to_int()),
g: byte_to_unit(data[offset + 1].to_int()),
b: byte_to_unit(data[offset + 2].to_int()),
a: 1.0,
}
Rgba8 =>
{
r: byte_to_unit(data[offset].to_int()),
g: byte_to_unit(data[offset + 1].to_int()),
b: byte_to_unit(data[offset + 2].to_int()),
a: byte_to_unit(data[offset + 3].to_int()),
}
Gray16 => {
let v = word_at(data, offset).to_double() / 65535.0
{ r: v, g: v, b: v, a: 1.0, }
}
GrayAlpha16 => {
let v = word_at(data, offset).to_double() / 65535.0
{ r: v, g: v, b: v, a: word_at(data, offset + 2).to_double() / 65535.0, }
}
Rgb16 =>
{
r: word_at(data, offset).to_double() / 65535.0,
g: word_at(data, offset + 2).to_double() / 65535.0,
b: word_at(data, offset + 4).to_double() / 65535.0,
a: 1.0,
}
Rgba16 =>
{
r: word_at(data, offset).to_double() / 65535.0,
g: word_at(data, offset + 2).to_double() / 65535.0,
b: word_at(data, offset + 4).to_double() / 65535.0,
a: word_at(data, offset + 6).to_double() / 65535.0,
}
RgbaF32 =>
{
r: float_at(data, offset).to_double(),
g: float_at(data, offset + 4).to_double(),
b: float_at(data, offset + 8).to_double(),
a: float_at(data, offset + 12).to_double(),
}
}
}
///|
/// A big-endian sixteen-bit sample.
fn word_at(data : BytesView, offset : Int32) -> Int32 {
data[offset].to_int() * 256 + data[offset + 1].to_int()
}
///|
/// A little-endian float, the layout a GPU buffer wants.
fn float_at(data : BytesView, offset : Int32) -> Float {
let bits = data[offset].to_int() +
data[offset + 1].to_int() * 256 +
data[offset + 2].to_int() * 65536 +
data[offset + 3].to_int() * 16777216
Float::reinterpret_from_uint(bits.reinterpret_as_uint())
}
///|
/// Store `pixel` at `offset` in `data`, in this format.
fn write_pixel(
data : FixedArray[Byte],
offset : Int32,
format : PixelFormat,
pixel : Pixel,
) -> Unit {
match format {
Gray8 => data[offset] = unit_to_byte(gray_of(pixel)).to_byte()
GrayAlpha8 => {
data[offset] = unit_to_byte(gray_of(pixel)).to_byte()
data[offset + 1] = unit_to_byte(pixel.a).to_byte()
}
Rgb8 => {
data[offset] = unit_to_byte(pixel.r).to_byte()
data[offset + 1] = unit_to_byte(pixel.g).to_byte()
data[offset + 2] = unit_to_byte(pixel.b).to_byte()
}
Rgba8 => {
data[offset] = unit_to_byte(pixel.r).to_byte()
data[offset + 1] = unit_to_byte(pixel.g).to_byte()
data[offset + 2] = unit_to_byte(pixel.b).to_byte()
data[offset + 3] = unit_to_byte(pixel.a).to_byte()
}
Gray16 => put_word(data, offset, unit_to_word(gray_of(pixel)))
GrayAlpha16 => {
put_word(data, offset, unit_to_word(gray_of(pixel)))
put_word(data, offset + 2, unit_to_word(pixel.a))
}
Rgb16 => {
put_word(data, offset, unit_to_word(pixel.r))
put_word(data, offset + 2, unit_to_word(pixel.g))
put_word(data, offset + 4, unit_to_word(pixel.b))
}
Rgba16 => {
put_word(data, offset, unit_to_word(pixel.r))
put_word(data, offset + 2, unit_to_word(pixel.g))
put_word(data, offset + 4, unit_to_word(pixel.b))
put_word(data, offset + 6, unit_to_word(pixel.a))
}
RgbaF32 => {
put_float(data, offset, unit_to_float(pixel.r))
put_float(data, offset + 4, unit_to_float(pixel.g))
put_float(data, offset + 8, unit_to_float(pixel.b))
put_float(data, offset + 12, unit_to_float(pixel.a))
}
}
}
///|
fn put_word(data : FixedArray[Byte], offset : Int32, value : Int32) -> Unit {
data[offset] = ((value >> 8) & 0xFF).to_byte()
data[offset + 1] = (value & 0xFF).to_byte()
}
///|
fn put_float(data : FixedArray[Byte], offset : Int32, value : Float) -> Unit {
let bits = value.reinterpret_as_uint().reinterpret_as_int()
data[offset] = (bits & 0xFF).to_byte()
data[offset + 1] = ((bits >> 8) & 0xFF).to_byte()
data[offset + 2] = ((bits >> 16) & 0xFF).to_byte()
data[offset + 3] = ((bits >> 24) & 0xFF).to_byte()
}
///|
/// The grey a colour becomes when a format has one channel.
///
/// The weights are the ones television settled on, which is what every other
/// converter uses for eight-bit sRGB, and they sum to one so that a grey pixel
/// stays the grey it was.
fn gray_of(pixel : Pixel) -> Double {
0.299 * pixel.r + 0.587 * pixel.g + 0.114 * pixel.b
}
///|
/// A sample from 0 to 1 as a float, clamped: a float image is where a filter
/// can overshoot, and a channel outside 0–1 is not a colour.
fn unit_to_float(x : Double) -> Float {
if x <= 0.0 {
0.0F
} else if x >= 1.0 {
1.0F
} else {
Float::from_double(x)
}
}
///|
/// A little-endian float in a buffer being built.
fn float_in(data : FixedArray[Byte], offset : Int32) -> Float {
let bits = data[offset].to_int() +
data[offset + 1].to_int() * 256 +
data[offset + 2].to_int() * 65536 +
data[offset + 3].to_int() * 16777216
Float::reinterpret_from_uint(bits.reinterpret_as_uint())
}