Skip to main content

libblackbody/
thermogram_trait.rs

1use std::cmp::{Ordering, PartialOrd};
2use std::fs::File;
3use std::path::PathBuf;
4
5use image::{ColorType, save_buffer};
6use tiff::encoder::*;
7use ndarray::*;
8
9use crate::palettes;
10
11/// All supported thermogram formats implement this trait.
12///
13/// ```rust
14/// pub trait ThermogramTrait {
15///     fn thermal(&self) -> &Array<f32, Ix2>;  // Extract the thermal data
16///     fn optical(&self) -> &Array<u8, Ix3>>;  // Extract embedded photos, if present
17///     fn identifier(&self) -> &str;  // A uniquely identifying string for this thermogram
18///     fn render(&self min_temp: f32, max_temp: f32, palette: [[f32; 3]; 256]) -> Array<u8, Ix3>;  // Thermal data render using the given palette
19///     fn render_defaults(&self) -> Array<u8, Ix3>;  // Thermal data rendered using the minimum and maximum thermal value and the `palette::TURBO` palette.
20///     fn thermal_shape(&self) -> [usize; 2];  // The [height, width] of the thermal data
21///     fn normalized_minmax(&self) -> Array<f32, Ix2>;  // Thermal data normalized to lie in the range 0.0..=1.0
22/// }
23/// ```
24pub trait ThermogramTrait {
25    /// Returns a reference to the 2D array of thermal data in celsius.
26    fn thermal(&self) -> &Array<f32, Ix2>;
27
28    /// Returns reference to the raw RGB values of the thermogram's corresponding optical photo, if
29    /// present. Otherwise `None`.
30    fn optical(&self) -> Option<&Array<u8, Ix3>>;
31
32    /// Provide the identifier for this thermogram, which is typically the file path. It can also be
33    /// a randomly generated uuid or similar, however, if there is no path associated with the data.
34    fn identifier(&self) -> &str;
35
36    // Returns the file path, or `None` if not a file.
37    fn path(&self) -> Option<&str>;
38
39    /// Render the thermogram with the given color palette and using the given minimum and maximum
40    /// temperature bounds.
41    ///
42    /// All values are clipped to be between the minimum and maximum value, then put in one of 256
43    /// bins. Each bin is mapped to one of the colors in the palette to render an RGB color value.
44    ///
45    /// # Arguments
46    /// * `min_temp` - The temperature value, and all values below it, that needs to be mapped to
47    ///     the first color in the palette.
48    /// * `max_temp` - The temperature value, and all values above it, that needs to be mapped to
49    ///     the last color in the palette.
50    /// * `palette` - A collection of 256 colors to which the 256 bins will be mapped.
51    ///
52    /// # Returns
53    /// A three-dimensional RGB array of u8 values between 0 and 255.
54    fn render(&self, min_temp: f32, max_temp: f32, palette: [[f32; 3]; 256]) -> Array<u8, Ix3> {
55        let num_bands = 3;
56        let map_color = |v: &f32| {
57            let idx = match (min_temp.partial_cmp(v), max_temp.partial_cmp(v)) {
58                (Some(Ordering::Greater), _) => 0,
59                (_, Some(Ordering::Less)) => 255,
60                (_, _) => ((v - min_temp) / (max_temp - min_temp) * 255f32) as usize,
61            };
62
63            let to_u8 = |f| (f * 255.0) as u8;
64            let color = [
65                // Create color array sized [u8; num_bands]
66                to_u8(palette[idx][0]),
67                to_u8(palette[idx][1]),
68                to_u8(palette[idx][2]),
69            ];
70
71            // Create iterator out of the array so we can use this in flat_map
72            (0..num_bands).map(move |i| color[i])
73        };
74
75        // Convert thermal array into a color array by iterating over all values,
76        // converting thermal values to RGB arrays, flattening the result into a
77        // single vector of u8s. Lastly we recreate an ndarray with the shape
78        // (height, width, num_bands) from this vector.
79        let colored_array: Vec<u8> = self.thermal().iter().flat_map(map_color).collect();
80
81        let width = self.thermal().ncols();
82        let height = self.thermal().nrows();
83        Array::from_shape_vec((height, width, num_bands), colored_array).unwrap()
84    }
85
86    /// Render the thermogram using the minimum and maximum thermal value and the
87    // `palette::TURBO` palette.
88    fn render_defaults(&self) -> Array<u8, Ix3> {
89        self.render(self.min_temp(), self.max_temp(), palettes::TURBO)
90    }
91
92    /// Export thermal data to a tiff file.
93    ///
94    /// # Arguments
95    /// `path` - Where to save the thermogram export to. Regardless of the file extension, a tiff
96    ///     file is created.
97    ///
98    /// # Returns
99    /// `Some<()>` in case of success, otherwise `None`.
100    fn export_thermal(&self, path: &PathBuf) -> Option<()> {
101        // TODO Return LibblackbodyErrorEnum with finegrained failure info instead of Option
102        let thermal = self.thermal().iter().map(|v| *v).collect::<Vec<f32>>();
103
104        let width = self.thermal_shape()[1] as u32;
105        let height = self.thermal_shape()[0] as u32;
106        match File::create(path) {  // TODO Return error codes and handle in Blackbody
107            Ok(mut file) => match TiffEncoder::new(&mut file) {
108                Ok(mut tiff) => tiff.write_image::<colortype::Gray32Float>(width, height, &thermal).ok(),
109                _ => None,
110            },
111            _ => None,
112        }
113    }
114
115    /// Save render to file.
116    ///
117    /// # Arguments
118    /// `path` - Where to save the render to. The image type is extrapolated from the extension.
119    /// `min_temp` - The minimum temperature for the render, see `render(..)`.
120    /// `max_temp` - The maximum temperature for the render, see `render(..)`.
121    /// `palette` - The color palette to render the thermogram with, see `render(..)`.
122    ///
123    /// # Returns
124    /// `Some<()>` in case of success, otherwise `None`.
125    fn save_render(
126        &self, path: PathBuf, min_temp: f32, max_temp: f32, palette: [[f32; 3]; 256]
127    ) -> Option<()> {
128        let render = self.render(min_temp, max_temp, palette);
129        let width = render.shape()[1] as u32;
130        let height = render.shape()[0] as u32;
131        let render = render.iter().map(|v| *v).collect::<Vec<u8>>();
132
133        // TODO Return LibblackbodyErrorEnum with finegrained failure info instead of Option
134        save_buffer(path, &render.as_slice(), width, height, ColorType::Rgb8).ok()
135    }
136
137    /// Gives the shape of the thermal data, in the order of [height, width].
138    fn thermal_shape(&self) -> [usize; 2] {
139        let thermal = self.thermal();
140        [thermal.nrows(), thermal.ncols()]
141    }
142
143    fn has_optical(&self) -> bool {
144        self.optical() == None // TODO
145    }
146
147    /// Returns the lowest temperature in the thermogram, or `f32::MAX` if there is no such value.
148    fn min_temp(&self) -> f32 {
149        self.thermal().fold(f32::MAX, |acc, elem| acc.min(*elem))
150    }
151
152    /// Returns the highest temperature in the thermogram, or `f32::MIN` if there is no such value.
153    fn max_temp(&self) -> f32 {
154        self.thermal().fold(f32::MIN, |acc, elem| acc.max(*elem))
155    }
156
157    /// Normalized the thermal array to lie in the 0.0..=1.0 in such a way to prevent division by 0
158    /// errors.
159    fn normalized_minmax(&self) -> Array<f32, Ix2> {
160        let thermal = self.thermal();
161        let max_temp = self.max_temp();
162        let divider = match max_temp == 0.0 {
163            true => self.min_temp() + 0.0000000001,
164            false => max_temp,
165        };
166        (thermal - self.min_temp()) / divider
167    }
168}