Skip to main content

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}