zenpixels/policy.rs
1//! Conversion policy types for explicit control over lossy operations.
2//!
3//! All lossy pixel format conversions (alpha removal, depth reduction, etc.)
4//! require an explicit policy choice — there are no silent defaults.
5
6/// How to expand grayscale channels to RGB.
7///
8/// Used when converting from a grayscale layout to an RGB-family layout.
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10#[non_exhaustive]
11pub enum GrayExpand {
12 /// Channel broadcast: `v → (v, v, v)`. Lossless.
13 Broadcast,
14}
15
16/// Policy for alpha channel removal. Required when converting
17/// from a layout with alpha to one without.
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
19#[non_exhaustive]
20pub enum AlphaPolicy {
21 /// Discard only if every pixel is fully opaque. Returns error otherwise.
22 DiscardIfOpaque,
23 /// Discard unconditionally. Caller acknowledges data loss.
24 DiscardUnchecked,
25 /// Composite onto solid background (values in source range, 0–255 for U8).
26 CompositeOnto {
27 /// Red background value.
28 r: u8,
29 /// Green background value.
30 g: u8,
31 /// Blue background value.
32 b: u8,
33 },
34 /// Return error rather than dropping alpha.
35 Forbid,
36}
37
38/// Policy for bit depth reduction (U16→U8, etc.).
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
40#[non_exhaustive]
41pub enum DepthPolicy {
42 /// Round to nearest value.
43 Round,
44 /// Truncate (floor). Faster, biased toward lower values.
45 Truncate,
46 /// Return error rather than reducing depth.
47 Forbid,
48}
49
50/// Luma coefficients for RGB→Gray conversion.
51///
52/// Use [`coefficients()`](Self::coefficients) to get the concrete `[f32; 3]`
53/// weights for each variant.
54#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
55#[non_exhaustive]
56pub enum LumaCoefficients {
57 /// BT.709: `0.2126R + 0.7152G + 0.0722B` (HDTV, sRGB).
58 Bt709,
59 /// BT.601: `0.299R + 0.587G + 0.114B` (SDTV, JPEG).
60 Bt601,
61 /// BT.2020 / BT.2100: `0.2627R + 0.6780G + 0.0593B` (UHDTV, wide gamut).
62 ///
63 /// BT.2100 (HDR) uses the same primaries as BT.2020, so this is the
64 /// variant to use for both SDR BT.2020 and HDR BT.2100 content.
65 Bt2020,
66 /// Display P3 (DCI-P3 primaries + D65): `0.2289746R + 0.6917385G + 0.0792869B`
67 /// (Apple wide-gamut consumer displays).
68 ///
69 /// Unlike BT.709 and BT.2020, no ITU recommendation prescribes these
70 /// weights — they are derived as the middle (Y) row of the
71 /// DisplayP3→XYZ matrix from the P3 primaries and D65 white point, and
72 /// match what libultrahdr and other HDR tooling use in practice for
73 /// RGB→luma on DisplayP3 content.
74 DisplayP3,
75}
76
77impl LumaCoefficients {
78 /// Return the `[R, G, B]` weights for this luma recipe.
79 ///
80 /// - [`Bt709`](Self::Bt709): `[0.2126, 0.7152, 0.0722]`
81 /// - [`Bt601`](Self::Bt601): `[0.299, 0.587, 0.114]`
82 /// - [`Bt2020`](Self::Bt2020): `[0.2627, 0.6780, 0.0593]` (same as BT.2100)
83 /// - [`DisplayP3`](Self::DisplayP3): `[0.2289746, 0.6917385, 0.0792869]`
84 ///
85 /// The three weights always sum to exactly 1.0 in IEEE 754 double
86 /// precision, but may sum to 1.0 ± 1 ULP in `f32`. Callers that need
87 /// the weights to sum to exactly 1.0 in f32 should use double-precision
88 /// accumulation in their inner loop.
89 #[inline]
90 pub const fn coefficients(self) -> [f32; 3] {
91 match self {
92 Self::Bt709 => [0.2126, 0.7152, 0.0722],
93 Self::Bt601 => [0.299, 0.587, 0.114],
94 Self::Bt2020 => [0.2627, 0.6780, 0.0593],
95 Self::DisplayP3 => [0.2289746, 0.6917385, 0.0792869],
96 }
97 }
98}
99
100/// Explicit options for pixel format conversion. All lossy
101/// operations require a policy choice — no silent defaults.
102///
103/// Construct via struct literal for full control, or use the convenience
104/// constructors and `with_*` builders for common patterns:
105///
106/// ```
107/// use zenpixels::{ConvertOptions, AlphaPolicy, DepthPolicy};
108///
109/// // Forbid all lossy operations (safe default)
110/// let strict = ConvertOptions::forbid_lossy();
111///
112/// // Allow common lossy operations with sensible defaults
113/// let permissive = ConvertOptions::permissive();
114///
115/// // Customize from a preset
116/// let custom = ConvertOptions::permissive()
117/// .with_alpha_policy(AlphaPolicy::CompositeOnto { r: 255, g: 255, b: 255 })
118/// .with_depth_policy(DepthPolicy::Truncate);
119/// ```
120#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
121#[non_exhaustive]
122pub struct ConvertOptions {
123 /// How to expand grayscale to RGB.
124 pub gray_expand: GrayExpand,
125 /// How to handle alpha removal.
126 pub alpha_policy: AlphaPolicy,
127 /// How to handle depth reduction.
128 pub depth_policy: DepthPolicy,
129 /// Luma coefficients for RGB→Gray conversion. `None` means
130 /// RGB→Gray is forbidden (returns `ConvertError::RgbToGray`).
131 pub luma: Option<LumaCoefficients>,
132 /// Whether to clamp out-of-gamut values to [0, 1] during f32 transfer
133 /// function conversions.
134 ///
135 /// - `true` (default): clamp sRGB/BT.709/PQ/HLG transfers to [0, 1].
136 /// Matches display expectations; safe for standard workflows.
137 /// - `false`: use sign-preserving extended-range transfer functions.
138 /// Preserves out-of-gamut (negative, > 1.0) values through the f32
139 /// pipeline for HDR and wide-gamut workflows where tone mapping or
140 /// gamut mapping happens later in the pipeline.
141 ///
142 /// Only affects f32 intermediate conversions. u8/u16 outputs always
143 /// clip since those formats can't represent out-of-gamut values.
144 pub clip_out_of_gamut: bool,
145}
146
147impl ConvertOptions {
148 /// Forbid all lossy operations.
149 ///
150 /// - Alpha removal: forbidden (returns error)
151 /// - Depth reduction: forbidden (returns error)
152 /// - RGB→Gray: forbidden (returns error)
153 /// - Gray→RGB: broadcast (lossless)
154 ///
155 /// Use this as a starting point when you want to ensure no data loss,
156 /// then selectively relax with `with_*` methods.
157 pub const fn forbid_lossy() -> Self {
158 Self {
159 gray_expand: GrayExpand::Broadcast,
160 alpha_policy: AlphaPolicy::Forbid,
161 depth_policy: DepthPolicy::Forbid,
162 luma: None,
163 clip_out_of_gamut: true,
164 }
165 }
166
167 /// Allow common lossy operations with sensible defaults.
168 ///
169 /// - Alpha removal: discard only if all pixels are opaque
170 /// - Depth reduction: round to nearest
171 /// - RGB→Gray: BT.709 luma coefficients
172 /// - Gray→RGB: broadcast (lossless)
173 pub const fn permissive() -> Self {
174 Self {
175 gray_expand: GrayExpand::Broadcast,
176 alpha_policy: AlphaPolicy::DiscardIfOpaque,
177 depth_policy: DepthPolicy::Round,
178 luma: Some(LumaCoefficients::Bt709),
179 clip_out_of_gamut: true,
180 }
181 }
182
183 /// Set the alpha removal policy.
184 pub const fn with_alpha_policy(mut self, policy: AlphaPolicy) -> Self {
185 self.alpha_policy = policy;
186 self
187 }
188
189 /// Set the depth reduction policy.
190 pub const fn with_depth_policy(mut self, policy: DepthPolicy) -> Self {
191 self.depth_policy = policy;
192 self
193 }
194
195 /// Set the grayscale expansion method.
196 pub const fn with_gray_expand(mut self, expand: GrayExpand) -> Self {
197 self.gray_expand = expand;
198 self
199 }
200
201 /// Set whether f32 transfer conversions clamp to [0, 1] (`true`, default)
202 /// or preserve extended-range values via sign-preserving transfers
203 /// (`false`). u8/u16 outputs always clip.
204 pub const fn with_clip_out_of_gamut(mut self, clip: bool) -> Self {
205 self.clip_out_of_gamut = clip;
206 self
207 }
208
209 /// Set the luma coefficients for RGB→Gray conversion.
210 ///
211 /// Pass `None` to forbid RGB→Gray conversion.
212 pub const fn with_luma(mut self, luma: Option<LumaCoefficients>) -> Self {
213 self.luma = luma;
214 self
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use alloc::format;
222
223 #[test]
224 fn gray_expand_derive_traits() {
225 let a = GrayExpand::Broadcast;
226 let b = a;
227 #[allow(clippy::clone_on_copy)]
228 let c = a.clone();
229 assert_eq!(a, b);
230 assert_eq!(a, c);
231 let _ = format!("{a:?}");
232 }
233
234 #[test]
235 fn alpha_policy_variants() {
236 let discard = AlphaPolicy::DiscardIfOpaque;
237 let unchecked = AlphaPolicy::DiscardUnchecked;
238 let composite = AlphaPolicy::CompositeOnto {
239 r: 255,
240 g: 255,
241 b: 255,
242 };
243 let forbid = AlphaPolicy::Forbid;
244
245 assert_ne!(discard, unchecked);
246 assert_ne!(composite, forbid);
247
248 let composite2 = AlphaPolicy::CompositeOnto {
249 r: 255,
250 g: 255,
251 b: 255,
252 };
253 assert_eq!(composite, composite2);
254
255 let composite_diff = AlphaPolicy::CompositeOnto { r: 0, g: 0, b: 0 };
256 assert_ne!(composite, composite_diff);
257 }
258
259 #[test]
260 fn depth_policy_variants() {
261 assert_ne!(DepthPolicy::Round, DepthPolicy::Truncate);
262 assert_ne!(DepthPolicy::Round, DepthPolicy::Forbid);
263 let a = DepthPolicy::Truncate;
264 #[allow(clippy::clone_on_copy)]
265 let b = a.clone();
266 assert_eq!(a, b);
267 }
268
269 #[test]
270 fn luma_coefficients_variants() {
271 assert_ne!(LumaCoefficients::Bt709, LumaCoefficients::Bt601);
272 assert_ne!(LumaCoefficients::Bt709, LumaCoefficients::Bt2020);
273 assert_ne!(LumaCoefficients::Bt601, LumaCoefficients::Bt2020);
274 assert_ne!(LumaCoefficients::Bt709, LumaCoefficients::DisplayP3);
275 assert_ne!(LumaCoefficients::Bt601, LumaCoefficients::DisplayP3);
276 assert_ne!(LumaCoefficients::Bt2020, LumaCoefficients::DisplayP3);
277 let a = LumaCoefficients::Bt709;
278 let b = a;
279 assert_eq!(a, b);
280 }
281
282 #[test]
283 fn luma_coefficients_accessor_values() {
284 // Exact bit patterns of documented coefficients — any numeric drift
285 // in the enum is a behavior break for downstream RGB→Gray kernels.
286 assert_eq!(
287 LumaCoefficients::Bt709.coefficients(),
288 [0.2126_f32, 0.7152, 0.0722],
289 );
290 assert_eq!(
291 LumaCoefficients::Bt601.coefficients(),
292 [0.299_f32, 0.587, 0.114],
293 );
294 assert_eq!(
295 LumaCoefficients::Bt2020.coefficients(),
296 [0.2627_f32, 0.6780, 0.0593],
297 );
298 assert_eq!(
299 LumaCoefficients::DisplayP3.coefficients(),
300 [0.2289746_f32, 0.6917385, 0.0792869],
301 );
302 }
303
304 #[test]
305 fn luma_coefficients_sum_to_near_unity() {
306 // All three recipes are constructed from spec chromaticities and
307 // must normalize to white = 1.0. Allow 1 f32 ULP of slack for
308 // BT.2020 (its double-precision sum is exactly 1.0 but single
309 // precision may round).
310 for luma in [
311 LumaCoefficients::Bt709,
312 LumaCoefficients::Bt601,
313 LumaCoefficients::Bt2020,
314 LumaCoefficients::DisplayP3,
315 ] {
316 let [r, g, b] = luma.coefficients();
317 let sum = r + g + b;
318 assert!(
319 (sum - 1.0).abs() < 1e-6,
320 "{luma:?} coefficients sum to {sum}, expected ~1.0"
321 );
322 }
323 }
324
325 #[test]
326 fn convert_options_derive_traits() {
327 let opts = ConvertOptions {
328 gray_expand: GrayExpand::Broadcast,
329 alpha_policy: AlphaPolicy::DiscardUnchecked,
330 depth_policy: DepthPolicy::Round,
331 luma: Some(LumaCoefficients::Bt709),
332 clip_out_of_gamut: true,
333 };
334 #[allow(clippy::clone_on_copy)]
335 let opts2 = opts.clone();
336 assert_eq!(opts, opts2);
337 let _ = format!("{opts:?}");
338 }
339
340 #[test]
341 #[cfg(feature = "std")]
342 fn alpha_policy_hash() {
343 use core::hash::{Hash, Hasher};
344 let mut h1 = std::hash::DefaultHasher::new();
345 AlphaPolicy::Forbid.hash(&mut h1);
346 let mut h2 = std::hash::DefaultHasher::new();
347 AlphaPolicy::Forbid.hash(&mut h2);
348 assert_eq!(h1.finish(), h2.finish());
349 }
350
351 #[test]
352 #[cfg(feature = "std")]
353 fn convert_options_hash() {
354 use core::hash::{Hash, Hasher};
355 let opts = ConvertOptions {
356 gray_expand: GrayExpand::Broadcast,
357 alpha_policy: AlphaPolicy::Forbid,
358 depth_policy: DepthPolicy::Forbid,
359 luma: None,
360 clip_out_of_gamut: true,
361 };
362 let mut h = std::hash::DefaultHasher::new();
363 opts.hash(&mut h);
364 // Just verify it doesn't panic.
365 let _ = h.finish();
366 }
367}