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}