├── .cargo
└── config.toml
├── .github
└── workflows
│ └── rust.yml
├── .gitignore
├── .idea
├── .gitignore
├── image-hdr.iml
├── modules.xml
└── vcs.xml
├── Cargo.toml
├── README.md
├── examples
└── readme_example.rs
└── src
├── error.rs
├── exif.rs
├── extensions.rs
├── input.rs
├── io.rs
├── lib.rs
├── poisson.rs
└── stretch.rs
/.cargo/config.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | rustflags = ["-Ctarget-cpu=native"]
3 |
--------------------------------------------------------------------------------
/.github/workflows/rust.yml:
--------------------------------------------------------------------------------
1 | name: Rust
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 |
9 | env:
10 | CARGO_TERM_COLOR: always
11 | RUSTFLAGS: "-Dwarnings"
12 |
13 | jobs:
14 | build:
15 |
16 | runs-on: ubuntu-latest
17 |
18 | steps:
19 | - uses: actions/checkout@v4
20 | - name: Clippy
21 | run: cargo clippy --all-targets --all-features
22 | - name: Tests
23 | run: cargo test --verbose
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 | /Cargo.lock
3 | *.jpg
4 | *.jpeg
5 | *.tiff
6 | *.tif
7 | .DS_Store
8 | */.DS_Store
9 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Editor-based HTTP Client requests
5 | /httpRequests/
6 | # Datasource local storage ignored files
7 | /dataSources/
8 | /dataSources.local.xml
9 |
--------------------------------------------------------------------------------
/.idea/image-hdr.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "image-hdr"
3 | version = "0.6.0"
4 | edition = "2021"
5 | authors = ["Anshul Sanghi "]
6 | description = "An implementation of HDR Radiance Estimation using Poisson Photon Noise Estimator for creating HDR image from a set of images"
7 | homepage = "https://github.com/anshap1719/image-hdr"
8 | repository = "https://github.com/anshap1719/image-hdr"
9 | keywords = ["image", "hdr", "merge"]
10 | categories = ["multimedia"]
11 | license = "Apache-2.0"
12 | readme = "./README.md"
13 |
14 | [lib]
15 | crate-type = ["cdylib", "rlib"]
16 | bench = false
17 |
18 | [dependencies]
19 | image = { version = "0.25.6", default-features = false }
20 | rayon = "1.10"
21 | kamadak-exif = "0.6.1"
22 | rawloader = { version = "0.37", optional = true }
23 | imagepipe = { version = "0.5", optional = true }
24 | thiserror = "2.0.12"
25 | ndarray = { version = "0.16.1", features = ["rayon"] }
26 |
27 | [dev-dependencies]
28 | reqwest = { version = "0.12.15", features = ["blocking"] }
29 | image = { version = "0.25.6", default-features = false, features = [
30 | "jpeg",
31 | "tiff",
32 | ] }
33 |
34 | [features]
35 | default = ["read-raw-image"]
36 | read-raw-image = ["dep:imagepipe", "dep:rawloader"]
37 |
38 | [profile.release]
39 | lto = true
40 | codegen-units = 1
41 | panic = "abort"
42 |
43 | [lints.clippy]
44 | # Clippy lint groups
45 | correctness = { level = "deny", priority = 0 }
46 | suspicious = { level = "deny", priority = 0 }
47 | complexity = { level = "deny", priority = 0 }
48 | perf = { level = "deny", priority = 0 }
49 | style = { level = "deny", priority = 0 }
50 | pedantic = { level = "deny", priority = 0 }
51 | cargo = { level = "deny", priority = 0 }
52 |
53 | # Overrides
54 | too_many_lines = { level = "deny", priority = 1 }
55 | unwrap_used = { level = "deny", priority = 1 }
56 | get_unwrap = { level = "deny", priority = 1 }
57 | fallible_impl_from = { level = "deny", priority = 1 }
58 | module_name_repetitions = { level = "allow", priority = 1 }
59 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # image-hdr 
2 |
3 | This is a rust library which implements the HDR merging algorithm for camera images taken with different exposure
4 | times (or with bracketing). It uses the algorithms described
5 | in https://www.cl.cam.ac.uk/research/rainbow/projects/noise-aware-merging/2020-ppne-mle.pdf, and uses "Poisson Photon
6 | Noise Estimator" equations to estimate final radiances at each pixel position.
7 |
8 | ## Current State
9 |
10 | The library is still in early stages of development, but aims to provide a crate that can handle all HDR merging needs.
11 | Towards that end, the following todos are the top priority:
12 |
13 | - Tone mapping algorithm implementations.
14 | - Improve performance.
15 |
16 | ## Dependencies
17 |
18 | - image-rs: Uses DynamicImage as the output format and storage format between calculations.
19 | - rawloader: For supporting RAW image formats.
20 | - rayon: For doing point calculations in parallel.
21 | - kamadak-exif: For getting image's metadata, specifically exposure time and gain (ISO).
22 |
23 | ## Usage
24 |
25 | ```
26 | let paths = vec!["src/image1.tif", "src/image2.tif", "src/image3.tif"];
27 | let hdr_merge = image_hdr::hdr_merge_images(paths);
28 | let stretched = apply_histogram_stretch(&fusion);
29 |
30 | stretched
31 | .to_rgba16()
32 | .save(format!("src/hdr_merged.tiff"))
33 | .unwrap();
34 | ```
35 |
36 | ## Samples
37 |
38 | ### Given the following 3 exposures:
39 |
40 | 
41 | 
42 | 
43 |
44 | ### Resulting unprocessed image:
45 |
46 | 
47 |
48 | ### After basic processing (Levels and Contrast):
49 |
50 | 
51 |
52 | ## Contributing
53 |
54 | Bug reports and pull requests welcome at https://github.com/anshap1719/image-hdr
55 |
56 | ## Citations
57 |
58 | - Noise-Aware Merging of High Dynamic Range Image Stacks without Camera Calibration by Param Hanji, Fangcheng Zhong, and
59 | Rafa l K. Mantiuk (https://www.cl.cam.ac.uk/~rkm38/pdfs/hanji2020_noise_aware_HDR_merging.pdf)
60 |
--------------------------------------------------------------------------------
/examples/readme_example.rs:
--------------------------------------------------------------------------------
1 | //! The example from the Readme.md
2 | //!
3 | //! Run with `cargo run --example readme_example --no-default-features`
4 |
5 | use std::{io::Read, time::Duration};
6 |
7 | use image_hdr::{
8 | exif::{get_exif_data, get_exposures, get_gains},
9 | input::HDRInput,
10 | stretch::apply_histogram_stretch,
11 | };
12 |
13 | #[derive(Debug, thiserror::Error)]
14 | #[error("{0}")]
15 | enum Error {
16 | Reqwest(#[from] reqwest::Error),
17 | Io(#[from] std::io::Error),
18 | Image(#[from] image::ImageError),
19 | ImageHDR(#[from] image_hdr::Error),
20 | }
21 |
22 | fn main() -> Result<(), Error> {
23 | let image_urls = [
24 | (
25 | "https://image-hdr-assets.s3.ap-south-1.amazonaws.com/DSC00001+Large.jpeg",
26 | 1.0 / 640.0,
27 | ),
28 | (
29 | "https://image-hdr-assets.s3.ap-south-1.amazonaws.com/DSC00002+Large.jpeg",
30 | 1.0 / 4000.0,
31 | ),
32 | (
33 | "https://image-hdr-assets.s3.ap-south-1.amazonaws.com/DSC00003+Large.jpeg",
34 | 1.0 / 80.0,
35 | ),
36 | ];
37 |
38 | let mut images = Vec::with_capacity(image_urls.len());
39 | let mut buf = Vec::new();
40 | for (url, exposure) in image_urls {
41 | let file_name = url
42 | .split('/')
43 | .next_back()
44 | .expect("Expected filename as last component in url");
45 |
46 | if std::path::Path::exists(file_name.as_ref()) {
47 | println!("Using cached image: {url}");
48 |
49 | let _ = std::fs::File::open(file_name)?.read_to_end(&mut buf)?;
50 | } else {
51 | let mut response = reqwest::blocking::get(url)?;
52 | println!("Downloading image: {url}");
53 |
54 | let _ = response.read_to_end(&mut buf)?;
55 | // we ignore failing to cache the image
56 | let _ = std::fs::write(file_name, &buf);
57 | }
58 |
59 | let exif = get_exif_data(&buf)?;
60 | let gains = get_gains(&exif).unwrap_or(1.0);
61 | let exposure = get_exposures(&exif).unwrap_or(exposure);
62 |
63 | println!("Loading image: {url}");
64 |
65 | let image = image::load_from_memory_with_format(&buf, image::ImageFormat::Jpeg)?;
66 |
67 | println!("Adding image: {url}");
68 | dbg!(gains, exposure);
69 |
70 | images.push(HDRInput::with_image(
71 | &image,
72 | Duration::from_secs_f32(exposure),
73 | gains,
74 | )?);
75 |
76 | buf.clear();
77 | }
78 |
79 | println!("Mergin images...");
80 | let hdr_merged = image_hdr::hdr_merge_images(&mut images.into())?;
81 | let stretched = apply_histogram_stretch(&hdr_merged)?;
82 |
83 | println!("Saving merged image...");
84 | stretched
85 | .to_rgba16()
86 | .save("./DSC00001-DSC00003+Large.tiff")?;
87 |
88 | Ok(())
89 | }
90 |
--------------------------------------------------------------------------------
/src/error.rs:
--------------------------------------------------------------------------------
1 | //! Error definitions for the library
2 |
3 | use image::ImageError;
4 | #[cfg(feature = "read-raw-image")]
5 | use rawloader::RawLoaderError;
6 | use std::fmt::{Debug, Display, Formatter};
7 | use std::io;
8 | use thiserror::Error;
9 |
10 | /// Represents error occurred during the raw image decoding pipeline.
11 | #[derive(Error, Debug)]
12 | pub struct RawPipelineError(pub String);
13 |
14 | impl Display for RawPipelineError {
15 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
16 | std::fmt::Display::fmt(&self.0, f)
17 | }
18 | }
19 |
20 | impl From for RawPipelineError {
21 | fn from(value: String) -> Self {
22 | Self(value)
23 | }
24 | }
25 |
26 | /// Represents errors that cannot be categorised as any other error types.
27 | #[derive(Error, Debug)]
28 | pub struct UnknownError(pub String);
29 |
30 | impl Display for UnknownError {
31 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
32 | std::fmt::Display::fmt(&self.0, f)
33 | }
34 | }
35 |
36 | impl From for UnknownError {
37 | fn from(value: String) -> Self {
38 | Self(value)
39 | }
40 | }
41 |
42 | /// Error that can be returned by any of the methods in the library.
43 | #[derive(Debug, Error)]
44 | pub enum Error {
45 | /// Represents image or raw image decoding error
46 | #[error("Unable to decode raw image")]
47 | #[cfg_attr(not(feature = "read-raw-image"), non_exhaustive)]
48 | DecodeError(
49 | #[from]
50 | #[cfg(feature = "read-raw-image")]
51 | RawLoaderError,
52 | ),
53 | /// Represents raw image pipeline error
54 | #[error("Unable to decode raw image")]
55 | RawPipeline(#[from] RawPipelineError),
56 | /// Represents error occurred while reading exif data or if necessary exif data is missing.
57 | #[error("Unable to read exif data")]
58 | ExifError(#[from] exif::Error),
59 | /// Represents error occurred while reading/writing image files
60 | #[error("Unable to read file")]
61 | IoError(#[from] io::Error),
62 | /// Represents error occurred while converting between different image formats or while writing
63 | /// buffers from raw/processed pixel data.
64 | #[error("Unable to process image")]
65 | ImageError(#[from] ImageError),
66 | /// Represents error caused by invalid input to the crate's functions
67 | #[error("Invalid value for {parameter_name:?}: {message:?}")]
68 | InputError {
69 | /// Name of the parameter for which error occurred
70 | parameter_name: String,
71 | /// A message explaining why parameter is invalid
72 | message: String,
73 | },
74 | /// Represents errors that cannot be categorised as any other error types.
75 | #[error("{0}")]
76 | UnknownError(#[from] UnknownError),
77 | }
78 |
--------------------------------------------------------------------------------
/src/exif.rs:
--------------------------------------------------------------------------------
1 | //! Helpers to extract necessary EXIF information from source images
2 |
3 | use crate::Error;
4 | use exif::{Exif, In, Tag, Value};
5 |
6 | /// Extract the exif information from the bytes of an image file
7 | ///
8 | /// # Errors
9 | /// - failed to extract exif data
10 | pub fn get_exif_data(data: &[u8]) -> Result {
11 | let mut buf_reader = std::io::Cursor::new(data);
12 | let exif_reader = exif::Reader::new();
13 | let exif = exif_reader.read_from_container(&mut buf_reader)?;
14 |
15 | Ok(exif)
16 | }
17 |
18 | /// Extract the exposure time in seconds from exif information
19 | ///
20 | /// # Errors
21 | /// - failed to exposure from exif data
22 | pub fn get_exposures(exif: &Exif) -> Result {
23 | match exif
24 | .get_field(Tag::ExposureTime, In::PRIMARY)
25 | .ok_or(Error::ExifError(exif::Error::NotFound(
26 | "ExposureTime not found",
27 | )))?
28 | .value
29 | {
30 | Value::Rational(ref v) if !v.is_empty() => Ok(v[0].to_f32()),
31 | _ => Ok(0.),
32 | }
33 | }
34 |
35 | /// Extract the gains from exif information
36 | ///
37 | /// # Errors
38 | /// - failed to gains from exif data
39 | #[allow(clippy::cast_precision_loss)]
40 | pub fn get_gains(exif: &Exif) -> Result {
41 | match exif
42 | .get_field(Tag::ISOSpeed, In::PRIMARY)
43 | .unwrap_or(
44 | exif.get_field(Tag::StandardOutputSensitivity, In::PRIMARY)
45 | .unwrap_or(
46 | exif.get_field(Tag::PhotographicSensitivity, In::PRIMARY)
47 | .ok_or(Error::ExifError(exif::Error::NotFound("ISO not found")))?,
48 | ),
49 | )
50 | .value
51 | {
52 | Value::Long(ref v) if !v.is_empty() => Ok(v[0] as f32),
53 | Value::Short(ref v) if !v.is_empty() => Ok(f32::from(v[0])),
54 | _ => Ok(0.),
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/src/extensions.rs:
--------------------------------------------------------------------------------
1 | //! Extensions on top of dependencies to facilitate the implementations of this library
2 |
3 | use image::{DynamicImage, ImageBuffer, Luma, Rgb};
4 | use ndarray::Array3;
5 |
6 | /// Trait to add the ability to get a nd-array buffer from the target type
7 | pub trait NDArrayBuffer {
8 | /// Get the buffer as `Array3`
9 | fn to_nd_array_buffer(&self) -> Array3;
10 |
11 | /// Generate a new instance of the target from a nd-array buffer.
12 | fn from_nd_array_buffer(buffer: Array3) -> Self;
13 | }
14 |
15 | impl NDArrayBuffer for DynamicImage {
16 | fn to_nd_array_buffer(&self) -> Array3 {
17 | match self {
18 | DynamicImage::ImageLuma8(_)
19 | | DynamicImage::ImageLumaA8(_)
20 | | DynamicImage::ImageLuma16(_)
21 | | DynamicImage::ImageLumaA16(_) => {
22 | let mut buffer =
23 | Array3::::zeros((self.height() as usize, self.width() as usize, 1));
24 |
25 | for (x, y, pixel) in self.to_luma32f().enumerate_pixels() {
26 | buffer[[y as usize, x as usize, 0]] = pixel.0[0];
27 | }
28 |
29 | buffer
30 | }
31 | _ => {
32 | let mut buffer =
33 | Array3::::zeros((self.height() as usize, self.width() as usize, 3));
34 |
35 | for (x, y, pixel) in self.to_rgb32f().enumerate_pixels() {
36 | let [red, green, blue] = pixel.0;
37 |
38 | buffer[[y as usize, x as usize, 0]] = red;
39 | buffer[[y as usize, x as usize, 1]] = green;
40 | buffer[[y as usize, x as usize, 2]] = blue;
41 | }
42 |
43 | buffer
44 | }
45 | }
46 | }
47 |
48 | #[allow(clippy::cast_possible_truncation)]
49 | #[allow(clippy::cast_sign_loss)]
50 | fn from_nd_array_buffer(buffer: Array3) -> Self {
51 | if let (height, width, 1) = buffer.dim() {
52 | let mut result = ImageBuffer::, Vec>::new(width as u32, height as u32);
53 | for (x, y, pixel) in result.enumerate_pixels_mut() {
54 | let intensity = buffer[[y as usize, x as usize, 0]] * f32::from(u16::MAX);
55 | *pixel = Luma([intensity as u16]);
56 | }
57 |
58 | DynamicImage::ImageLuma16(result)
59 | } else if let (height, width, 3) = buffer.dim() {
60 | let mut result = ImageBuffer::, Vec>::new(width as u32, height as u32);
61 | for (x, y, pixel) in result.enumerate_pixels_mut() {
62 | let red = buffer[[y as usize, x as usize, 0]];
63 | let green = buffer[[y as usize, x as usize, 1]];
64 | let blue = buffer[[y as usize, x as usize, 2]];
65 |
66 | *pixel = Rgb([red, green, blue]);
67 | }
68 |
69 | DynamicImage::ImageRgb32F(result)
70 | } else {
71 | panic!("Unexpected dimensions encountered");
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/input.rs:
--------------------------------------------------------------------------------
1 | //! Input type for processing HDR merge
2 |
3 | use crate::exif::{get_exif_data, get_exposures, get_gains};
4 | use crate::extensions::NDArrayBuffer;
5 | use crate::io::read_image;
6 | use crate::Error;
7 | use image::DynamicImage;
8 | use ndarray::Array3;
9 | use rayon::prelude::*;
10 | use std::path::Path;
11 | use std::time::Duration;
12 |
13 | /// Base input item that is used to process the HDR merge
14 | #[derive(Clone)]
15 | pub struct HDRInput {
16 | buffer: Array3,
17 | exposure: f32,
18 | gain: f32,
19 | }
20 |
21 | impl HDRInput {
22 | /// Create new [`HDRInput`] from a given file path. The file must have EXIF data for exposure
23 | /// and gain.
24 | ///
25 | /// # Arguments
26 | ///
27 | /// * `path`: Path to file
28 | ///
29 | /// returns: `Result`
30 | ///
31 | /// # Errors
32 | ///
33 | /// - If image cannot be opened
34 | /// - If image doesn't contain EXIF metadata for exposure and/or gain.
35 | pub fn new(path: &Path) -> Result {
36 | let new_input = Self::try_from(path)?;
37 |
38 | Ok(new_input)
39 | }
40 |
41 | ///
42 | ///
43 | /// # Arguments
44 | ///
45 | /// * `path`:
46 | /// * `exposure`:
47 | /// * `gain`:
48 | ///
49 | /// returns: `Result`
50 | ///
51 | /// # Errors
52 | ///
53 | /// - If image cannot be opened
54 | /// - invalid gain
55 | /// - invalid exposure duration
56 | pub fn with_exposure_and_gain(
57 | path: &Path,
58 | exposure: Duration,
59 | gain: f32,
60 | ) -> Result {
61 | let data = std::fs::read(path)?;
62 | let format = image::ImageFormat::from_path(path).ok();
63 | let image = read_image(&data, format)?;
64 |
65 | Self::with_image(&image, exposure, gain)
66 | }
67 |
68 | ///
69 | /// # Arguments
70 | ///
71 | /// * `image`:
72 | /// * `exposure`:
73 | /// * `gain`:
74 | ///
75 | /// returns: `Result`
76 | ///
77 | /// # Errors
78 | ///
79 | /// - invalid gain
80 | /// - invalid exposure duration
81 | pub fn with_image(image: &DynamicImage, exposure: Duration, gain: f32) -> Result {
82 | if gain.is_infinite() || gain.is_nan() || gain <= 0. {
83 | return Err(Error::InputError {
84 | parameter_name: "gain".to_string(),
85 | message: "Gain must be a valid positive and non-zero floating point number"
86 | .to_string(),
87 | });
88 | }
89 |
90 | if exposure.is_zero() {
91 | return Err(Error::InputError {
92 | parameter_name: "exposure".to_string(),
93 | message: "Exposure must be a positive non-zero duration".to_string(),
94 | });
95 | }
96 |
97 | let buffer = image.to_nd_array_buffer();
98 |
99 | Ok(Self {
100 | buffer,
101 | exposure: exposure.as_secs_f32(),
102 | gain,
103 | })
104 | }
105 |
106 | /// Get exposure of the input item
107 | #[must_use]
108 | pub fn get_exposure(&self) -> f32 {
109 | self.exposure
110 | }
111 |
112 | /// Get gain of the input item
113 | #[must_use]
114 | pub fn get_gain(&self) -> f32 {
115 | self.gain
116 | }
117 |
118 | /// Get underlying image data for the input item
119 | #[must_use]
120 | pub fn get_buffer(&self) -> &Array3 {
121 | &self.buffer
122 | }
123 |
124 | /// Get underlying image data for the input item
125 | #[must_use]
126 | pub fn get_buffer_mut(&mut self) -> &mut Array3 {
127 | &mut self.buffer
128 | }
129 | }
130 |
131 | impl TryFrom<&Path> for HDRInput {
132 | type Error = Error;
133 |
134 | fn try_from(value: &Path) -> Result {
135 | let data = std::fs::read(value)?;
136 | let format = image::ImageFormat::from_path(value).ok();
137 | let image = read_image(&data, format)?;
138 | let exif = get_exif_data(&data)?;
139 | let exposure = get_exposures(&exif)?;
140 | let gain = get_gains(&exif)?;
141 |
142 | Self::with_image(&image, Duration::from_secs_f32(exposure), gain)
143 | }
144 | }
145 |
146 | /// A wrapper for list of [`HDRInput`] for ease of trait implementations.
147 | pub struct HDRInputList(Vec);
148 |
149 | impl HDRInputList {
150 | /// Get list of [`HDRInput`] as a vec.
151 | #[must_use]
152 | pub fn into_vec(self) -> Vec {
153 | self.0
154 | }
155 |
156 | /// Get list of [`HDRInput`] as a slice.
157 | #[must_use]
158 | pub fn as_slice(&self) -> &[HDRInput] {
159 | &self.0
160 | }
161 |
162 | /// Get list of [`HDRInput`] as a slice.
163 | #[must_use]
164 | pub fn as_slice_mut(&mut self) -> &mut [HDRInput] {
165 | &mut self.0
166 | }
167 |
168 | /// Returns the number of elements in the list
169 | #[must_use]
170 | pub fn len(&self) -> usize {
171 | self.0.len()
172 | }
173 |
174 | /// Returns `true` if the vector contains no elements.
175 | #[must_use]
176 | pub fn is_empty(&self) -> bool {
177 | self.len() == 0
178 | }
179 | }
180 |
181 | impl From> for HDRInputList {
182 | fn from(value: Vec) -> Self {
183 | Self(value)
184 | }
185 | }
186 |
187 | impl + Sync> TryFrom<&[P]> for HDRInputList {
188 | type Error = Error;
189 |
190 | fn try_from(value: &[P]) -> Result {
191 | Ok(HDRInputList(
192 | value
193 | .par_iter()
194 | .map(|value| -> Result {
195 | HDRInput::try_from(value.as_ref())
196 | })
197 | .collect::, Self::Error>>()?,
198 | ))
199 | }
200 | }
201 |
--------------------------------------------------------------------------------
/src/io.rs:
--------------------------------------------------------------------------------
1 | //! Helper functions to read and decode images
2 |
3 | use crate::Error;
4 | use image::DynamicImage;
5 |
6 | /// Given a path to a file, attempt to read the image.
7 | /// The function supports reading raw images. All
8 | /// formats and cameras supported by rawloader crate
9 | /// [rawloader](https://github.com/pedrocr/rawloader) are supported.
10 | ///
11 | /// # Errors
12 | /// If image cannot be read
13 | pub(crate) fn read_image(
14 | data: &[u8],
15 | format: Option,
16 | ) -> Result {
17 | let load_result = match format {
18 | Some(format) => image::load_from_memory_with_format(data, format),
19 | None => image::load_from_memory(data),
20 | };
21 |
22 | match load_result {
23 | Ok(image) => Ok(image),
24 | Err(_err) => {
25 | #[cfg(not(feature = "read-raw-image"))]
26 | return Err(_err.into());
27 | #[cfg(feature = "read-raw-image")]
28 | Ok(read_raw_image(data)?)
29 | }
30 | }
31 | }
32 |
33 | /// Given a path to a file, attempt to read the RAW image.
34 | /// All formats and cameras supported by rawloader crate
35 | /// [rawloader](https://github.com/pedrocr/rawloader) are supported.
36 | #[cfg(feature = "read-raw-image")]
37 | pub(crate) fn read_raw_image(data: &[u8]) -> Result {
38 | use crate::error::{RawPipelineError, UnknownError};
39 | use image::{ImageBuffer, Rgb};
40 | use imagepipe::{ImageSource, Pipeline};
41 |
42 | let raw = rawloader::decode(&mut std::io::Cursor::new(data))?;
43 |
44 | let source = ImageSource::Raw(raw);
45 | let mut pipeline = Pipeline::new_from_source(source).map_err(RawPipelineError::from)?;
46 |
47 | pipeline.run(None);
48 |
49 | let image = pipeline
50 | .output_16bit(None)
51 | .map_err(RawPipelineError::from)?;
52 | let image = ImageBuffer::, Vec>::from_raw(
53 | u32::try_from(image.width).map_err(|err| UnknownError::from(err.to_string()))?,
54 | u32::try_from(image.height).map_err(|err| UnknownError::from(err.to_string()))?,
55 | image.data,
56 | );
57 |
58 | match image {
59 | Some(image) => Ok(DynamicImage::ImageRgb16(image)),
60 | None => Err(Error::RawPipeline(RawPipelineError::from(
61 | "Failed to load pipeline output".to_string(),
62 | ))),
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/lib.rs:
--------------------------------------------------------------------------------
1 | //! An implementation of HDR Radiance Estimation using Poisson Photon Noise Estimator for creating HDR image from a set of images
2 | #![allow(clippy::multiple_crate_versions)]
3 |
4 | use image::DynamicImage;
5 | use poisson::calculate_poisson_estimate;
6 |
7 | pub mod error;
8 | pub mod exif;
9 | pub mod extensions;
10 | pub mod input;
11 | mod io;
12 | mod poisson;
13 | pub mod stretch;
14 |
15 | use crate::extensions::NDArrayBuffer;
16 | use crate::input::HDRInputList;
17 | pub use error::Error;
18 |
19 | /// Given a set of file paths, attempt to HDR merge the images
20 | /// and produce a single [`DynamicImage`] (from image-rs crate).
21 | ///
22 | /// # Errors
23 | /// - If image list is empty
24 | /// - If supplied image is not an RGB image. Non RGB images include images with alpha channel, grayscale images, and images with other color encodings (like CMYK).
25 | /// - If images are of different dimensions.
26 | pub fn hdr_merge_images(inputs: &mut HDRInputList) -> Result {
27 | if inputs.len() < 2 {
28 | return Err(Error::InputError {
29 | parameter_name: "paths".to_string(),
30 | message: "At least two images must be provided".to_string(),
31 | });
32 | }
33 |
34 | let phi = calculate_poisson_estimate(inputs.as_slice_mut());
35 |
36 | Ok(DynamicImage::from_nd_array_buffer(phi))
37 | }
38 |
--------------------------------------------------------------------------------
/src/poisson.rs:
--------------------------------------------------------------------------------
1 | //! An implementation of HDR merging via "Poisson Photon Noise Estimator" as introduced in
2 | //! [Noise-Aware Merging of High Dynamic Range Image Stacks without Camera Calibration](https://www.cl.cam.ac.uk/research/rainbow/projects/noise-aware-merging/2020-ppne-mle.pdf)
3 |
4 | use crate::input::HDRInput;
5 | use ndarray::array;
6 | use ndarray::prelude::*;
7 | use rayon::prelude::*;
8 |
9 | const RED_COEFFICIENT: f32 = 1.;
10 | const GREEN_COEFFICIENT: f32 = 1.;
11 | const BLUE_COEFFICIENT: f32 = 1.;
12 |
13 | /// Calculate the poisson estimate for an image.
14 | /// Given a set of image paths, this returns a
15 | /// pixel buffer of the resultant HDR merge of
16 | /// supplied images.
17 | ///
18 | /// For more details on the algorithm used, please
19 | /// refer to [Noise-Aware Merging of High Dynamic Range Image Stacks without Camera Calibration](https://www.cl.cam.ac.uk/research/rainbow/projects/noise-aware-merging/2020-ppne-mle.pdf)
20 | ///
21 | /// specifically the section about "Poisson Photon Noise Estimator"
22 | ///
23 | /// # Errors
24 | /// If supplied image is not an RGB image. Non RGB images
25 | /// include images with alpha channel, grayscale images,
26 | /// and images with other color encodings (like CMYK).
27 | pub(crate) fn calculate_poisson_estimate(inputs: &mut [HDRInput]) -> Array3 {
28 | inputs.par_iter_mut().for_each(|input| {
29 | let scaling_factor = input.get_exposure() * input.get_gain();
30 | let input_buffer = input.get_buffer_mut();
31 |
32 | if let (_, _, 1) = input_buffer.dim() {
33 | *input_buffer /= scaling_factor;
34 | } else if let (_, _, 3) = input_buffer.dim() {
35 | *input_buffer /= &array![[[
36 | scaling_factor * RED_COEFFICIENT,
37 | scaling_factor * GREEN_COEFFICIENT,
38 | scaling_factor * BLUE_COEFFICIENT
39 | ]]];
40 | } else {
41 | panic!("Unexpected scaling matrix encountered.")
42 | }
43 | });
44 |
45 | let shape = inputs
46 | .first()
47 | .unwrap_or_else(|| panic!("Expected at least 1 input image"))
48 | .get_buffer()
49 | .dim();
50 |
51 | let sum_exposures: f32 = inputs.iter().map(HDRInput::get_exposure).sum();
52 |
53 | let normalized_radiances = inputs
54 | .par_iter()
55 | .map(|input| {
56 | let mut radiance = input.get_buffer().clone();
57 | let exposure = input.get_exposure();
58 |
59 | radiance *= exposure / sum_exposures;
60 |
61 | radiance
62 | })
63 | .reduce(
64 | || Array3::::zeros(shape),
65 | |acc, radiance| acc + radiance,
66 | );
67 |
68 | normalized_radiances
69 | }
70 |
--------------------------------------------------------------------------------
/src/stretch.rs:
--------------------------------------------------------------------------------
1 | //! Apply basic histogram stretch to a linear image to make it viewable.
2 |
3 | use crate::extensions::NDArrayBuffer;
4 | use crate::Error;
5 | use image::DynamicImage;
6 | use rayon::prelude::*;
7 |
8 | fn scale_pixel(pixel: f32, min: f32, max: f32) -> f32 {
9 | (pixel - min) * (1. / (max - min))
10 | }
11 |
12 | /// Contrast stretch (normalize) a given image.
13 | ///
14 | /// # Errors
15 | /// - if image cannot be constructed from processed pixels.
16 | pub fn apply_histogram_stretch(image: &DynamicImage) -> Result {
17 | let mut buffer = image.to_nd_array_buffer();
18 |
19 | let input_max_value = buffer.iter().copied().reduce(f32::max).unwrap_or(1.);
20 | let input_min_value = buffer.iter().copied().reduce(f32::min).unwrap_or(0.);
21 |
22 | buffer.par_iter_mut().for_each(|pixel| {
23 | *pixel = scale_pixel(*pixel, input_min_value, input_max_value);
24 | });
25 |
26 | Ok(DynamicImage::from_nd_array_buffer(buffer))
27 | }
28 |
--------------------------------------------------------------------------------