1use std::path::Path;
9
10use image::{Rgba, Rgba32FImage, RgbaImage};
11
12use crate::thumbnail::ThumbError;
13
14pub 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
23pub 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
29pub fn stops_to_exposure(stops: f32) -> f32 {
31 stops.exp2()
32}
33
34fn 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 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
46pub fn tone_map(img: &Rgba32FImage, exposure: f32) -> RgbaImage {
50 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 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 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}