Skip to main content

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}