├── .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 ![](https://github.com/anshap1719/image-hdr/actions/workflows/rust.yml/badge.svg) 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 | ![alt "1/640s"](https://image-hdr-assets.s3.ap-south-1.amazonaws.com/DSC00001+Large.jpeg) 41 | ![alt "1/4000s"](https://image-hdr-assets.s3.ap-south-1.amazonaws.com/DSC00002+Large.jpeg) 42 | ![alt "1/80s"](https://image-hdr-assets.s3.ap-south-1.amazonaws.com/DSC00003+Large.jpeg) 43 | 44 | ### Resulting unprocessed image: 45 | 46 | ![alt "Merged image"](https://image-hdr-assets.s3.ap-south-1.amazonaws.com/hdr_merged+Large.jpeg) 47 | 48 | ### After basic processing (Levels and Contrast): 49 | 50 | ![alt "Processed image"](https://image-hdr-assets.s3.ap-south-1.amazonaws.com/Processed+Large.jpeg) 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 | --------------------------------------------------------------------------------