Skip to main content

falcon_library/
hdr.rs

1//! High dynamic range images (Radiance HDR and OpenEXR): decoding to linear
2//! floating point and tone mapping for display.
3//!
4//! Eagle tone-maps these with Reinhard at an exposure of 1.0 and sRGB
5//! output; [`tone_map`] does the same per channel: scale by the exposure,
6//! `c / (1 + c)`, then the linear to sRGB encode.
7
8use std::path::Path;
9
10use image::{Rgba, Rgba32FImage, RgbaImage};
11
12use crate::thumbnail::ThumbError;
13
14/// Whether `ext` (lowercase) is a high dynamic range format.
15pub fn is_hdr(ext: &str) -> bool {
16    matches!(ext, "hdr" | "exr")
17}
18
19fn err(e: impl std::fmt::Display) -> ThumbError {
20    ThumbError(e.to_string())
21}
22
23/// Decodes `path` to linear RGBA floats, turned by its orientation if any.
24pub fn decode_linear(path: &Path) -> Result<Rgba32FImage, ThumbError> {
25    let img = image::ImageReader::open(path).map_err(err)?.with_guessed_format().map_err(err)?.decode().map_err(err)?;
26    Ok(img.into_rgba32f())
27}
28
29/// Exposure multiplier for a slider in stops (EV): 2^stops.
30pub fn stops_to_exposure(stops: f32) -> f32 {
31    stops.exp2()
32}
33
34/// Linear light to an sRGB-encoded value, both 0..1.
35fn srgb_encode(c: f32) -> f32 {
36    if c <= 0.003_130_8 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 }
37}
38
39fn channel(c: f32, exposure: f32) -> u8 {
40    let c = c * exposure;
41    // NaN and negative values come out black; infinity saturates to white.
42    let c = if c.is_nan() || c < 0.0 { 0.0 } else if c.is_infinite() { 1.0 } else { c / (1.0 + c) };
43    (srgb_encode(c) * 255.0 + 0.5).clamp(0.0, 255.0) as u8
44}
45
46/// Tone maps linear `img` for display: Reinhard at `exposure` (a multiplier,
47/// see [`stops_to_exposure`]), sRGB encoded. Alpha is the image's own,
48/// clamped to 0..1 (Radiance files have none, so it is 1).
49pub fn tone_map(img: &Rgba32FImage, exposure: f32) -> RgbaImage {
50    // Encoding is a pow per channel; a table over 12 bits of the Reinhard
51    // output would be coarser than the 8-bit result needs, but the direct
52    // maths is simple and fast enough per pixel here.
53    let mut out = RgbaImage::new(img.width(), img.height());
54    for (src, dst) in img.pixels().zip(out.pixels_mut()) {
55        let Rgba([r, g, b, a]) = *src;
56        let a = if a.is_nan() { 1.0 } else { a.clamp(0.0, 1.0) };
57        *dst = Rgba([channel(r, exposure), channel(g, exposure), channel(b, exposure), (a * 255.0 + 0.5) as u8]);
58    }
59    out
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    fn gradient() -> Rgba32FImage {
67        Rgba32FImage::from_fn(4, 3, |x, y| Rgba([x as f32 * 0.5, y as f32, 4.0, 1.0]))
68    }
69
70    #[test]
71    fn extensions() {
72        assert!(is_hdr("hdr") && is_hdr("exr"));
73        assert!(!is_hdr("png") && !is_hdr("HDR"));
74    }
75
76    #[test]
77    fn radiance_round_trips() {
78        let dir = tempfile::tempdir().unwrap();
79        let path = dir.path().join("x.hdr");
80        let img = gradient();
81        let pixels: Vec<image::Rgb<f32>> = img.pixels().map(|p| image::Rgb([p[0], p[1], p[2]])).collect();
82        let file = std::io::BufWriter::new(std::fs::File::create(&path).unwrap());
83        image::codecs::hdr::HdrEncoder::new(file).encode(&pixels, 4, 3).unwrap();
84        let back = decode_linear(&path).unwrap();
85        assert_eq!(back.dimensions(), (4, 3));
86        let p = back.get_pixel(2, 1);
87        // RGBE keeps about 8 bits of mantissa shared by the channels.
88        assert!((p[0] - 1.0).abs() < 0.03 && (p[1] - 1.0).abs() < 0.03 && (p[2] - 4.0).abs() < 0.05, "{p:?}");
89        assert_eq!(p[3], 1.0);
90    }
91
92    #[test]
93    fn openexr_round_trips() {
94        let dir = tempfile::tempdir().unwrap();
95        let path = dir.path().join("x.exr");
96        let img = Rgba32FImage::from_fn(4, 3, |x, y| Rgba([x as f32 * 0.5, y as f32, 4.0, 0.5]));
97        image::DynamicImage::ImageRgba32F(img).save(&path).unwrap();
98        let back = decode_linear(&path).unwrap();
99        assert_eq!(back.dimensions(), (4, 3));
100        assert_eq!(*back.get_pixel(2, 1), Rgba([1.0, 1.0, 4.0, 0.5]));
101        assert_eq!(tone_map(&back, 1.0).get_pixel(0, 0)[3], 128, "the file's alpha is kept");
102    }
103
104    #[test]
105    fn reinhard_srgb() {
106        let img = gradient();
107        let out = tone_map(&img, 1.0);
108        assert_eq!(out.get_pixel(0, 0).0[..2], [0, 0], "zero stays black");
109        // 1.0 -> 0.5 linear -> 0.7354 sRGB.
110        assert_eq!(out.get_pixel(2, 0)[0], 188);
111        assert_eq!(out.get_pixel(0, 0)[3], 255);
112        let bright = tone_map(&img, 4.0);
113        assert!(bright.get_pixel(2, 0)[0] > out.get_pixel(2, 0)[0]);
114        let dark = tone_map(&img, 0.25);
115        assert!(dark.get_pixel(2, 0)[0] < out.get_pixel(2, 0)[0]);
116        let ramp: Vec<u8> = (0..40).map(|i| channel(i as f32 * 0.25, 1.0)).collect();
117        assert!(ramp.windows(2).all(|w| w[0] <= w[1]));
118        assert_eq!(channel(f32::NAN, 1.0), 0);
119        assert_eq!(channel(-3.0, 1.0), 0);
120        assert_eq!(channel(f32::INFINITY, 1.0), 255);
121    }
122
123    #[test]
124    fn stops() {
125        assert_eq!(stops_to_exposure(0.0), 1.0);
126        assert_eq!(stops_to_exposure(2.0), 4.0);
127        assert_eq!(stops_to_exposure(-1.0), 0.5);
128    }
129}