Skip to main content

fast_ssim2/
input.rs

1//! Input image types and conversion to linear RGB.
2//!
3//! This module provides the [`ToLinearRgb`] trait for converting various image
4//! formats to the internal linear RGB representation used by SSIMULACRA2.
5//!
6//! ## Supported input formats (with `imgref` feature)
7//!
8//! | Type | Color Space | Conversion |
9//! |------|-------------|------------|
10//! | `ImgRef<[u8; 3]>` | sRGB (gamma) | `/255` + linearize |
11//! | `ImgRef<[u16; 3]>` | sRGB (gamma) | `/65535` + linearize |
12//! | `ImgRef<[f32; 3]>` | Linear RGB | none |
13//! | `ImgRef<u8>` | sRGB grayscale | `/255` + linearize + expand |
14//! | `ImgRef<f32>` | Linear grayscale | expand to RGB |
15//!
16//! ## Convention
17//!
18//! - Integer types (u8, u16) are assumed to be **sRGB** (gamma-encoded)
19//! - Float types (f32) are assumed to be **linear**
20
21/// Internal linear RGB image representation.
22///
23/// Stores pixels as `[f32; 3]` in linear RGB color space (0.0-1.0 range).
24#[derive(Clone, Debug)]
25pub struct LinearRgbImage {
26    pub(crate) data: Vec<[f32; 3]>,
27    pub(crate) width: usize,
28    pub(crate) height: usize,
29}
30
31/// Errors returned by [`LinearRgbImage::try_new`].
32#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
33pub enum LinearRgbImageError {
34    /// `width` or `height` was zero.
35    #[error("LinearRgbImage dimensions must be nonzero")]
36    ZeroDimension,
37    /// `width * height` overflowed `usize`.
38    #[error("LinearRgbImage dimensions overflow usize")]
39    DimensionOverflow,
40    /// `data.len()` did not match `width * height`.
41    #[error("LinearRgbImage data length {actual} does not match width * height = {expected}")]
42    DataLengthMismatch {
43        /// Expected pixel count (`width * height`).
44        expected: usize,
45        /// Actual `data.len()`.
46        actual: usize,
47    },
48}
49
50impl LinearRgbImage {
51    /// Creates a new linear RGB image from raw data.
52    ///
53    /// # Panics
54    ///
55    /// Panics if `width` or `height` is `0`, if `width * height` overflows
56    /// `usize`, or if `data.len()` does not equal `width * height`.
57    /// For a non-panicking constructor, use [`LinearRgbImage::try_new`].
58    pub fn new(data: Vec<[f32; 3]>, width: usize, height: usize) -> Self {
59        Self::try_new(data, width, height)
60            .expect("LinearRgbImage::new: invalid dimensions or data length")
61    }
62
63    /// Fallible constructor for [`LinearRgbImage`].
64    ///
65    /// Returns `Err` if `width` or `height` is `0`, if `width * height`
66    /// overflows `usize`, or if `data.len()` does not equal `width * height`.
67    pub fn try_new(
68        data: Vec<[f32; 3]>,
69        width: usize,
70        height: usize,
71    ) -> Result<Self, LinearRgbImageError> {
72        if width == 0 || height == 0 {
73            return Err(LinearRgbImageError::ZeroDimension);
74        }
75        let expected = width
76            .checked_mul(height)
77            .ok_or(LinearRgbImageError::DimensionOverflow)?;
78        if data.len() != expected {
79            return Err(LinearRgbImageError::DataLengthMismatch {
80                expected,
81                actual: data.len(),
82            });
83        }
84        Ok(Self {
85            data,
86            width,
87            height,
88        })
89    }
90
91    /// Returns the image width.
92    pub fn width(&self) -> usize {
93        self.width
94    }
95
96    /// Returns the image height.
97    pub fn height(&self) -> usize {
98        self.height
99    }
100
101    /// Returns the pixel data.
102    pub fn data(&self) -> &[[f32; 3]] {
103        &self.data
104    }
105
106    /// Returns mutable pixel data.
107    pub fn data_mut(&mut self) -> &mut [[f32; 3]] {
108        &mut self.data
109    }
110}
111
112/// Trait for converting image types to linear RGB.
113///
114/// Implement this trait to add support for custom image types.
115///
116/// Override [`into_linear_rgb`](ToLinearRgb::into_linear_rgb) for owned types
117/// that can convert in-place without allocating a new pixel buffer.
118pub trait ToLinearRgb {
119    /// Convert to linear RGB image (borrowing).
120    fn to_linear_rgb(&self) -> LinearRgbImage;
121
122    /// Convert to linear RGB image, consuming self.
123    ///
124    /// The default implementation calls [`to_linear_rgb`](ToLinearRgb::to_linear_rgb).
125    /// Override this for owned types that can reuse their pixel buffer
126    /// to avoid allocation.
127    fn into_linear_rgb(self) -> LinearRgbImage
128    where
129        Self: Sized,
130    {
131        self.to_linear_rgb()
132    }
133}
134
135/// Identity implementation for already-converted images.
136impl ToLinearRgb for LinearRgbImage {
137    fn to_linear_rgb(&self) -> LinearRgbImage {
138        self.clone()
139    }
140
141    fn into_linear_rgb(self) -> LinearRgbImage {
142        self
143    }
144}
145
146// =============================================================================
147// sRGB conversion functions
148// =============================================================================
149
150/// Convert sRGB (gamma-encoded) value to linear.
151///
152/// Uses a degree-4/4 rational polynomial approximation matching libjxl's
153/// `TF_SRGB::DisplayFromEncoded`. Coefficients computed via `af_cheb_rational`
154/// (k=100), approximation error ~5e-7. Evaluated with Horner's scheme using
155/// FMA to match HWY's `EvalRationalPolynomial`.
156#[inline]
157pub fn srgb_to_linear(s: f32) -> f32 {
158    const THRESH: f32 = 0.04045;
159    const LOW_DIV_INV: f32 = 1.0 / 12.92;
160
161    // Rational polynomial coefficients from libjxl TF_SRGB
162    const P: [f32; 5] = [
163        2.200_248_3e-4,
164        1.043_637_6e-2,
165        1.624_820_4e-1,
166        7.961_565e-1,
167        8.210_153e-1,
168    ];
169    const Q: [f32; 5] = [
170        2.631_847e-1,
171        1.076_976_5,
172        4.987_528_3e-1,
173        -5.512_498_3e-2,
174        6.521_209e-3,
175    ];
176
177    let x = s.abs();
178    if x <= THRESH {
179        x * LOW_DIV_INV
180    } else {
181        // Horner's: p[4]*x^4 + p[3]*x^3 + p[2]*x^2 + p[1]*x + p[0]
182        let num = P[4]
183            .mul_add(x, P[3])
184            .mul_add(x, P[2])
185            .mul_add(x, P[1])
186            .mul_add(x, P[0]);
187        let den = Q[4]
188            .mul_add(x, Q[3])
189            .mul_add(x, Q[2])
190            .mul_add(x, Q[1])
191            .mul_add(x, Q[0]);
192        num / den
193    }
194}
195
196/// Convert 8-bit sRGB value to linear f32.
197#[inline]
198pub fn srgb_u8_to_linear(v: u8) -> f32 {
199    // Use lookup table for performance
200    SRGB_TO_LINEAR_LUT[v as usize]
201}
202
203/// Convert 16-bit sRGB value to linear f32.
204#[inline]
205pub fn srgb_u16_to_linear(v: u16) -> f32 {
206    srgb_to_linear(v as f32 / 65535.0)
207}
208
209// Precomputed lookup table for sRGB u8 -> linear f32
210// Generated with: (0..256).map(|i| srgb_to_linear(i as f32 / 255.0))
211static SRGB_TO_LINEAR_LUT: std::sync::LazyLock<[f32; 256]> = std::sync::LazyLock::new(|| {
212    let mut lut = [0.0f32; 256];
213    for (i, entry) in lut.iter_mut().enumerate() {
214        *entry = srgb_to_linear(i as f32 / 255.0);
215    }
216    lut
217});
218
219// =============================================================================
220// imgref implementations
221// =============================================================================
222
223#[cfg(feature = "imgref")]
224mod imgref_impl {
225    use super::*;
226    use imgref::ImgRef;
227
228    /// RGB u8 (sRGB) -> Linear RGB
229    impl ToLinearRgb for ImgRef<'_, [u8; 3]> {
230        fn to_linear_rgb(&self) -> LinearRgbImage {
231            let data: Vec<[f32; 3]> = self
232                .pixels()
233                .map(|[r, g, b]| {
234                    [
235                        srgb_u8_to_linear(r),
236                        srgb_u8_to_linear(g),
237                        srgb_u8_to_linear(b),
238                    ]
239                })
240                .collect();
241            LinearRgbImage::new(data, self.width(), self.height())
242        }
243    }
244
245    /// RGB u16 (sRGB) -> Linear RGB
246    impl ToLinearRgb for ImgRef<'_, [u16; 3]> {
247        fn to_linear_rgb(&self) -> LinearRgbImage {
248            let data: Vec<[f32; 3]> = self
249                .pixels()
250                .map(|[r, g, b]| {
251                    [
252                        srgb_u16_to_linear(r),
253                        srgb_u16_to_linear(g),
254                        srgb_u16_to_linear(b),
255                    ]
256                })
257                .collect();
258            LinearRgbImage::new(data, self.width(), self.height())
259        }
260    }
261
262    /// RGB f32 (already linear) -> Linear RGB
263    impl ToLinearRgb for ImgRef<'_, [f32; 3]> {
264        fn to_linear_rgb(&self) -> LinearRgbImage {
265            let data: Vec<[f32; 3]> = self.pixels().collect();
266            LinearRgbImage::new(data, self.width(), self.height())
267        }
268    }
269
270    /// Grayscale u8 (sRGB) -> Linear RGB
271    impl ToLinearRgb for ImgRef<'_, u8> {
272        fn to_linear_rgb(&self) -> LinearRgbImage {
273            let data: Vec<[f32; 3]> = self
274                .pixels()
275                .map(|v| {
276                    let l = srgb_u8_to_linear(v);
277                    [l, l, l]
278                })
279                .collect();
280            LinearRgbImage::new(data, self.width(), self.height())
281        }
282    }
283
284    /// Grayscale f32 (linear) -> Linear RGB
285    impl ToLinearRgb for ImgRef<'_, f32> {
286        fn to_linear_rgb(&self) -> LinearRgbImage {
287            let data: Vec<[f32; 3]> = self.pixels().map(|v| [v, v, v]).collect();
288            LinearRgbImage::new(data, self.width(), self.height())
289        }
290    }
291}
292
293// =============================================================================
294// yuvxyb compatibility
295// =============================================================================
296
297impl ToLinearRgb for yuvxyb::LinearRgb {
298    fn to_linear_rgb(&self) -> LinearRgbImage {
299        LinearRgbImage::new(
300            self.data().to_vec(),
301            self.width().get(),
302            self.height().get(),
303        )
304    }
305
306    fn into_linear_rgb(self) -> LinearRgbImage {
307        let width = self.width().get();
308        let height = self.height().get();
309        LinearRgbImage::new(self.into_data(), width, height)
310    }
311}
312
313// =============================================================================
314// Conversion to yuvxyb::LinearRgb (for internal pipeline)
315// =============================================================================
316
317impl From<LinearRgbImage> for yuvxyb::LinearRgb {
318    fn from(img: LinearRgbImage) -> Self {
319        use std::num::NonZeroUsize;
320        // `LinearRgbImage::try_new` enforces nonzero dimensions and
321        // `data.len() == width * height`, so the conversions below cannot fail.
322        // We assert defensively in case `LinearRgbImage` was constructed
323        // without going through the validated constructor (e.g., by an
324        // internal `pub(crate)` field assignment that bypassed validation).
325        let width = NonZeroUsize::new(img.width)
326            .expect("LinearRgbImage width is nonzero (try_new invariant)");
327        let height = NonZeroUsize::new(img.height)
328            .expect("LinearRgbImage height is nonzero (try_new invariant)");
329        assert_eq!(
330            img.data.len(),
331            width.get().saturating_mul(height.get()),
332            "LinearRgbImage data length must equal width * height (try_new invariant)"
333        );
334        yuvxyb::LinearRgb::new(img.data, width, height)
335            .expect("LinearRgbImage dimensions are valid (try_new invariant)")
336    }
337}
338
339impl ToLinearRgb for yuvxyb::Rgb {
340    fn to_linear_rgb(&self) -> LinearRgbImage {
341        if self.transfer() == yuvxyb::TransferCharacteristic::SRGB {
342            // Use our own IEC 61966-2-1 sRGB linearization (standard constants)
343            // instead of yuvxyb's smoothed variant, for C++ ssimulacra2 parity.
344            let data: Vec<[f32; 3]> = self
345                .data()
346                .iter()
347                .map(|&[r, g, b]| [srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b)])
348                .collect();
349            LinearRgbImage::new(data, self.width().get(), self.height().get())
350        } else {
351            // For non-sRGB transfers, fall back to yuvxyb's conversion
352            let linear: yuvxyb::LinearRgb = yuvxyb::LinearRgb::try_from(self.clone())
353                .expect("Rgb to LinearRgb conversion should not fail");
354            linear.to_linear_rgb()
355        }
356    }
357
358    fn into_linear_rgb(self) -> LinearRgbImage {
359        let width = self.width().get();
360        let height = self.height().get();
361        if self.transfer() == yuvxyb::TransferCharacteristic::SRGB {
362            // Consume the Rgb, linearize in-place — zero allocation
363            let mut data = self.into_data();
364            for pixel in &mut data {
365                pixel[0] = srgb_to_linear(pixel[0]);
366                pixel[1] = srgb_to_linear(pixel[1]);
367                pixel[2] = srgb_to_linear(pixel[2]);
368            }
369            LinearRgbImage::new(data, width, height)
370        } else {
371            let linear: yuvxyb::LinearRgb = yuvxyb::LinearRgb::try_from(self)
372                .expect("Rgb to LinearRgb conversion should not fail");
373            linear.into_linear_rgb()
374        }
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn test_srgb_to_linear_bounds() {
384        assert!((srgb_to_linear(0.0) - 0.0).abs() < 1e-6);
385        assert!((srgb_to_linear(1.0) - 1.0).abs() < 1e-6);
386    }
387
388    #[test]
389    fn test_srgb_to_linear_midpoint() {
390        // sRGB 0.5 should be approximately 0.214 in linear
391        let linear = srgb_to_linear(0.5);
392        assert!((linear - 0.214).abs() < 0.01);
393    }
394
395    #[test]
396    fn test_srgb_u8_to_linear() {
397        assert!((srgb_u8_to_linear(0) - 0.0).abs() < 1e-6);
398        assert!((srgb_u8_to_linear(255) - 1.0).abs() < 1e-6);
399    }
400
401    #[test]
402    fn test_linear_rgb_image_accessors() {
403        let data = vec![[0.5, 0.3, 0.1], [0.2, 0.4, 0.6]];
404        let img = LinearRgbImage::new(data.clone(), 2, 1);
405
406        assert_eq!(img.width(), 2);
407        assert_eq!(img.height(), 1);
408        assert_eq!(img.data(), &data[..]);
409    }
410
411    #[test]
412    fn test_try_new_rejects_zero_dimension() {
413        let err = LinearRgbImage::try_new(vec![], 0, 4).unwrap_err();
414        assert_eq!(err, LinearRgbImageError::ZeroDimension);
415        let err = LinearRgbImage::try_new(vec![], 4, 0).unwrap_err();
416        assert_eq!(err, LinearRgbImageError::ZeroDimension);
417    }
418
419    #[test]
420    fn test_try_new_rejects_dimension_overflow() {
421        // usize::MAX * 2 always overflows on every supported target.
422        let err = LinearRgbImage::try_new(vec![], usize::MAX, 2).unwrap_err();
423        assert_eq!(err, LinearRgbImageError::DimensionOverflow);
424    }
425
426    #[test]
427    fn test_try_new_rejects_data_length_mismatch() {
428        let err = LinearRgbImage::try_new(vec![[0.0; 3]; 3], 2, 2).unwrap_err();
429        assert!(matches!(
430            err,
431            LinearRgbImageError::DataLengthMismatch {
432                expected: 4,
433                actual: 3
434            }
435        ));
436    }
437
438    #[test]
439    fn test_try_new_accepts_valid_input() {
440        let img = LinearRgbImage::try_new(vec![[0.5, 0.3, 0.1]; 6], 3, 2).unwrap();
441        assert_eq!(img.width(), 3);
442        assert_eq!(img.height(), 2);
443    }
444
445    #[test]
446    #[should_panic(expected = "LinearRgbImage::new: invalid dimensions or data length")]
447    fn test_new_panics_on_zero_dimension_in_release() {
448        // This panic now fires in release as well as debug builds — previously
449        // only `debug_assert_eq!` validated, so release-mode misuse silently
450        // produced a malformed image that would later panic deep in
451        // `From<LinearRgbImage> for yuvxyb::LinearRgb`.
452        let _ = LinearRgbImage::new(vec![], 0, 4);
453    }
454
455    #[test]
456    fn test_yuvxyb_linearrgb_roundtrip() {
457        use std::num::NonZeroUsize;
458        let data = vec![[0.5, 0.3, 0.1]; 4];
459        let yuvxyb_img = yuvxyb::LinearRgb::new(
460            data.clone(),
461            NonZeroUsize::new(2).unwrap(),
462            NonZeroUsize::new(2).unwrap(),
463        )
464        .expect("valid dimensions");
465
466        let our_img = yuvxyb_img.to_linear_rgb();
467        assert_eq!(our_img.width(), 2);
468        assert_eq!(our_img.height(), 2);
469        assert_eq!(our_img.data(), &data[..]);
470
471        // Convert back
472        let back: yuvxyb::LinearRgb = our_img.into();
473        assert_eq!(back.data(), &data[..]);
474    }
475}
476
477#[cfg(all(test, feature = "imgref"))]
478mod imgref_tests {
479    use super::*;
480    use imgref::{Img, ImgVec};
481
482    #[test]
483    fn test_imgref_u8_srgb_conversion() {
484        // Create a 2x2 sRGB image
485        let pixels: Vec<[u8; 3]> = vec![
486            [0, 0, 0],       // black
487            [255, 255, 255], // white
488            [128, 128, 128], // mid gray
489            [255, 0, 0],     // red
490        ];
491        let img: ImgVec<[u8; 3]> = Img::new(pixels, 2, 2);
492
493        let linear = img.as_ref().to_linear_rgb();
494        assert_eq!(linear.width(), 2);
495        assert_eq!(linear.height(), 2);
496
497        // Black should be [0, 0, 0]
498        assert!((linear.data()[0][0] - 0.0).abs() < 1e-6);
499        // White should be [1, 1, 1]
500        assert!((linear.data()[1][0] - 1.0).abs() < 1e-6);
501        assert!((linear.data()[1][1] - 1.0).abs() < 1e-6);
502        // Mid gray (sRGB 128) should be ~0.215 in linear
503        assert!((linear.data()[2][0] - 0.215).abs() < 0.01);
504        // Red should have R=1, G=B=0
505        assert!((linear.data()[3][0] - 1.0).abs() < 1e-6);
506        assert!((linear.data()[3][1] - 0.0).abs() < 1e-6);
507    }
508
509    #[test]
510    fn test_imgref_f32_passthrough() {
511        // f32 is assumed to already be linear - should pass through unchanged
512        let pixels: Vec<[f32; 3]> = vec![[0.5, 0.3, 0.1], [0.9, 0.8, 0.7]];
513        let img: ImgVec<[f32; 3]> = Img::new(pixels.clone(), 2, 1);
514
515        let linear = img.as_ref().to_linear_rgb();
516        assert_eq!(linear.data(), &pixels[..]);
517    }
518
519    #[test]
520    fn test_imgref_grayscale_u8_expansion() {
521        // Grayscale u8 should expand to R=G=B and apply sRGB conversion
522        let pixels: Vec<u8> = vec![0, 255, 128];
523        let img: ImgVec<u8> = Img::new(pixels, 3, 1);
524
525        let linear = img.as_ref().to_linear_rgb();
526
527        // Black
528        let black = linear.data()[0];
529        assert!((black[0] - 0.0).abs() < 1e-6);
530        assert_eq!(black[0], black[1]);
531        assert_eq!(black[1], black[2]);
532
533        // White
534        let white = linear.data()[1];
535        assert!((white[0] - 1.0).abs() < 1e-6);
536        assert_eq!(white[0], white[1]);
537
538        // Mid gray
539        let gray = linear.data()[2];
540        assert!((gray[0] - 0.215).abs() < 0.01);
541        assert_eq!(gray[0], gray[1]);
542    }
543
544    #[test]
545    fn test_imgref_grayscale_f32_expansion() {
546        // Grayscale f32 should expand to R=G=B (already linear)
547        let pixels: Vec<f32> = vec![0.0, 1.0, 0.5];
548        let img: ImgVec<f32> = Img::new(pixels, 3, 1);
549
550        let linear = img.as_ref().to_linear_rgb();
551
552        assert_eq!(linear.data()[0], [0.0, 0.0, 0.0]);
553        assert_eq!(linear.data()[1], [1.0, 1.0, 1.0]);
554        assert_eq!(linear.data()[2], [0.5, 0.5, 0.5]);
555    }
556
557    #[test]
558    fn test_compute_ssimulacra2_with_imgref_u8() {
559        use crate::compute_ssimulacra2;
560
561        // Create two 16x16 images (minimum viable for SSIMULACRA2)
562        let pixels1: Vec<[u8; 3]> = vec![[128, 128, 128]; 16 * 16];
563        let pixels2: Vec<[u8; 3]> = vec![[130, 130, 130]; 16 * 16]; // slightly different
564
565        let img1: ImgVec<[u8; 3]> = Img::new(pixels1, 16, 16);
566        let img2: ImgVec<[u8; 3]> = Img::new(pixels2, 16, 16);
567
568        // Should compute successfully
569        let score = compute_ssimulacra2(img1.as_ref(), img2.as_ref()).unwrap();
570        // Small difference should result in high score (close to 100)
571        assert!(
572            score > 90.0,
573            "Score {score} should be > 90 for very similar images"
574        );
575    }
576
577    #[test]
578    fn test_compute_ssimulacra2_identical_imgref() {
579        use crate::compute_ssimulacra2;
580
581        // Identical images should score 100
582        let pixels: Vec<[u8; 3]> = vec![[100, 150, 200]; 16 * 16];
583        let img: ImgVec<[u8; 3]> = Img::new(pixels, 16, 16);
584
585        let score = compute_ssimulacra2(img.as_ref(), img.as_ref()).unwrap();
586        assert!(
587            (score - 100.0).abs() < 0.01,
588            "Identical images should score 100, got {score}"
589        );
590    }
591
592    #[test]
593    fn test_precompute_with_imgref() {
594        use crate::Ssimulacra2Reference;
595
596        // Create source and distorted images
597        let source_pixels: Vec<[u8; 3]> = vec![[128, 128, 128]; 32 * 32];
598        let distorted_pixels: Vec<[u8; 3]> = vec![[130, 128, 126]; 32 * 32];
599
600        let source: ImgVec<[u8; 3]> = Img::new(source_pixels, 32, 32);
601        let distorted: ImgVec<[u8; 3]> = Img::new(distorted_pixels, 32, 32);
602
603        // Use precompute API with imgref
604        let reference = Ssimulacra2Reference::new(source.as_ref()).unwrap();
605        let score = reference.compare(distorted.as_ref()).unwrap();
606
607        // Should compute successfully with reasonable score
608        assert!(
609            score > 80.0,
610            "Score {score} should be > 80 for similar images"
611        );
612    }
613}