Skip to main content

fovea_display/
strategy.rs

1//! Display strategies: named conversions from pixel types to `Srgba8`.
2//!
3//! Every pixel type requires an explicit [`DisplayStrategy`] to become
4//! displayable. This follows fovea's core philosophy: conversions are
5//! named, data loss never happens silently.
6//!
7//! # Strategies
8//!
9//! | Strategy            | Use case                                        |
10//! |---------------------|-------------------------------------------------|
11//! | [`Identity`]        | sRGB types that are already display-ready        |
12//! | [`LinearToDisplay`] | Linear-light types (applies sRGB gamma)          |
13//! | [`AutoContrast`]    | Scan-based contrast stretching for HDR / deep    |
14//! | [`FixedRange`]      | User-specified value range mapping               |
15//!
16//! # Examples
17//!
18//! ```
19//! use fovea::pixel::Srgba8;
20//! use fovea_display::{DisplayStrategy, Identity};
21//!
22//! let px = Srgba8::new(128, 64, 200, 255);
23//! let out = Identity.to_display(&px);
24//! assert_eq!(out, px);
25//! ```
26
27use fovea::image::ImageView;
28use fovea::pixel::{
29    Bgr8, Bgra8, Mono, Mono8, Mono16, Mono32, Mono64, MonoF32, MonoF64, Rgb8, RgbF32, Rgba8,
30    RgbaF32, Srgb8, SrgbMono8, SrgbMonoA8, Srgba8,
31};
32use fovea::transform::{ConvertPixel, SrgbGamma};
33
34use crate::pixel::DisplayPixel;
35
36// ═══════════════════════════════════════════════════════════════════════════════
37// 2.1  DisplayStrategy trait
38// ═══════════════════════════════════════════════════════════════════════════════
39
40/// Named conversion from any pixel type to [`Srgba8`] for display.
41///
42/// This follows fovea Philosophy #4: conversions are named, data loss
43/// never happens silently. Every pixel type requires an explicit strategy
44/// to become displayable.
45///
46/// The output is always [`Srgba8`] because displays are sRGB devices.
47///
48/// # Design note
49///
50/// This is intentionally separate from [`ConvertPixel<P, Srgba8>`]. See
51/// the TODO.md "Why not reuse `ConvertPixel`?" section for rationale.
52///
53/// [`ConvertPixel<P, Srgba8>`]: fovea::transform::ConvertPixel
54pub trait DisplayStrategy<P: Copy> {
55    /// Convert a single pixel to display-ready [`Srgba8`].
56    fn to_display(&self, pixel: &P) -> Srgba8;
57}
58
59// ═══════════════════════════════════════════════════════════════════════════════
60// 2.2  Identity strategy
61// ═══════════════════════════════════════════════════════════════════════════════
62
63/// Display sRGB pixels as-is. Only available for sRGB types.
64///
65/// Attempting to use `Identity` with a linear type like `Rgb8` is a
66/// compile error — use [`LinearToDisplay`] instead.
67///
68/// # Examples
69///
70/// ```
71/// use fovea::pixel::{Srgba8, Srgb8, SrgbMono8, SrgbMonoA8};
72/// use fovea_display::{DisplayStrategy, Identity};
73///
74/// // Srgba8 pass-through
75/// let px = Srgba8::new(100, 150, 200, 255);
76/// assert_eq!(Identity.to_display(&px), px);
77///
78/// // Srgb8 → Srgba8 (alpha = 255)
79/// let px = Srgb8::new(128, 64, 200);
80/// assert_eq!(Identity.to_display(&px), Srgba8::new(128, 64, 200, 255));
81///
82/// // SrgbMono8 → broadcast to Srgba8
83/// let px = SrgbMono8::new(128);
84/// assert_eq!(Identity.to_display(&px), Srgba8::new(128, 128, 128, 255));
85///
86/// // SrgbMonoA8 → broadcast value, keep alpha
87/// let px = SrgbMonoA8::new(128, 64);
88/// assert_eq!(Identity.to_display(&px), Srgba8::new(128, 128, 128, 64));
89/// ```
90///
91/// ```compile_fail
92/// use fovea::pixel::Rgb8;
93/// use fovea_display::{DisplayStrategy, Identity};
94///
95/// // ERROR: Rgb8 is linear, not sRGB — no Identity impl.
96/// let px = Rgb8::new(128, 64, 200);
97/// let _ = Identity.to_display(&px);
98/// ```
99///
100/// ```compile_fail
101/// use fovea::pixel::Mono16;
102/// use fovea_display::{DisplayStrategy, Identity};
103///
104/// // ERROR: Mono16 is not an sRGB type — use AutoContrast or FixedRange.
105/// let px = Mono16::new(1000);
106/// let _ = Identity.to_display(&px);
107/// ```
108pub struct Identity;
109
110impl DisplayStrategy<Srgba8> for Identity {
111    #[inline]
112    fn to_display(&self, pixel: &Srgba8) -> Srgba8 {
113        *pixel
114    }
115}
116
117impl DisplayStrategy<Srgb8> for Identity {
118    #[inline]
119    fn to_display(&self, pixel: &Srgb8) -> Srgba8 {
120        Srgba8::new(pixel.r.0, pixel.g.0, pixel.b.0, 255)
121    }
122}
123
124impl DisplayStrategy<SrgbMono8> for Identity {
125    #[inline]
126    fn to_display(&self, pixel: &SrgbMono8) -> Srgba8 {
127        let v = pixel.0.0;
128        Srgba8::new(v, v, v, 255)
129    }
130}
131
132impl DisplayStrategy<SrgbMonoA8> for Identity {
133    #[inline]
134    fn to_display(&self, pixel: &SrgbMonoA8) -> Srgba8 {
135        let v = pixel.v.0;
136        Srgba8::new(v, v, v, pixel.a.0)
137    }
138}
139
140// ═══════════════════════════════════════════════════════════════════════════════
141// 2.3  LinearToDisplay strategy
142// ═══════════════════════════════════════════════════════════════════════════════
143
144/// Apply sRGB gamma encoding to linear pixels for display.
145///
146/// This strategy accepts linear-light pixel types (like `Rgb8`, `RgbF32`,
147/// `Mono8`, `f32`) and applies the sRGB transfer function before display.
148/// Without this, linear images appear washed out on sRGB monitors.
149///
150/// # Performance note
151///
152/// This strategy does float conversion for every pixel. For debug display
153/// this is acceptable. For production rendering, users should pre-convert
154/// to sRGB or use GPU shaders.
155///
156/// # Examples
157///
158/// ```
159/// use fovea::pixel::{Rgb8, Srgba8};
160/// use fovea_display::{DisplayStrategy, LinearToDisplay};
161///
162/// // Black stays black
163/// let px = Rgb8::new(0, 0, 0);
164/// assert_eq!(LinearToDisplay.to_display(&px), Srgba8::new(0, 0, 0, 255));
165///
166/// // White stays white
167/// let px = Rgb8::new(255, 255, 255);
168/// assert_eq!(LinearToDisplay.to_display(&px), Srgba8::new(255, 255, 255, 255));
169/// ```
170pub struct LinearToDisplay;
171
172impl DisplayStrategy<Rgb8> for LinearToDisplay {
173    #[inline]
174    fn to_display(&self, pixel: &Rgb8) -> Srgba8 {
175        let linear = RgbF32::new(
176            pixel.r.0 as f32 / 255.0,
177            pixel.g.0 as f32 / 255.0,
178            pixel.b.0 as f32 / 255.0,
179        );
180        let srgb: Srgb8 = SrgbGamma.convert(&linear);
181        Srgba8::new(srgb.r.0, srgb.g.0, srgb.b.0, 255)
182    }
183}
184
185impl DisplayStrategy<Rgba8> for LinearToDisplay {
186    #[inline]
187    fn to_display(&self, pixel: &Rgba8) -> Srgba8 {
188        let linear = RgbaF32::new(
189            pixel.r.0 as f32 / 255.0,
190            pixel.g.0 as f32 / 255.0,
191            pixel.b.0 as f32 / 255.0,
192            pixel.a.0 as f32 / 255.0,
193        );
194        SrgbGamma.convert(&linear)
195    }
196}
197
198impl DisplayStrategy<RgbF32> for LinearToDisplay {
199    #[inline]
200    fn to_display(&self, pixel: &RgbF32) -> Srgba8 {
201        let srgb: Srgb8 = SrgbGamma.convert(pixel);
202        Srgba8::new(srgb.r.0, srgb.g.0, srgb.b.0, 255)
203    }
204}
205
206impl DisplayStrategy<RgbaF32> for LinearToDisplay {
207    #[inline]
208    fn to_display(&self, pixel: &RgbaF32) -> Srgba8 {
209        SrgbGamma.convert(pixel)
210    }
211}
212
213impl DisplayStrategy<Bgr8> for LinearToDisplay {
214    #[inline]
215    fn to_display(&self, pixel: &Bgr8) -> Srgba8 {
216        let linear = RgbF32::new(
217            pixel.r.0 as f32 / 255.0,
218            pixel.g.0 as f32 / 255.0,
219            pixel.b.0 as f32 / 255.0,
220        );
221        let srgb: Srgb8 = SrgbGamma.convert(&linear);
222        Srgba8::new(srgb.r.0, srgb.g.0, srgb.b.0, 255)
223    }
224}
225
226impl DisplayStrategy<Bgra8> for LinearToDisplay {
227    #[inline]
228    fn to_display(&self, pixel: &Bgra8) -> Srgba8 {
229        let linear = RgbaF32::new(
230            pixel.r.0 as f32 / 255.0,
231            pixel.g.0 as f32 / 255.0,
232            pixel.b.0 as f32 / 255.0,
233            pixel.a.0 as f32 / 255.0,
234        );
235        SrgbGamma.convert(&linear)
236    }
237}
238
239impl DisplayStrategy<Mono8> for LinearToDisplay {
240    #[inline]
241    fn to_display(&self, pixel: &Mono8) -> Srgba8 {
242        let linear = MonoF32::new(pixel.value() as f32 / 255.0);
243        let srgb: SrgbMono8 = SrgbGamma.convert(&linear);
244        let v = srgb.0.0;
245        Srgba8::new(v, v, v, 255)
246    }
247}
248
249impl DisplayStrategy<f32> for LinearToDisplay {
250    #[inline]
251    fn to_display(&self, pixel: &f32) -> Srgba8 {
252        let clamped = MonoF32::new(pixel.clamp(0.0, 1.0));
253        let srgb: SrgbMono8 = SrgbGamma.convert(&clamped);
254        let v = srgb.0.0;
255        Srgba8::new(v, v, v, 255)
256    }
257}
258
259impl DisplayStrategy<MonoF32> for LinearToDisplay {
260    #[inline]
261    fn to_display(&self, pixel: &MonoF32) -> Srgba8 {
262        <Self as DisplayStrategy<f32>>::to_display(self, &pixel.0)
263    }
264}
265
266impl DisplayStrategy<MonoF64> for LinearToDisplay {
267    #[inline]
268    fn to_display(&self, pixel: &MonoF64) -> Srgba8 {
269        let clamped = MonoF32::new(pixel.0.clamp(0.0, 1.0) as f32);
270        let srgb: SrgbMono8 = SrgbGamma.convert(&clamped);
271        let v = srgb.0.0;
272        Srgba8::new(v, v, v, 255)
273    }
274}
275
276// ═══════════════════════════════════════════════════════════════════════════════
277// 2.4  RangeMap internal helper
278// ═══════════════════════════════════════════════════════════════════════════════
279
280/// Internal helper: maps a [min, max] range to sRGB gray `Srgba8`.
281///
282/// Used by [`AutoContrast`] and [`FixedRange`] to share the core
283/// normalize-then-gamma-encode math.
284#[derive(Clone, Copy)]
285struct RangeMap {
286    min: f64,
287    scale: f64, // 1.0 / (max - min), or 0.0 if min == max
288}
289
290impl RangeMap {
291    /// Create a new range map.
292    ///
293    /// If `min == max`, all values map to mid-gray (128).
294    fn new(min: f64, max: f64) -> Self {
295        let range = max - min;
296        let scale = if range.abs() < f64::EPSILON {
297            0.0
298        } else {
299            1.0 / range
300        };
301        RangeMap { min, scale }
302    }
303
304    /// Map a scalar value to an sRGB-encoded gray [`Srgba8`].
305    ///
306    /// 1. Normalize to [0, 1]
307    /// 2. Apply sRGB gamma encoding via `SrgbGamma`
308    /// 3. Broadcast to `Srgba8` with alpha = 255
309    #[inline]
310    fn map_to_srgba8(&self, value: f64) -> Srgba8 {
311        // Degenerate range → mid-gray
312        if self.scale == 0.0 {
313            return Srgba8::new(128, 128, 128, 255);
314        }
315
316        let t = MonoF32::new(((value - self.min) * self.scale).clamp(0.0, 1.0) as f32);
317        let srgb: SrgbMono8 = SrgbGamma.convert(&t);
318        let v = srgb.0.0;
319        Srgba8::new(v, v, v, 255)
320    }
321}
322
323// ═══════════════════════════════════════════════════════════════════════════════
324// 2.5  AutoContrast strategy
325// ═══════════════════════════════════════════════════════════════════════════════
326
327/// Automatically determines display range by scanning the image.
328///
329/// Must be constructed via [`AutoContrast::new()`], [`AutoContrast::scan()`],
330/// or [`AutoContrast::scan_with()`].
331///
332/// This strategy applies sRGB gamma encoding after range mapping:
333/// values at `min` map to black, values at `max` map to white.
334///
335/// # Supported pixel types
336///
337/// `AutoContrast` implements [`DisplayStrategy`] for single-channel types:
338/// `Mono8`, `Mono16`, `Mono32`, `Mono64`, `Mono<10>`, `Mono<12>`, `Mono<14>`,
339/// `f32`, `f64`, `u8`, `u16`.
340///
341/// For multi-channel types (RGB, etc.), what "auto contrast" means is
342/// ambiguous. Use [`LinearToDisplay`] or a custom strategy instead.
343///
344/// # Examples
345///
346/// ```
347/// use fovea::pixel::{Mono16, Srgba8};
348/// use fovea_display::{DisplayStrategy, AutoContrast};
349///
350/// let ac = AutoContrast::new(0.0, 65535.0);
351/// assert_eq!(ac.to_display(&Mono16::new(0)), Srgba8::new(0, 0, 0, 255));
352/// assert_eq!(ac.to_display(&Mono16::new(65535)), Srgba8::new(255, 255, 255, 255));
353/// ```
354#[derive(Clone, Copy)]
355pub struct AutoContrast {
356    range: RangeMap,
357}
358
359impl AutoContrast {
360    /// Create an `AutoContrast` with an explicit min/max range.
361    ///
362    /// Values at `min` map to black, values at `max` map to white.
363    /// If `min == max`, all pixels map to mid-gray.
364    #[must_use]
365    pub fn new(min: f64, max: f64) -> Self {
366        AutoContrast {
367            range: RangeMap::new(min, max),
368        }
369    }
370
371    /// Scan an image to determine the display range.
372    ///
373    /// Iterates all pixels, converting each to `f64` via `Into<f64>`,
374    /// and finds the minimum and maximum values.
375    ///
376    /// # Type bounds
377    ///
378    /// Requires `V::Pixel: Into<f64>`. For pixel types that don't
379    /// implement `Into<f64>` (e.g. `Mono<BITS>`), use
380    /// [`AutoContrast::scan_with()`] instead.
381    ///
382    /// # Panics
383    ///
384    /// Returns a degenerate (mid-gray) range for empty images.
385    ///
386    /// # Examples
387    ///
388    /// ```
389    /// use fovea::image::{Image, ImageView};
390    /// use fovea::pixel::{MonoF32, Srgba8};
391    /// use fovea_display::{DisplayStrategy, AutoContrast};
392    ///
393    /// // pixel role for floats is `MonoF32`, not `f32`.
394    /// let img = Image::<MonoF32>::fill(4, 4, MonoF32::new(0.5));
395    /// let ac = AutoContrast::scan(&img);
396    /// // Constant image → degenerate range → mid-gray
397    /// assert_eq!(ac.to_display(&MonoF32::new(0.5)), Srgba8::new(128, 128, 128, 255));
398    /// ```
399    pub fn scan<V>(image: &V) -> Self
400    where
401        V: ImageView,
402        V::Pixel: Copy + Into<f64>,
403    {
404        Self::scan_with(image, |p| (*p).into())
405    }
406
407    /// Scan an image to determine the display range using a custom
408    /// scalar extraction function.
409    ///
410    /// This is the general-purpose constructor that works with any pixel
411    /// type, including `Mono<BITS>` and other types without `Into<f64>`.
412    ///
413    /// # Examples
414    ///
415    /// ```
416    /// use fovea::image::{Image, ImageView, ImageViewMut};
417    /// use fovea::pixel::{Mono16, Srgba8};
418    /// use fovea_display::{DisplayStrategy, AutoContrast};
419    ///
420    /// let mut img = Image::<Mono16>::fill(4, 4, Mono16::new(100));
421    /// *img.get_mut(0, 0).unwrap() = Mono16::new(50);
422    /// *img.get_mut(3, 3).unwrap() = Mono16::new(200);
423    ///
424    /// let ac = AutoContrast::scan_with(&img, |p| p.value() as f64);
425    /// assert_eq!(ac.to_display(&Mono16::new(50)), Srgba8::new(0, 0, 0, 255));
426    /// assert_eq!(ac.to_display(&Mono16::new(200)), Srgba8::new(255, 255, 255, 255));
427    /// ```
428    pub fn scan_with<V, F>(image: &V, to_scalar: F) -> Self
429    where
430        V: ImageView,
431        V::Pixel: Copy,
432        F: Fn(&V::Pixel) -> f64,
433    {
434        let w = image.width();
435        let h = image.height();
436
437        if w == 0 || h == 0 {
438            return AutoContrast::new(0.0, 0.0);
439        }
440
441        let first = to_scalar(&image.pixel_at(0, 0));
442        let mut min = first;
443        let mut max = first;
444
445        for y in 0..h {
446            for x in 0..w {
447                let v = to_scalar(&image.pixel_at(x, y));
448                if v < min {
449                    min = v;
450                }
451                if v > max {
452                    max = v;
453                }
454            }
455        }
456
457        AutoContrast::new(min, max)
458    }
459}
460
461// ── AutoContrast: DisplayStrategy impls ────────────────────────────────────
462
463/// Helper: extract a `Mono8` intensity as `f64`.
464#[inline(always)]
465fn mono8_to_f64(pixel: &Mono8) -> f64 {
466    pixel.value() as f64
467}
468
469/// Helper: extract a `Mono16` intensity as `f64`.
470#[inline(always)]
471fn mono16_to_f64(pixel: &Mono16) -> f64 {
472    pixel.value() as f64
473}
474
475/// Helper: extract a `Mono32` intensity as `f64`.
476#[inline(always)]
477fn mono32_to_f64(pixel: &Mono32) -> f64 {
478    pixel.value() as f64
479}
480
481/// Helper: extract a `Mono64` intensity as `f64`.
482#[inline(always)]
483fn mono64_to_f64(pixel: &Mono64) -> f64 {
484    pixel.value() as f64
485}
486
487impl DisplayStrategy<Mono8> for AutoContrast {
488    #[inline]
489    fn to_display(&self, pixel: &Mono8) -> Srgba8 {
490        self.range.map_to_srgba8(mono8_to_f64(pixel))
491    }
492}
493
494impl DisplayStrategy<Mono16> for AutoContrast {
495    #[inline]
496    fn to_display(&self, pixel: &Mono16) -> Srgba8 {
497        self.range.map_to_srgba8(mono16_to_f64(pixel))
498    }
499}
500
501impl DisplayStrategy<Mono32> for AutoContrast {
502    #[inline]
503    fn to_display(&self, pixel: &Mono32) -> Srgba8 {
504        self.range.map_to_srgba8(mono32_to_f64(pixel))
505    }
506}
507
508impl DisplayStrategy<Mono64> for AutoContrast {
509    #[inline]
510    fn to_display(&self, pixel: &Mono64) -> Srgba8 {
511        self.range.map_to_srgba8(mono64_to_f64(pixel))
512    }
513}
514
515impl DisplayStrategy<f32> for AutoContrast {
516    #[inline]
517    fn to_display(&self, pixel: &f32) -> Srgba8 {
518        self.range.map_to_srgba8(*pixel as f64)
519    }
520}
521
522impl DisplayStrategy<f64> for AutoContrast {
523    #[inline]
524    fn to_display(&self, pixel: &f64) -> Srgba8 {
525        self.range.map_to_srgba8(*pixel)
526    }
527}
528
529impl DisplayStrategy<MonoF32> for AutoContrast {
530    #[inline]
531    fn to_display(&self, pixel: &MonoF32) -> Srgba8 {
532        self.range.map_to_srgba8(pixel.0 as f64)
533    }
534}
535
536impl DisplayStrategy<MonoF64> for AutoContrast {
537    #[inline]
538    fn to_display(&self, pixel: &MonoF64) -> Srgba8 {
539        self.range.map_to_srgba8(pixel.0)
540    }
541}
542
543impl DisplayStrategy<u8> for AutoContrast {
544    #[inline]
545    fn to_display(&self, pixel: &u8) -> Srgba8 {
546        self.range.map_to_srgba8(*pixel as f64)
547    }
548}
549
550impl DisplayStrategy<u16> for AutoContrast {
551    #[inline]
552    fn to_display(&self, pixel: &u16) -> Srgba8 {
553        self.range.map_to_srgba8(*pixel as f64)
554    }
555}
556
557impl<const BITS: usize> DisplayStrategy<Mono<BITS>> for AutoContrast {
558    #[inline]
559    fn to_display(&self, pixel: &Mono<BITS>) -> Srgba8 {
560        self.range.map_to_srgba8(pixel.value() as f64)
561    }
562}
563
564// ═══════════════════════════════════════════════════════════════════════════════
565// 2.6  FixedRange strategy
566// ═══════════════════════════════════════════════════════════════════════════════
567
568/// Display with a fixed, user-specified value range.
569///
570/// Values at `min` map to black, values at `max` map to white.
571/// Values outside the range are clamped.
572///
573/// This strategy applies sRGB gamma encoding after range mapping.
574///
575/// # Supported pixel types
576///
577/// Same as [`AutoContrast`]: single-channel types only.
578///
579/// # Examples
580///
581/// ```
582/// use fovea::pixel::{Mono16, Srgba8};
583/// use fovea_display::{DisplayStrategy, FixedRange};
584///
585/// let fr = FixedRange::new(100.0, 200.0);
586/// assert_eq!(fr.to_display(&Mono16::new(100)), Srgba8::new(0, 0, 0, 255));
587/// assert_eq!(fr.to_display(&Mono16::new(200)), Srgba8::new(255, 255, 255, 255));
588///
589/// // Values outside the range are clamped
590/// assert_eq!(fr.to_display(&Mono16::new(0)), Srgba8::new(0, 0, 0, 255));
591/// assert_eq!(fr.to_display(&Mono16::new(65535)), Srgba8::new(255, 255, 255, 255));
592/// ```
593#[derive(Clone, Copy)]
594pub struct FixedRange {
595    range: RangeMap,
596}
597
598impl FixedRange {
599    /// Create a `FixedRange` with the given min/max bounds.
600    ///
601    /// Values at `min` map to black, values at `max` map to white.
602    /// If `min == max`, all pixels map to mid-gray.
603    #[must_use]
604    pub fn new(min: f64, max: f64) -> Self {
605        FixedRange {
606            range: RangeMap::new(min, max),
607        }
608    }
609}
610
611// ── FixedRange: DisplayStrategy impls (same types as AutoContrast) ─────────
612
613impl DisplayStrategy<Mono8> for FixedRange {
614    #[inline]
615    fn to_display(&self, pixel: &Mono8) -> Srgba8 {
616        self.range.map_to_srgba8(mono8_to_f64(pixel))
617    }
618}
619
620impl DisplayStrategy<Mono16> for FixedRange {
621    #[inline]
622    fn to_display(&self, pixel: &Mono16) -> Srgba8 {
623        self.range.map_to_srgba8(mono16_to_f64(pixel))
624    }
625}
626
627impl DisplayStrategy<Mono32> for FixedRange {
628    #[inline]
629    fn to_display(&self, pixel: &Mono32) -> Srgba8 {
630        self.range.map_to_srgba8(mono32_to_f64(pixel))
631    }
632}
633
634impl DisplayStrategy<Mono64> for FixedRange {
635    #[inline]
636    fn to_display(&self, pixel: &Mono64) -> Srgba8 {
637        self.range.map_to_srgba8(mono64_to_f64(pixel))
638    }
639}
640
641impl DisplayStrategy<f32> for FixedRange {
642    #[inline]
643    fn to_display(&self, pixel: &f32) -> Srgba8 {
644        self.range.map_to_srgba8(*pixel as f64)
645    }
646}
647
648impl DisplayStrategy<f64> for FixedRange {
649    #[inline]
650    fn to_display(&self, pixel: &f64) -> Srgba8 {
651        self.range.map_to_srgba8(*pixel)
652    }
653}
654
655impl DisplayStrategy<u8> for FixedRange {
656    #[inline]
657    fn to_display(&self, pixel: &u8) -> Srgba8 {
658        self.range.map_to_srgba8(*pixel as f64)
659    }
660}
661
662impl DisplayStrategy<u16> for FixedRange {
663    #[inline]
664    fn to_display(&self, pixel: &u16) -> Srgba8 {
665        self.range.map_to_srgba8(*pixel as f64)
666    }
667}
668
669impl<const BITS: usize> DisplayStrategy<Mono<BITS>> for FixedRange {
670    #[inline]
671    fn to_display(&self, pixel: &Mono<BITS>) -> Srgba8 {
672        self.range.map_to_srgba8(pixel.value() as f64)
673    }
674}
675
676// ═══════════════════════════════════════════════════════════════════════════════
677// 2.7  Framebuffer helper type
678// ═══════════════════════════════════════════════════════════════════════════════
679
680/// A width×height buffer of `u32` pixels in `0x00RRGGBB` format.
681///
682/// This is the result of applying a [`DisplayStrategy`] to an
683/// [`ImageView`]. It can be blitted directly to a `softbuffer::Buffer`.
684///
685/// This type is crate-private — it is an implementation detail of the
686/// debug window system.
687pub(crate) struct Framebuffer {
688    pub width: u32,
689    pub height: u32,
690    pub data: Vec<u32>,
691}
692
693impl Framebuffer {
694    /// Convert an [`ImageView`] to a [`Framebuffer`] using the given
695    /// [`DisplayStrategy`].
696    ///
697    /// Iterates pixels row-by-row, applies the strategy to produce
698    /// [`Srgba8`], then packs each result into `0x00RRGGBB` via
699    /// [`DisplayPixel::to_framebuffer_u32()`].
700    pub(crate) fn from_image<V, S>(image: &V, strategy: S) -> Self
701    where
702        V: ImageView,
703        V::Pixel: Copy,
704        S: DisplayStrategy<V::Pixel>,
705    {
706        let w = image.width();
707        let h = image.height();
708        let len = w * h;
709        let mut data = Vec::with_capacity(len);
710
711        for y in 0..h {
712            for x in 0..w {
713                let pixel = image.pixel_at(x, y);
714                let display = strategy.to_display(&pixel);
715                data.push(display.to_framebuffer_u32());
716            }
717        }
718
719        Framebuffer {
720            width: w as u32,
721            height: h as u32,
722            data,
723        }
724    }
725
726    /// Create a `Framebuffer` from pre-built data.
727    ///
728    /// # Panics
729    ///
730    /// Panics if `data.len() != width * height`.
731    #[allow(dead_code)]
732    pub(crate) fn from_raw(width: u32, height: u32, data: Vec<u32>) -> Self {
733        assert_eq!(
734            data.len(),
735            (width as usize) * (height as usize),
736            "Framebuffer::from_raw: data length ({}) does not match dimensions ({}×{}={})",
737            data.len(),
738            width,
739            height,
740            (width as usize) * (height as usize),
741        );
742        Framebuffer {
743            width,
744            height,
745            data,
746        }
747    }
748}
749
750// ═══════════════════════════════════════════════════════════════════════════════
751// Tests
752// ═══════════════════════════════════════════════════════════════════════════════
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757    use fovea::image::{Image, ImageViewMut, SubView};
758    use fovea::pixel::*;
759
760    // ── Identity tests ─────────────────────────────────────────────────
761
762    #[test]
763    fn identity_srgba8_passthrough() {
764        let px = Srgba8::new(42, 100, 200, 128);
765        assert_eq!(Identity.to_display(&px), px);
766    }
767
768    #[test]
769    fn identity_srgba8_all_values_preserved() {
770        for r in [0u8, 1, 127, 128, 254, 255] {
771            for g in [0u8, 128, 255] {
772                for b in [0u8, 128, 255] {
773                    for a in [0u8, 128, 255] {
774                        let px = Srgba8::new(r, g, b, a);
775                        assert_eq!(Identity.to_display(&px), px);
776                    }
777                }
778            }
779        }
780    }
781
782    #[test]
783    fn identity_srgb8_adds_alpha() {
784        let px = Srgb8::new(128, 64, 200);
785        assert_eq!(Identity.to_display(&px), Srgba8::new(128, 64, 200, 255));
786    }
787
788    #[test]
789    fn identity_srgb8_black() {
790        let px = Srgb8::new(0, 0, 0);
791        assert_eq!(Identity.to_display(&px), Srgba8::new(0, 0, 0, 255));
792    }
793
794    #[test]
795    fn identity_srgb8_white() {
796        let px = Srgb8::new(255, 255, 255);
797        assert_eq!(Identity.to_display(&px), Srgba8::new(255, 255, 255, 255));
798    }
799
800    #[test]
801    fn identity_srgb_mono8_broadcast() {
802        let px = SrgbMono8::new(128);
803        assert_eq!(Identity.to_display(&px), Srgba8::new(128, 128, 128, 255));
804    }
805
806    #[test]
807    fn identity_srgb_mono8_black() {
808        let px = SrgbMono8::new(0);
809        assert_eq!(Identity.to_display(&px), Srgba8::new(0, 0, 0, 255));
810    }
811
812    #[test]
813    fn identity_srgb_mono8_white() {
814        let px = SrgbMono8::new(255);
815        assert_eq!(Identity.to_display(&px), Srgba8::new(255, 255, 255, 255));
816    }
817
818    #[test]
819    fn identity_srgb_mono_a8_broadcast_with_alpha() {
820        let px = SrgbMonoA8::new(128, 64);
821        assert_eq!(Identity.to_display(&px), Srgba8::new(128, 128, 128, 64));
822    }
823
824    #[test]
825    fn identity_srgb_mono_a8_black_transparent() {
826        let px = SrgbMonoA8::new(0, 0);
827        assert_eq!(Identity.to_display(&px), Srgba8::new(0, 0, 0, 0));
828    }
829
830    #[test]
831    fn identity_srgb_mono_a8_white_opaque() {
832        let px = SrgbMonoA8::new(255, 255);
833        assert_eq!(Identity.to_display(&px), Srgba8::new(255, 255, 255, 255));
834    }
835
836    // ── LinearToDisplay tests ──────────────────────────────────────────
837
838    #[test]
839    fn linear_rgb8_black() {
840        let px = Rgb8::new(0, 0, 0);
841        assert_eq!(LinearToDisplay.to_display(&px), Srgba8::new(0, 0, 0, 255));
842    }
843
844    #[test]
845    fn linear_rgb8_white() {
846        let px = Rgb8::new(255, 255, 255);
847        assert_eq!(
848            LinearToDisplay.to_display(&px),
849            Srgba8::new(255, 255, 255, 255)
850        );
851    }
852
853    #[test]
854    fn linear_rgbf32_mid_gray() {
855        // Linear 0.5 → sRGB ≈ 188 (the sRGB transfer function maps 0.5 → ~0.735)
856        let px = RgbF32::new(0.5, 0.5, 0.5);
857        let result = LinearToDisplay.to_display(&px);
858        // srgb_encode(0.5) = 1.055 * 0.5^(1/2.4) - 0.055 ≈ 0.7354 → round(0.7354*255) = 188
859        assert_eq!(result.r.0, 188);
860        assert_eq!(result.g.0, 188);
861        assert_eq!(result.b.0, 188);
862        assert_eq!(result.a.0, 255);
863    }
864
865    #[test]
866    fn linear_mono8_correct_srgb_gray() {
867        // Mono8(128) → linear 128/255 ≈ 0.502 → sRGB ≈ 188
868        let px = Mono8::new(128);
869        let result = LinearToDisplay.to_display(&px);
870        assert_eq!(result.r.0, result.g.0);
871        assert_eq!(result.g.0, result.b.0);
872        assert_eq!(result.a.0, 255);
873        // Linear 0.502 → sRGB ≈ 188
874        assert!(result.r.0 >= 187 && result.r.0 <= 189);
875    }
876
877    #[test]
878    fn linear_rgba_f32_alpha_preserved() {
879        // Alpha should be transferred linearly (not gamma-encoded)
880        let px = RgbaF32::new(0.5, 0.5, 0.5, 0.5);
881        let result = LinearToDisplay.to_display(&px);
882        // Alpha: round(0.5 * 255) = 128
883        assert_eq!(result.a.0, 128);
884        // RGB should be gamma-encoded ≈ 188
885        assert_eq!(result.r.0, 188);
886    }
887
888    #[test]
889    fn linear_bgr8_channel_order() {
890        // Bgr8 has fields b, g, r but the .r field is still "red"
891        let px = Bgr8::new(0, 0, 255); // b=0, g=0, r=255
892        let result = LinearToDisplay.to_display(&px);
893        assert_eq!(result.r.0, 255); // red channel maximum
894        assert_eq!(result.g.0, 0);
895        assert_eq!(result.b.0, 0);
896    }
897
898    #[test]
899    fn linear_bgra8_channel_order_with_alpha() {
900        let px = Bgra8::new(0, 0, 255, 128); // b=0, g=0, r=255, a=128
901        let result = LinearToDisplay.to_display(&px);
902        assert_eq!(result.r.0, 255);
903        assert_eq!(result.g.0, 0);
904        assert_eq!(result.b.0, 0);
905        // Alpha: round(128/255 * 255) = 128
906        assert_eq!(result.a.0, 128);
907    }
908
909    #[test]
910    fn linear_f32_clamps_below_zero() {
911        let px: f32 = -0.5;
912        let result = LinearToDisplay.to_display(&px);
913        assert_eq!(result, Srgba8::new(0, 0, 0, 255));
914    }
915
916    #[test]
917    fn linear_f32_clamps_above_one() {
918        let px: f32 = 1.5;
919        let result = LinearToDisplay.to_display(&px);
920        assert_eq!(result, Srgba8::new(255, 255, 255, 255));
921    }
922
923    // ── RangeMap tests ─────────────────────────────────────────────────
924
925    #[test]
926    fn range_map_zero_to_one_black() {
927        let rm = RangeMap::new(0.0, 1.0);
928        assert_eq!(rm.map_to_srgba8(0.0), Srgba8::new(0, 0, 0, 255));
929    }
930
931    #[test]
932    fn range_map_zero_to_one_white() {
933        let rm = RangeMap::new(0.0, 1.0);
934        assert_eq!(rm.map_to_srgba8(1.0), Srgba8::new(255, 255, 255, 255));
935    }
936
937    #[test]
938    fn range_map_zero_to_one_mid_gray() {
939        let rm = RangeMap::new(0.0, 1.0);
940        let result = rm.map_to_srgba8(0.5);
941        // Linear 0.5 → sRGB ≈ 188
942        assert_eq!(result.r.0, 188);
943        assert_eq!(result.g.0, 188);
944        assert_eq!(result.b.0, 188);
945        assert_eq!(result.a.0, 255);
946    }
947
948    #[test]
949    fn range_map_custom_range_black() {
950        let rm = RangeMap::new(100.0, 200.0);
951        assert_eq!(rm.map_to_srgba8(100.0), Srgba8::new(0, 0, 0, 255));
952    }
953
954    #[test]
955    fn range_map_custom_range_white() {
956        let rm = RangeMap::new(100.0, 200.0);
957        assert_eq!(rm.map_to_srgba8(200.0), Srgba8::new(255, 255, 255, 255));
958    }
959
960    #[test]
961    fn range_map_degenerate_mid_gray() {
962        let rm = RangeMap::new(5.0, 5.0);
963        assert_eq!(rm.map_to_srgba8(5.0), Srgba8::new(128, 128, 128, 255));
964    }
965
966    #[test]
967    fn range_map_clamp_below() {
968        let rm = RangeMap::new(100.0, 200.0);
969        assert_eq!(rm.map_to_srgba8(50.0), Srgba8::new(0, 0, 0, 255));
970    }
971
972    #[test]
973    fn range_map_clamp_above() {
974        let rm = RangeMap::new(100.0, 200.0);
975        assert_eq!(rm.map_to_srgba8(300.0), Srgba8::new(255, 255, 255, 255));
976    }
977
978    // ── AutoContrast tests ─────────────────────────────────────────────
979
980    #[test]
981    fn auto_contrast_mono16_full_range() {
982        let ac = AutoContrast::new(0.0, 65535.0);
983        assert_eq!(ac.to_display(&Mono16::new(0)), Srgba8::new(0, 0, 0, 255));
984        assert_eq!(
985            ac.to_display(&Mono16::new(65535)),
986            Srgba8::new(255, 255, 255, 255)
987        );
988    }
989
990    #[test]
991    fn auto_contrast_custom_range() {
992        let ac = AutoContrast::new(100.0, 200.0);
993        assert_eq!(ac.to_display(&Mono16::new(100)), Srgba8::new(0, 0, 0, 255));
994        assert_eq!(
995            ac.to_display(&Mono16::new(200)),
996            Srgba8::new(255, 255, 255, 255)
997        );
998    }
999
1000    #[test]
1001    fn auto_contrast_f32_mid_gray() {
1002        let ac = AutoContrast::new(0.0, 1.0);
1003        let result = ac.to_display(&0.5f32);
1004        assert_eq!(result.r.0, 188);
1005    }
1006
1007    #[test]
1008    fn auto_contrast_scan_f32() {
1009        let mut img = Image::<MonoF32>::fill(4, 4, MonoF32::new(0.5));
1010        *img.get_mut(0, 0).unwrap() = MonoF32::new(0.0);
1011        *img.get_mut(3, 3).unwrap() = MonoF32::new(1.0);
1012
1013        let ac = AutoContrast::scan(&img);
1014        assert_eq!(ac.to_display(&MonoF32::new(0.0)), Srgba8::new(0, 0, 0, 255));
1015        assert_eq!(
1016            ac.to_display(&MonoF32::new(1.0)),
1017            Srgba8::new(255, 255, 255, 255)
1018        );
1019    }
1020
1021    #[test]
1022    fn auto_contrast_scan_constant_image_degenerate() {
1023        let img = Image::<MonoF32>::fill(4, 4, MonoF32::new(0.5));
1024        let ac = AutoContrast::scan(&img);
1025        // Constant image → degenerate range → mid-gray
1026        assert_eq!(
1027            ac.to_display(&MonoF32::new(0.5)),
1028            Srgba8::new(128, 128, 128, 255)
1029        );
1030    }
1031
1032    #[test]
1033    fn auto_contrast_scan_single_pixel_degenerate() {
1034        let img = Image::<MonoF64>::fill(1, 1, MonoF64::new(42.0));
1035        let ac = AutoContrast::scan(&img);
1036        assert_eq!(
1037            ac.to_display(&MonoF64::new(42.0)),
1038            Srgba8::new(128, 128, 128, 255)
1039        );
1040    }
1041
1042    #[test]
1043    fn auto_contrast_scan_empty_image() {
1044        let img = Image::<MonoF32>::fill(0, 0, MonoF32::new(0.0));
1045        let ac = AutoContrast::scan(&img);
1046        // Degenerate range
1047        assert_eq!(
1048            ac.to_display(&MonoF32::new(0.0)),
1049            Srgba8::new(128, 128, 128, 255)
1050        );
1051    }
1052
1053    #[test]
1054    fn auto_contrast_scan_with_mono16() {
1055        let mut img = Image::<Mono16>::fill(4, 4, Mono16::new(100));
1056        *img.get_mut(0, 0).unwrap() = Mono16::new(50);
1057        *img.get_mut(3, 3).unwrap() = Mono16::new(200);
1058
1059        let ac = AutoContrast::scan_with(&img, mono16_to_f64);
1060        assert_eq!(ac.to_display(&Mono16::new(50)), Srgba8::new(0, 0, 0, 255));
1061        assert_eq!(
1062            ac.to_display(&Mono16::new(200)),
1063            Srgba8::new(255, 255, 255, 255)
1064        );
1065    }
1066
1067    #[test]
1068    fn auto_contrast_mono32() {
1069        let ac = AutoContrast::new(0.0, u32::MAX as f64);
1070        assert_eq!(ac.to_display(&Mono32::new(0)), Srgba8::new(0, 0, 0, 255));
1071        assert_eq!(
1072            ac.to_display(&Mono32::new(u32::MAX)),
1073            Srgba8::new(255, 255, 255, 255)
1074        );
1075    }
1076
1077    #[test]
1078    fn auto_contrast_mono64() {
1079        let ac = AutoContrast::new(0.0, u64::MAX as f64);
1080        assert_eq!(ac.to_display(&Mono64::new(0)), Srgba8::new(0, 0, 0, 255));
1081        // u64::MAX as f64 → exact match at 1.0 → white
1082        assert_eq!(
1083            ac.to_display(&Mono64::new(u64::MAX)),
1084            Srgba8::new(255, 255, 255, 255)
1085        );
1086    }
1087
1088    #[test]
1089    fn auto_contrast_u8() {
1090        let ac = AutoContrast::new(0.0, 255.0);
1091        assert_eq!(ac.to_display(&0u8), Srgba8::new(0, 0, 0, 255));
1092        assert_eq!(ac.to_display(&255u8), Srgba8::new(255, 255, 255, 255));
1093    }
1094
1095    #[test]
1096    fn auto_contrast_u16() {
1097        let ac = AutoContrast::new(0.0, 65535.0);
1098        assert_eq!(ac.to_display(&0u16), Srgba8::new(0, 0, 0, 255));
1099        assert_eq!(ac.to_display(&65535u16), Srgba8::new(255, 255, 255, 255));
1100    }
1101
1102    #[test]
1103    fn auto_contrast_f64() {
1104        let ac = AutoContrast::new(0.0, 1.0);
1105        assert_eq!(ac.to_display(&0.0f64), Srgba8::new(0, 0, 0, 255));
1106        assert_eq!(ac.to_display(&1.0f64), Srgba8::new(255, 255, 255, 255));
1107    }
1108
1109    #[test]
1110    fn auto_contrast_mono10() {
1111        let ac = AutoContrast::new(0.0, 1023.0);
1112        let px = Mono10::new(0);
1113        assert_eq!(ac.to_display(&px), Srgba8::new(0, 0, 0, 255));
1114        let px = Mono10::new(1023);
1115        assert_eq!(ac.to_display(&px), Srgba8::new(255, 255, 255, 255));
1116    }
1117
1118    #[test]
1119    fn auto_contrast_mono12() {
1120        let ac = AutoContrast::new(0.0, 4095.0);
1121        let px = Mono12::new(0);
1122        assert_eq!(ac.to_display(&px), Srgba8::new(0, 0, 0, 255));
1123        let px = Mono12::new(4095);
1124        assert_eq!(ac.to_display(&px), Srgba8::new(255, 255, 255, 255));
1125    }
1126
1127    #[test]
1128    fn auto_contrast_mono14() {
1129        let ac = AutoContrast::new(0.0, 16383.0);
1130        let px = Mono14::new(0);
1131        assert_eq!(ac.to_display(&px), Srgba8::new(0, 0, 0, 255));
1132        let px = Mono14::new(16383);
1133        assert_eq!(ac.to_display(&px), Srgba8::new(255, 255, 255, 255));
1134    }
1135
1136    // ── FixedRange tests ───────────────────────────────────────────────
1137
1138    #[test]
1139    fn fixed_range_mono16_boundaries() {
1140        let fr = FixedRange::new(100.0, 200.0);
1141        assert_eq!(fr.to_display(&Mono16::new(100)), Srgba8::new(0, 0, 0, 255));
1142        assert_eq!(
1143            fr.to_display(&Mono16::new(200)),
1144            Srgba8::new(255, 255, 255, 255)
1145        );
1146    }
1147
1148    #[test]
1149    fn fixed_range_clamping_below() {
1150        let fr = FixedRange::new(100.0, 200.0);
1151        assert_eq!(fr.to_display(&Mono16::new(0)), Srgba8::new(0, 0, 0, 255));
1152    }
1153
1154    #[test]
1155    fn fixed_range_clamping_above() {
1156        let fr = FixedRange::new(100.0, 200.0);
1157        assert_eq!(
1158            fr.to_display(&Mono16::new(65535)),
1159            Srgba8::new(255, 255, 255, 255)
1160        );
1161    }
1162
1163    #[test]
1164    fn fixed_range_degenerate() {
1165        let fr = FixedRange::new(42.0, 42.0);
1166        assert_eq!(
1167            fr.to_display(&Mono16::new(42)),
1168            Srgba8::new(128, 128, 128, 255)
1169        );
1170    }
1171
1172    #[test]
1173    fn fixed_range_f32() {
1174        let fr = FixedRange::new(0.0, 1.0);
1175        assert_eq!(fr.to_display(&0.0f32), Srgba8::new(0, 0, 0, 255));
1176        assert_eq!(fr.to_display(&1.0f32), Srgba8::new(255, 255, 255, 255));
1177    }
1178
1179    #[test]
1180    fn fixed_range_f64() {
1181        let fr = FixedRange::new(-1.0, 1.0);
1182        assert_eq!(fr.to_display(&-1.0f64), Srgba8::new(0, 0, 0, 255));
1183        assert_eq!(fr.to_display(&1.0f64), Srgba8::new(255, 255, 255, 255));
1184    }
1185
1186    #[test]
1187    fn fixed_range_u8() {
1188        let fr = FixedRange::new(0.0, 255.0);
1189        assert_eq!(fr.to_display(&0u8), Srgba8::new(0, 0, 0, 255));
1190        assert_eq!(fr.to_display(&255u8), Srgba8::new(255, 255, 255, 255));
1191    }
1192
1193    #[test]
1194    fn fixed_range_u16() {
1195        let fr = FixedRange::new(0.0, 65535.0);
1196        assert_eq!(fr.to_display(&0u16), Srgba8::new(0, 0, 0, 255));
1197        assert_eq!(fr.to_display(&65535u16), Srgba8::new(255, 255, 255, 255));
1198    }
1199
1200    #[test]
1201    fn fixed_range_mono10() {
1202        let fr = FixedRange::new(0.0, 1023.0);
1203        assert_eq!(fr.to_display(&Mono10::new(0)), Srgba8::new(0, 0, 0, 255));
1204        assert_eq!(
1205            fr.to_display(&Mono10::new(1023)),
1206            Srgba8::new(255, 255, 255, 255)
1207        );
1208    }
1209
1210    // ── Framebuffer tests ──────────────────────────────────────────────
1211
1212    #[test]
1213    fn framebuffer_from_image_2x2_srgba8() {
1214        let mut img = Image::fill(2, 2, Srgba8::new(0, 0, 0, 255));
1215        *img.get_mut(0, 0).unwrap() = Srgba8::new(255, 0, 0, 255);
1216        *img.get_mut(1, 0).unwrap() = Srgba8::new(0, 255, 0, 255);
1217        *img.get_mut(0, 1).unwrap() = Srgba8::new(0, 0, 255, 255);
1218        *img.get_mut(1, 1).unwrap() = Srgba8::new(255, 255, 255, 255);
1219
1220        let fb = Framebuffer::from_image(&img, Identity);
1221        assert_eq!(fb.width, 2);
1222        assert_eq!(fb.height, 2);
1223        assert_eq!(fb.data.len(), 4);
1224        assert_eq!(fb.data[0], 0x00FF0000); // red
1225        assert_eq!(fb.data[1], 0x0000FF00); // green
1226        assert_eq!(fb.data[2], 0x000000FF); // blue
1227        assert_eq!(fb.data[3], 0x00FFFFFF); // white
1228    }
1229
1230    #[test]
1231    fn framebuffer_from_image_zero_size() {
1232        let img = Image::<Srgba8>::fill(0, 0, Srgba8::new(0, 0, 0, 0));
1233        let fb = Framebuffer::from_image(&img, Identity);
1234        assert_eq!(fb.width, 0);
1235        assert_eq!(fb.height, 0);
1236        assert!(fb.data.is_empty());
1237    }
1238
1239    #[test]
1240    fn framebuffer_from_image_1x1() {
1241        let img = Image::fill(1, 1, Srgba8::new(0xAA, 0xBB, 0xCC, 0xFF));
1242        let fb = Framebuffer::from_image(&img, Identity);
1243        assert_eq!(fb.width, 1);
1244        assert_eq!(fb.height, 1);
1245        assert_eq!(fb.data.len(), 1);
1246        assert_eq!(fb.data[0], 0x00AABBCC);
1247    }
1248
1249    #[test]
1250    fn framebuffer_from_image_with_strategy() {
1251        // Use LinearToDisplay on a Mono8 image
1252        let img = Image::fill(2, 1, Mono8::new(0));
1253        let fb = Framebuffer::from_image(&img, LinearToDisplay);
1254        assert_eq!(fb.width, 2);
1255        assert_eq!(fb.height, 1);
1256        assert_eq!(fb.data.len(), 2);
1257        // Mono8(0) → linear 0.0 → sRGB 0 → 0x00000000
1258        assert_eq!(fb.data[0], 0x00000000);
1259        assert_eq!(fb.data[1], 0x00000000);
1260    }
1261
1262    #[test]
1263    fn framebuffer_from_image_roi() {
1264        // Create a 4x4 image, take a 2x2 ROI, and convert
1265        let mut img = Image::fill(4, 4, Srgba8::new(0, 0, 0, 255));
1266        *img.get_mut(1, 1).unwrap() = Srgba8::new(255, 0, 0, 255);
1267        *img.get_mut(2, 1).unwrap() = Srgba8::new(0, 255, 0, 255);
1268        *img.get_mut(1, 2).unwrap() = Srgba8::new(0, 0, 255, 255);
1269        *img.get_mut(2, 2).unwrap() = Srgba8::new(255, 255, 255, 255);
1270
1271        let roi = img
1272            .roi(fovea::Rectangle::new((1usize, 1usize), (2usize, 2usize)))
1273            .unwrap();
1274        let fb = Framebuffer::from_image(&roi, Identity);
1275        assert_eq!(fb.width, 2);
1276        assert_eq!(fb.height, 2);
1277        assert_eq!(fb.data[0], 0x00FF0000); // red
1278        assert_eq!(fb.data[1], 0x0000FF00); // green
1279        assert_eq!(fb.data[2], 0x000000FF); // blue
1280        assert_eq!(fb.data[3], 0x00FFFFFF); // white
1281    }
1282
1283    #[test]
1284    fn framebuffer_from_raw_valid() {
1285        let fb = Framebuffer::from_raw(2, 2, vec![0, 1, 2, 3]);
1286        assert_eq!(fb.width, 2);
1287        assert_eq!(fb.height, 2);
1288        assert_eq!(fb.data, vec![0, 1, 2, 3]);
1289    }
1290
1291    #[test]
1292    fn framebuffer_from_raw_empty() {
1293        let fb = Framebuffer::from_raw(0, 0, vec![]);
1294        assert_eq!(fb.width, 0);
1295        assert_eq!(fb.height, 0);
1296        assert!(fb.data.is_empty());
1297    }
1298
1299    #[test]
1300    #[should_panic(expected = "does not match dimensions")]
1301    fn framebuffer_from_raw_wrong_size() {
1302        let _ = Framebuffer::from_raw(2, 2, vec![0, 1, 2]);
1303    }
1304}