image_conv/lib.rs
1//! # image-conv — High-Performance Image Convolution
2//!
3//! This library applies convolution filters to images. A convolution slides a small
4//! matrix (the **kernel**, or filter) over every pixel of the image, multiplying
5//! overlapping values and summing the results to produce each output pixel.
6//!
7//! ## Architecture
8//!
9//! ```text
10//! ┌──────────────┐ ┌─────────────────────┐ ┌──────────────┐
11//! │ Input Image │────▶│ convolution() │────▶│ Output Image │
12//! │ (PhotonImage) │ │ ├─ try_separable() │ │ (PhotonImage)│
13//! └──────────────┘ │ │ ├─ Y: separable │ └──────────────┘
14//! │ │ └─ N: 2D fallback │
15//! ┌──────────────┐ │ ├─ padding │
16//! │ Filter │────▶│ └─ output dimensions│
17//! └──────────────┘ └─────────────────────┘
18//! ```
19//!
20//! The `Filter` struct holds a convolution kernel (also called a "mask" or "window").
21//! `convolution()` auto-detects whether the kernel is **separable** — if so, it
22//! decomposes the 2D convolution into two 1D passes for a major speedup (e.g.
23//! a 15×15 Gaussian goes from O(225) to O(30) per pixel, ~7.5× faster).
24//!
25//! ## Example
26//! ```no_run
27//! use image_conv::conv;
28//! use image_conv::{Filter, PaddingType};
29//! use photon_rs::native::{open_image, save_image};
30//!
31//! fn main() {
32//! let mut img = open_image("img.jpg").expect("No such file found");
33//!
34//! // Sobel-X edge detection kernel (3×3)
35//! let sobel_x: Vec<f32> = vec![1.0, 0.0, -1.0, 2.0, 0.0, -2.0, 1.0, 0.0, -1.0];
36//! let filter = Filter::from(sobel_x, 3, 3);
37//!
38//! // Automatically detected as separable: col=[1,2,1] × row=[1,0,-1]
39//! let img_conv = conv::convolution(&img, filter, 1, PaddingType::UNIFORM(1));
40//! save_image(img_conv, "img_conv.jpg");
41//! }
42//! ```
43
44pub mod conv;
45
46use image::DynamicImage::{self, ImageRgba8};
47use photon_rs::{helpers, PhotonImage};
48use prettytable::{Cell, Row, Table};
49
50/// A 2D convolution kernel (filter / mask / window).
51///
52/// ## Kernel Layout
53///
54/// The kernel is stored in **row-major** order as a flat `Vec<f32>`.
55/// For a 3×3 Sobel-X kernel:
56///
57/// ```text
58/// j=0 j=1 j=2 Flat storage:
59/// i=0 [ 1 0 -1 ] index: 0 1 2 3 4 5 6 7 8
60/// i=1 [ 2 0 -2 ] value: 1, 0,-1, 2, 0,-2, 1, 0,-1
61/// i=2 [ 1 0 -1 ]
62/// ```
63///
64/// Access formula: `kernel[i * width + j]`
65#[derive(Clone)]
66pub struct Filter {
67 width: usize,
68 height: usize,
69 kernel: Vec<f32>,
70}
71
72impl Filter {
73 /// Creates a zero-initialised filter of given dimensions.
74 ///
75 /// ```text
76 /// Filter::new(3, 3) → [0, 0, 0, 0, 0, 0, 0, 0, 0]
77 /// ```
78 pub fn new(width: usize, height: usize) -> Self {
79 let mut kernel = Vec::<f32>::new();
80 Vec::resize(&mut kernel, width * height, 0_f32);
81 Self { width, height, kernel }
82 }
83
84 /// Creates a filter from a pre-computed flat kernel buffer.
85 ///
86 /// The buffer must have exactly `width × height` elements, stored
87 /// row-major (row 0 first, then row 1, etc.).
88 ///
89 /// # Panics
90 /// Exits the process if `kernel_buffer.len() != width * height`.
91 pub fn from(kernel_buffer: Vec<f32>, width: usize, height: usize) -> Self {
92 let kernel_size = kernel_buffer.len();
93 if width * height != kernel_size {
94 eprintln!("[ERROR]: Invalid dimensions provided");
95 std::process::exit(1);
96 }
97 Self {
98 width,
99 height,
100 kernel: kernel_buffer,
101 }
102 }
103
104 /// Returns the filter width (number of columns).
105 pub fn width(&self) -> usize {
106 self.width
107 }
108
109 /// Returns the filter height (number of rows).
110 pub fn height(&self) -> usize {
111 self.height
112 }
113
114 /// Returns a clone of the kernel data as a flat `Vec<f32>`.
115 pub fn kernel(&self) -> Vec<f32> {
116 self.kernel.clone()
117 }
118
119 /// Returns the element at `(row, col)`, or `None` if out of bounds.
120 pub fn get_element(&self, x: usize, y: usize) -> Option<f32> {
121 let element_pos = x * self.width + y;
122 if self.kernel.is_empty() || element_pos >= self.kernel.len() {
123 None
124 } else {
125 Some(self.kernel[element_pos])
126 }
127 }
128
129 /// Sets the element at `(row, col)` to the given value.
130 ///
131 /// # Panics
132 /// Exits the process if the position is out of bounds.
133 pub fn set_value_at_pos(&mut self, val: f32, position: (usize, usize)) {
134 let element_pos = position.0 * self.width + position.1;
135 if self.kernel.is_empty() || element_pos >= self.kernel.len() {
136 eprintln!("[ERROR]: Index out of bound");
137 std::process::exit(1);
138 } else {
139 self.kernel[element_pos] = val;
140 }
141 }
142
143 /// Tests whether this kernel is **separable** — i.e. can be expressed
144 /// as the outer product of a column vector and a row vector.
145 ///
146 /// ## The Concept
147 ///
148 /// ```text
149 /// A 2D kernel K can sometimes be factored: K = col × rowᵀ
150 ///
151 /// [ a ] [ a·c a·d a·e ]
152 /// [ b ] × [ c d e ] = [ b·c b·d b·e ]
153 ///
154 /// col 2×1 row 1×3 kernel 2×3
155 /// ```
156 ///
157 /// When separable, convolution can use two 1D passes instead of one 2D pass:
158 /// O(fw·fh) → O(fw + fh) per output pixel.
159 ///
160 /// ## Decomposition Algorithm
161 ///
162 /// 1. Find the largest absolute value in the kernel — this is the **pivot**.
163 /// 2. Extract the pivot's **row** as the row vector.
164 /// 3. Extract the pivot's **column**, divided by the pivot value, as the
165 /// column vector.
166 /// 4. Verify that for every element: `col[i] × row[j] ≈ kernel[i][j]`.
167 /// If any element deviates beyond `1e-4`, the kernel is not separable.
168 ///
169 /// ```text
170 /// Example: Sobel-X = [1,0,-1; 2,0,-2; 1,0,-1]
171 ///
172 /// Pivot = |2| at (1,0) col = col₀ / 2 = [1/2, 2/2, 1/2]
173 /// row = row₁ = [2, 0, -2] row = row₁ = [2, 0, -2]
174 ///
175 /// Verify: [0.5] × [2, 0, -2] = [1,0,-1; 2,0,-2; 1,0,-1] ✓
176 /// [1.0]
177 /// [0.5]
178 /// ```
179 ///
180 /// ## Returns
181 /// * `Some((col_vec, row_vec))` — col has `height` elements, row has `width` elements.
182 /// * `None` — kernel is not separable (or is all zeros).
183 pub fn try_separable(&self) -> Option<(Vec<f32>, Vec<f32>)> {
184 let fw = self.width;
185 let fh = self.height;
186 let kernel = &self.kernel;
187
188 let (pi, pj) = (0..fh)
189 .flat_map(|i| (0..fw).map(move |j| (i, j)))
190 .max_by(|(i1, j1), (i2, j2)| {
191 let v1 = kernel[i1 * fw + j1].abs();
192 let v2 = kernel[i2 * fw + j2].abs();
193 v1.partial_cmp(&v2).unwrap_or(std::cmp::Ordering::Equal)
194 })?;
195
196 let pivot = kernel[pi * fw + pj];
197
198 if pivot.abs() < 1e-10 {
199 return None;
200 }
201
202 let row_vec: Vec<f32> = (0..fw).map(|j| kernel[pi * fw + j]).collect();
203 let col_vec: Vec<f32> = (0..fh).map(|i| kernel[i * fw + pj] / pivot).collect();
204
205 for i in 0..fh {
206 for j in 0..fw {
207 if (col_vec[i] * row_vec[j] - kernel[i * fw + j]).abs() > 1e-4 {
208 return None;
209 }
210 }
211 }
212
213 Some((col_vec, row_vec))
214 }
215
216 /// Pretty-prints the kernel as a formatted table to stdout.
217 pub fn display(&self) {
218 let mut table = Table::new();
219
220 for x in 0..self.height {
221 let mut row_vec = Vec::<Cell>::new();
222 for y in 0..self.width {
223 let pos = x * self.width + y;
224 let element = &self.kernel[pos];
225 row_vec.push(Cell::new(element.to_string().as_str()));
226 }
227 table.add_row(Row::new(row_vec));
228 }
229 table.printstd();
230 }
231}
232
233/// Specifies how border pixels are handled during convolution.
234///
235/// ```text
236/// Without padding, the output shrinks: out = in - (filter - 1)
237///
238/// Input (5×5) Filter 3×3 Output (3×3)
239/// ┌─────────┐ ┌───┐ ┌─────┐
240/// │ * * * * *│ │###│ │ * * *│
241/// │ * * * * *│ │###│ │ * * *│
242/// │ * * * * *│ × │###│ = │ * * *│
243/// │ * * * * *│ └───┘ └─────┘
244/// │ * * * * *│
245/// └─────────┘
246///
247/// With UNIFORM(1), a border of 1 is added all around:
248///
249/// Input (5×5) Padded (7×7) Output (5×5)
250/// ┌─────────┐ ┌·············┐ ┌─────────┐
251/// │ * * * * *│ ┆ 0 0 0 0 0 0 0┆ │ * * * * *│
252/// │ * * * * *│ ┆ 0 * * * * * 0┆ │ * * * * *│
253/// │ * * * * *│ → ┆ 0 * * * * * 0┆ →│ * * * * *│ (same size as input)
254/// │ * * * * *│ ┆ 0 * * * * * 0┆ │ * * * * *│
255/// │ * * * * *│ ┆ 0 * * * * * 0┆ │ * * * * *│
256/// └─────────┘ ┆ 0 0 0 0 0 0 0┆ └─────────┘
257/// └·············┘
258/// ```
259pub enum PaddingType {
260 /// Pad with `n` black pixels on all four sides.
261 /// Output size = `(input_size - filter_size + 2·n) / stride + 1`.
262 UNIFORM(u32),
263 /// No padding. Output shrinks by `filter_size - 1` in each dimension.
264 /// Faster than UNIFORM since no padding buffer is allocated.
265 NONE,
266}
267
268/// Converts a `PhotonImage` (photon-rs native format) into an
269/// `image::DynamicImage` (image crate format).
270pub fn photon_to_dynamic(photon_image: &PhotonImage) -> DynamicImage {
271 let mut img = helpers::dyn_image_from_raw(photon_image);
272 img = ImageRgba8(img.to_rgba8());
273 img
274}
275
276/// Converts an `image::DynamicImage` into a `PhotonImage`.
277pub fn dynamic_to_photon(dynamic_image: &DynamicImage) -> PhotonImage {
278 let image_buffer: Vec<u8> = (*dynamic_image).clone().into_bytes();
279 PhotonImage::new(image_buffer, dynamic_image.width(), dynamic_image.height())
280}