otf_pixels_core/pixel.rs
1//! Pixel formats and the sample types kernels are monomorphized over.
2//!
3//! The v1 format set is fixed by SPEC §Pixel formats. Alpha is **unassociated
4//! (straight)** at every API boundary; ops needing premultiplied alpha convert
5//! internally and convert back.
6//!
7//! # Typing model (ADR-0002)
8//!
9//! [`PixelFormat`] is a runtime value: the graph and the public API are
10//! dynamic, so `Image` carries no type parameter. Kernels are generic over
11//! [`Sample`] and are selected **once per tile** by matching on the format —
12//! see [`dispatch_sample!`]. One match per tile is noise next to a fully
13//! specialized inner loop.
14//!
15//! [`dispatch_sample!`]: crate::dispatch_sample
16
17use core::fmt;
18
19/// The numeric type of one channel sample.
20///
21/// Deliberately **exhaustive**, unlike most enums here: kernels match on it to
22/// select a monomorphization, so downstream op crates must be able to match it
23/// without a wildcard arm — see [`dispatch_sample!`]. A wildcard would silently
24/// swallow a new sample type at runtime; an exhaustive match turns adding one
25/// into the compile error it deserves to be.
26///
27/// [`dispatch_sample!`]: crate::dispatch_sample
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub enum SampleKind {
30 /// 8-bit unsigned integer, full range 0..=255.
31 U8,
32 /// 16-bit unsigned integer, native endian in memory, full range 0..=65535.
33 U16,
34 /// 32-bit IEEE-754 float, nominal range 0.0..=1.0 (used for filter math).
35 F32,
36}
37
38impl SampleKind {
39 /// Size of one sample in bytes.
40 #[must_use]
41 pub const fn size(self) -> usize {
42 match self {
43 Self::U8 => 1,
44 Self::U16 => 2,
45 Self::F32 => 4,
46 }
47 }
48}
49
50/// The channel layout of a pixel, independent of sample type.
51///
52/// Exhaustive for the same reason as [`SampleKind`]: kernels dispatch on it.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
54pub enum ChannelLayout {
55 /// Single luminance channel.
56 Gray,
57 /// Luminance plus unassociated alpha.
58 GrayAlpha,
59 /// Red, green, blue.
60 Rgb,
61 /// Red, green, blue, plus unassociated alpha.
62 Rgba,
63}
64
65impl ChannelLayout {
66 /// Number of channels in this layout.
67 #[must_use]
68 pub const fn channels(self) -> usize {
69 match self {
70 Self::Gray => 1,
71 Self::GrayAlpha => 2,
72 Self::Rgb => 3,
73 Self::Rgba => 4,
74 }
75 }
76
77 /// Whether this layout carries an alpha channel.
78 #[must_use]
79 pub const fn has_alpha(self) -> bool {
80 matches!(self, Self::GrayAlpha | Self::Rgba)
81 }
82}
83
84/// An interleaved pixel format: a [`ChannelLayout`] over a [`SampleKind`].
85///
86/// The v1 set is exactly the formats listed in SPEC §Pixel formats. Samples
87/// are interleaved (`RGBRGB…`, not planar) and, for `U16`, stored in **native
88/// endianness** in memory — codecs convert at their own boundary.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
90#[non_exhaustive]
91pub enum PixelFormat {
92 /// 8-bit grayscale.
93 Gray8,
94 /// 16-bit grayscale.
95 Gray16,
96 /// 8-bit grayscale with alpha.
97 GrayA8,
98 /// 8-bit RGB.
99 Rgb8,
100 /// 8-bit RGBA.
101 Rgba8,
102 /// 16-bit RGB.
103 Rgb16,
104 /// 16-bit RGBA.
105 Rgba16,
106 /// 32-bit float RGB.
107 RgbF32,
108 /// 32-bit float RGBA.
109 RgbaF32,
110}
111
112impl PixelFormat {
113 /// Every pixel format supported in v1.
114 pub const ALL: &'static [Self] = &[
115 Self::Gray8,
116 Self::Gray16,
117 Self::GrayA8,
118 Self::Rgb8,
119 Self::Rgba8,
120 Self::Rgb16,
121 Self::Rgba16,
122 Self::RgbF32,
123 Self::RgbaF32,
124 ];
125
126 /// The channel layout of this format.
127 #[must_use]
128 pub const fn layout(self) -> ChannelLayout {
129 match self {
130 Self::Gray8 | Self::Gray16 => ChannelLayout::Gray,
131 Self::GrayA8 => ChannelLayout::GrayAlpha,
132 Self::Rgb8 | Self::Rgb16 | Self::RgbF32 => ChannelLayout::Rgb,
133 Self::Rgba8 | Self::Rgba16 | Self::RgbaF32 => ChannelLayout::Rgba,
134 }
135 }
136
137 /// The format with `layout` channels of `kind` samples, if v1 has one
138 /// (there is no grey float, nor 16-bit grey with alpha).
139 #[must_use]
140 pub const fn from_parts(layout: ChannelLayout, kind: SampleKind) -> Option<Self> {
141 match (layout, kind) {
142 (ChannelLayout::Gray, SampleKind::U8) => Some(Self::Gray8),
143 (ChannelLayout::Gray, SampleKind::U16) => Some(Self::Gray16),
144 (ChannelLayout::GrayAlpha, SampleKind::U8) => Some(Self::GrayA8),
145 (ChannelLayout::Rgb, SampleKind::U8) => Some(Self::Rgb8),
146 (ChannelLayout::Rgb, SampleKind::U16) => Some(Self::Rgb16),
147 (ChannelLayout::Rgb, SampleKind::F32) => Some(Self::RgbF32),
148 (ChannelLayout::Rgba, SampleKind::U8) => Some(Self::Rgba8),
149 (ChannelLayout::Rgba, SampleKind::U16) => Some(Self::Rgba16),
150 (ChannelLayout::Rgba, SampleKind::F32) => Some(Self::RgbaF32),
151 _ => None,
152 }
153 }
154
155 /// The sample type of this format.
156 #[must_use]
157 pub const fn sample_kind(self) -> SampleKind {
158 match self {
159 Self::Gray8 | Self::GrayA8 | Self::Rgb8 | Self::Rgba8 => SampleKind::U8,
160 Self::Gray16 | Self::Rgb16 | Self::Rgba16 => SampleKind::U16,
161 Self::RgbF32 | Self::RgbaF32 => SampleKind::F32,
162 }
163 }
164
165 /// Number of channels per pixel.
166 #[must_use]
167 pub const fn channels(self) -> usize {
168 self.layout().channels()
169 }
170
171 /// Whether this format carries an alpha channel.
172 #[must_use]
173 pub const fn has_alpha(self) -> bool {
174 self.layout().has_alpha()
175 }
176
177 /// Size of one pixel in bytes.
178 ///
179 /// This is exact: v1 formats are byte-aligned and interleaved, so there is
180 /// no sub-byte packing to account for.
181 #[must_use]
182 pub const fn bytes_per_pixel(self) -> usize {
183 self.channels() * self.sample_kind().size()
184 }
185
186 /// A short, stable, lowercase name for this format.
187 #[must_use]
188 pub const fn as_str(self) -> &'static str {
189 match self {
190 Self::Gray8 => "gray8",
191 Self::Gray16 => "gray16",
192 Self::GrayA8 => "graya8",
193 Self::Rgb8 => "rgb8",
194 Self::Rgba8 => "rgba8",
195 Self::Rgb16 => "rgb16",
196 Self::Rgba16 => "rgba16",
197 Self::RgbF32 => "rgbf32",
198 Self::RgbaF32 => "rgbaf32",
199 }
200 }
201}
202
203impl fmt::Display for PixelFormat {
204 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205 f.write_str(self.as_str())
206 }
207}
208
209/// The color model a descriptor's samples are interpreted in.
210///
211/// Samples are sRGB once opened: an embedded ICC profile is converted on
212/// open or carried beside the pixels (SPEC §Pixel formats, ADR-0015). The
213/// enum exists so that other working spaces are an added variant rather than
214/// a breaking descriptor change.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
216#[non_exhaustive]
217pub enum ColorModel {
218 /// sRGB primaries and transfer function; the v1 assumption for all input.
219 #[default]
220 Srgb,
221}
222
223/// A channel sample type that kernels can be monomorphized over.
224///
225/// Implemented for exactly `u8`, `u16` and `f32` — the sample types of the v1
226/// [`PixelFormat`] set. This trait is the type-level half of ADR-0002: ops
227/// match on a runtime [`PixelFormat`] once per tile, then call a generic
228/// kernel that the compiler specializes.
229///
230/// This trait is sealed: implementing it for other types would let a kernel
231/// reinterpret tile bytes as a type no codec produces.
232pub trait Sample: sealed::Sealed + Copy + Send + Sync + 'static {
233 /// The [`SampleKind`] this type corresponds to.
234 const KIND: SampleKind;
235 /// The value representing full intensity: opaque alpha, white luminance.
236 ///
237 /// This is **not** the type's maximum representable value. For `f32` the
238 /// nominal range is `0.0..=1.0`, so `FULL_SCALE` is `1.0` while
239 /// [`f32::MAX`] is about `3.4e38`.
240 ///
241 /// It is named `FULL_SCALE` rather than `MAX` precisely because of that
242 /// gap: `u8`, `u16` and `f32` all have an *inherent* `MAX` constant, and
243 /// inherent constants win name resolution over trait ones. A kernel
244 /// generic over `S: Sample` writing `S::MAX` would therefore compile,
245 /// resolve to the inherent constant, and be silently wrong for floats.
246 const FULL_SCALE: Self;
247 /// The value representing zero intensity.
248 const ZERO: Self;
249}
250
251impl Sample for u8 {
252 const KIND: SampleKind = SampleKind::U8;
253 const FULL_SCALE: Self = u8::MAX;
254 const ZERO: Self = 0;
255}
256
257impl Sample for u16 {
258 const KIND: SampleKind = SampleKind::U16;
259 const FULL_SCALE: Self = u16::MAX;
260 const ZERO: Self = 0;
261}
262
263impl Sample for f32 {
264 const KIND: SampleKind = SampleKind::F32;
265 const FULL_SCALE: Self = 1.0;
266 const ZERO: Self = 0.0;
267}
268
269mod sealed {
270 /// Prevents downstream implementations of [`super::Sample`].
271 pub trait Sealed {}
272 impl Sealed for u8 {}
273 impl Sealed for u16 {}
274 impl Sealed for f32 {}
275}
276
277/// Dispatch once on a [`SampleKind`] into a kernel generic over [`Sample`].
278///
279/// This is the per-tile dispatch of ADR-0002: the match runs once, the body is
280/// instantiated once per sample type, and the inner loop is fully specialized.
281///
282/// The macro binds a type alias (conventionally `S`) inside the body:
283///
284/// ```
285/// use otf_pixels_core::{dispatch_sample, PixelFormat, Sample};
286///
287/// fn full_scale_as_f64(format: PixelFormat) -> f64 {
288/// dispatch_sample!(format.sample_kind(), S => {
289/// // `S` is a concrete type here: u8, u16, or f32.
290/// fn widen<T: Sample + Into<f64>>(v: T) -> f64 { v.into() }
291/// widen(<S as Sample>::FULL_SCALE)
292/// })
293/// }
294///
295/// assert_eq!(full_scale_as_f64(PixelFormat::Rgb8), 255.0);
296/// assert_eq!(full_scale_as_f64(PixelFormat::Rgb16), 65535.0);
297/// assert_eq!(full_scale_as_f64(PixelFormat::RgbF32), 1.0);
298/// ```
299#[macro_export]
300macro_rules! dispatch_sample {
301 ($kind:expr, $ty:ident => $body:block) => {
302 match $kind {
303 $crate::SampleKind::U8 => {
304 type $ty = u8;
305 $body
306 }
307 $crate::SampleKind::U16 => {
308 type $ty = u16;
309 $body
310 }
311 $crate::SampleKind::F32 => {
312 type $ty = f32;
313 $body
314 }
315 }
316 };
317}
318
319#[cfg(test)]
320#[allow(
321 clippy::unwrap_used,
322 clippy::indexing_slicing,
323 reason = "tests operate on known-good values and assert shapes directly"
324)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn bytes_per_pixel_matches_layout_times_sample_size() {
330 for &format in PixelFormat::ALL {
331 let expected = format.channels() * format.sample_kind().size();
332 assert_eq!(format.bytes_per_pixel(), expected, "{format}");
333 assert!(format.bytes_per_pixel() > 0, "{format}");
334 }
335 }
336
337 #[test]
338 fn v1_format_set_has_expected_sizes() {
339 assert_eq!(PixelFormat::Gray8.bytes_per_pixel(), 1);
340 assert_eq!(PixelFormat::Gray16.bytes_per_pixel(), 2);
341 assert_eq!(PixelFormat::GrayA8.bytes_per_pixel(), 2);
342 assert_eq!(PixelFormat::Rgb8.bytes_per_pixel(), 3);
343 assert_eq!(PixelFormat::Rgba8.bytes_per_pixel(), 4);
344 assert_eq!(PixelFormat::Rgb16.bytes_per_pixel(), 6);
345 assert_eq!(PixelFormat::Rgba16.bytes_per_pixel(), 8);
346 assert_eq!(PixelFormat::RgbF32.bytes_per_pixel(), 12);
347 assert_eq!(PixelFormat::RgbaF32.bytes_per_pixel(), 16);
348 }
349
350 #[test]
351 fn alpha_layouts_are_reported_consistently() {
352 for &format in PixelFormat::ALL {
353 assert_eq!(format.has_alpha(), format.layout().has_alpha(), "{format}");
354 }
355 assert!(PixelFormat::Rgba8.has_alpha());
356 assert!(PixelFormat::GrayA8.has_alpha());
357 assert!(!PixelFormat::Rgb8.has_alpha());
358 assert!(!PixelFormat::Gray8.has_alpha());
359 }
360
361 #[test]
362 fn format_names_are_unique_and_stable() {
363 let mut seen = std::collections::HashSet::new();
364 for &format in PixelFormat::ALL {
365 assert!(seen.insert(format.as_str()), "duplicate name {format}");
366 }
367 assert_eq!(PixelFormat::Rgba8.as_str(), "rgba8");
368 assert_eq!(
369 PixelFormat::ALL.len(),
370 9,
371 "SPEC §Pixel formats lists 9 v1 formats"
372 );
373 }
374
375 #[test]
376 fn dispatch_selects_the_matching_sample_type() {
377 fn sample_size(format: PixelFormat) -> usize {
378 dispatch_sample!(format.sample_kind(), S => { size_of::<S>() })
379 }
380 for &format in PixelFormat::ALL {
381 assert_eq!(sample_size(format), format.sample_kind().size(), "{format}");
382 }
383 }
384
385 #[test]
386 fn sample_constants_match_their_kind() {
387 assert_eq!(<u8 as Sample>::KIND, SampleKind::U8);
388 assert_eq!(<u16 as Sample>::KIND, SampleKind::U16);
389 assert_eq!(<f32 as Sample>::KIND, SampleKind::F32);
390 assert_eq!(<u8 as Sample>::FULL_SCALE, 255);
391 assert_eq!(<u16 as Sample>::FULL_SCALE, 65535);
392 assert_eq!(<u8 as Sample>::ZERO, 0);
393 }
394
395 #[test]
396 fn full_scale_is_the_nominal_range_not_the_type_maximum() {
397 // The whole reason the constant is not called `MAX`: for floats the
398 // nominal full-intensity value and the type's maximum differ wildly,
399 // and an inherent `MAX` would shadow a trait one in generic code.
400 assert!((<f32 as Sample>::FULL_SCALE - 1.0).abs() < f32::EPSILON);
401 let (full_scale, type_max) = (<f32 as Sample>::FULL_SCALE, f32::MAX);
402 assert!(
403 full_scale < type_max,
404 "{full_scale} should be far below {type_max}"
405 );
406 // For the integer formats the two coincide, which is what makes the
407 // shadowing bug invisible until a float kernel is written.
408 assert_eq!(<u8 as Sample>::FULL_SCALE, u8::MAX);
409 }
410}