diff --git a/Cargo.lock b/Cargo.lock index 6dc0860..420df26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -443,7 +443,7 @@ dependencies = [ [[package]] name = "glance-imgproc" -version = "0.1.0" +version = "0.1.1" dependencies = [ "derive_more", "glance-core", diff --git a/README.md b/README.md index fabed13..012a2eb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,80 @@ # Glance - Glance aims to be a modular computer vision library written in Rust. + +## Features + +### Image Manipulation +- [x] Create and save images +- [x] Load images from files +- [x] Display images (cross platform) +- [x] Draw shapes +- [ ] Draw text + +### Point Operations +- [x] Pixel-wise operations +- [x] Color adjustments (brightness, contrast, gamma) +- [ ] Color space conversions + - [x] RGBA to Grayscale + - [ ] RGBA to HSV + - [ ] RGBA to YUV +- [x] Thresholding +- [x] Histogram equalization +- [ ] Adaptive histogram equalization (CLAHE) + +### Linear Filters +- [x] Gaussian blur +- [x] Box blur +- [x] Sobel filter +- [x] Laplacian filter +- [ ] Unsharp masking +- [ ] Frequency domain filtering (FFT-based) + +### Non-linear Filters +- [x] Median filter +- [ ] Bilateral filter +- [ ] Noise reduction filters +- [x] Morphological operations + - [x] Erosion + - [x] Dilation + +### Detection & Recognition +- [ ] Edge detection (Canny, Harris corner detection) +- [ ] Feature detection and description (SIFT, ORB, FAST) +- [ ] Template matching +- [ ] Contour detection and analysis +- [ ] Blob detection +- [ ] Hough transforms (lines, circles) + +### Geometric Transformations +- [ ] Scaling, rotation, translation +- [ ] Perspective transformation +- [ ] Image warping and rectification +- [ ] Image registration and alignment + +### Segmentation +- [ ] Watershed segmentation +- [ ] Region growing +- [ ] K-means clustering for segmentation +- [ ] Graph-based segmentation + +### Analysis & Metrics +- [ ] Histogram computation and analysis +- [ ] Image statistics (mean, variance, entropy) +- [ ] Image quality metrics (PSNR, SSIM) +- [ ] Connected component analysis + +### Advanced Processing +- [ ] Image pyramids (Gaussian, Laplacian) +- [ ] Integral images +- [ ] Distance transforms +- [ ] Convex hull computation + +### I/O & Utilities +- [ ] Video frame extraction +- [ ] Batch processing utilities +- [ ] Memory-efficient streaming for large images +- [ ] Zero-copy operations where possible + +### Performance & Optimization +- [ ] SIMD optimizations +- [x] Multi-threading support (currently using Rayon) diff --git a/glance-core/src/img/mod.rs b/glance-core/src/img/mod.rs index dd73c62..c68c088 100644 --- a/glance-core/src/img/mod.rs +++ b/glance-core/src/img/mod.rs @@ -93,6 +93,12 @@ where Ok(()) } + /// Fills the image with the specified color. + pub fn fill(mut self, color: P) -> Self { + self.data.fill(color); + self + } + /// Opens an [`Image`] instance and displays it in a window. pub fn display(&self, title: &str) -> Result<()> { let (width, height) = self.dimensions(); @@ -129,6 +135,19 @@ where Ok(()) } + /// Vertically stacks two images of the same width. + pub fn vstack(mut self, other: &Self) -> Result { + if self.width != other.width { + return Err(CoreError::InvalidData( + "Images must have the same width to stack vertically".to_string(), + )); + } + + self.height += other.height; + self.data.extend(other.data.clone()); + Ok(self) + } + /// Returns a reference to the pixel data at the specified position. /// Returns an error if the position is out of bounds. pub fn get_pixel(&self, position: (usize, usize)) -> Result<&P> { @@ -177,9 +196,11 @@ where } impl Image { + /// Min-max normalizes the pixel data in the image. + /// The alpha channel is not modified. pub fn normalize(&self) -> Self { // Find the maximum value in the pixel data for each channel - let (max_r, max_g, max_b, max_a) = self + let (max_r, max_g, max_b, _max_a) = self .par_pixels() .map(|pixel| (pixel.r, pixel.g, pixel.b, pixel.a)) .reduce( @@ -189,7 +210,7 @@ impl Image { }, ); - let (min_r, min_g, min_b, min_a) = self + let (min_r, min_g, min_b, _min_a) = self .par_pixels() .map(|pixel| (pixel.r, pixel.g, pixel.b, pixel.a)) .reduce( @@ -206,7 +227,7 @@ impl Image { r: (pixel.r - min_r) / (max_r - min_r), g: (pixel.g - min_g) / (max_g - min_g), b: (pixel.b - min_b) / (max_b - min_b), - a: (pixel.a - min_a) / (max_a - min_a), + a: pixel.a, }) .collect(); @@ -216,9 +237,21 @@ impl Image { data: normalized, } } + + /// Applies a function to each pixel in the image, returning a new image. + /// The alpha channel is not modified. + pub fn apply(mut self, f: F) -> Self + where + F: Fn(f32) -> f32 + Sync + Send, + { + self.par_pixels_mut() + .for_each(|pixel| *pixel = pixel.apply(&f)); + self + } } impl Image { + /// Min-max normalizes the pixel data in the image. pub fn normalize(&self) -> Self { // Find the maximum value in the pixel data for each channel let max_l = self @@ -244,4 +277,14 @@ impl Image { data: normalized, } } + + /// Applies a function to each pixel in the image, returning a new image. + pub fn apply(mut self, f: F) -> Self + where + F: Fn(f32) -> f32 + Sync + Send, + { + self.par_pixels_mut() + .for_each(|pixel| *pixel = pixel.apply(&f)); + self + } } diff --git a/glance-core/src/img/pixel/luma.rs b/glance-core/src/img/pixel/luma.rs index cc0e0ad..8a0bfe7 100644 --- a/glance-core/src/img/pixel/luma.rs +++ b/glance-core/src/img/pixel/luma.rs @@ -27,3 +27,13 @@ impl Pixel for Luma { [l, l, l, 255] } } + +impl Luma { + pub fn apply(mut self, f: F) -> Self + where + F: Fn(f32) -> f32, + { + self.l = f(self.l); + self + } +} diff --git a/glance-core/src/img/pixel/rgba.rs b/glance-core/src/img/pixel/rgba.rs index 3bb03fb..4ecab65 100644 --- a/glance-core/src/img/pixel/rgba.rs +++ b/glance-core/src/img/pixel/rgba.rs @@ -51,3 +51,16 @@ impl From<[u8; 4]> for Rgba { } } } + +impl Rgba { + pub fn apply(mut self, f: F) -> Self + where + F: Fn(f32) -> f32, + { + self.r = f(self.r); + self.g = f(self.g); + self.b = f(self.b); + self.a = f(self.a); + self + } +} diff --git a/glance-imgproc/src/affine_transformations.rs b/glance-imgproc/src/affine_transformations.rs new file mode 100644 index 0000000..4beaa7d --- /dev/null +++ b/glance-imgproc/src/affine_transformations.rs @@ -0,0 +1,60 @@ +use glance_core::img::{Image, pixel::Luma}; +use rayon::iter::{IndexedParallelIterator, ParallelIterator}; + +pub trait AffineTransformationsExtLuma { + /// Applies an affine transformation to the image. + fn affine_transform(self, matrix: [[f32; 3]; 3]) -> Image; + fn rotate(self, angle: f32) -> Image; + fn scale(self, scale: (f32, f32)) -> Image; + fn translate(self, offset: (f32, f32)) -> Image; +} + +impl AffineTransformationsExtLuma for Image { + fn affine_transform(self, matrix: [[f32; 3]; 3]) -> Image { + let (width, height) = self.dimensions(); + let new_data = self + .par_pixels() + .enumerate() + .map(|(idx, _pixel)| { + let (x, y) = (idx % width, idx / width); + let new_x = + (matrix[0][0] * x as f32 + matrix[0][1] * y as f32 + matrix[0][2]) as usize; + let new_y = + (matrix[1][0] * x as f32 + matrix[1][1] * y as f32 + matrix[1][2]) as usize; + + if new_x < width && new_y < height && new_x > 0 && new_y > 0 { + return self.get_pixel((new_x, new_y)).unwrap().clone(); + } + + Luma { l: 0.0 } + }) + .collect::>(); + + Image::from_data(width, height, new_data).unwrap() + } + + fn rotate(self, angle: f32) -> Image { + let cos_angle = angle.cos(); + let sin_angle = angle.sin(); + + let matrix = [ + [cos_angle, -sin_angle, 0.0], + [sin_angle, cos_angle, 0.0], + [0.0, 0.0, 1.0], + ]; + + self.affine_transform(matrix) + } + + fn scale(self, scale: (f32, f32)) -> Image { + let matrix = [[scale.0, 0.0, 0.0], [0.0, scale.1, 0.0], [0.0, 0.0, 1.0]]; + + self.affine_transform(matrix) + } + + fn translate(self, offset: (f32, f32)) -> Image { + let matrix = [[1.0, 0.0, offset.0], [0.0, 1.0, offset.1], [0.0, 0.0, 1.0]]; + + self.affine_transform(matrix) + } +} diff --git a/glance-imgproc/src/kernels.rs b/glance-imgproc/src/kernels.rs new file mode 100644 index 0000000..e79ff32 --- /dev/null +++ b/glance-imgproc/src/kernels.rs @@ -0,0 +1,106 @@ +use glance_core::img::{Image, pixel::Luma}; + +pub fn sobel_x() -> Image { + Image::from_data( + 3, + 3, + [-1.0, -2.0, -1.0, 0.0, 0.0, 0.0, 1.0, 2.0, 1.0] + .iter() + .map(|&l| Luma { l }) + .collect(), + ) + .unwrap() +} + +pub fn sobel_y() -> Image { + Image::from_data( + 3, + 3, + [-1.0, 0.0, 1.0, -2.0, 0.0, 2.0, -1.0, 0.0, 1.0] + .iter() + .map(|&l| Luma { l }) + .collect(), + ) + .unwrap() +} + +pub fn laplacian_3x3() -> Image { + Image::from_data( + 3, + 3, + [0.0, 1.0, 0.0, 1.0, -4.0, 1.0, 0.0, 1.0, 0.0] + .iter() + .map(|&l| Luma { l }) + .collect(), + ) + .unwrap() +} + +pub fn box_filter(size: usize) -> Image { + let value = 1.0 / (size * size) as f32; + let data: Vec = vec![Luma { l: value }; size * size]; + + Image::from_data(size, size, data).unwrap() +} + +pub fn gaussian_filter(size: usize, sigma: f32) -> Image { + let mut data = Vec::with_capacity(size * size); + let half_size = size as f32 / 2.0; + let two_sigma_squared = 2.0 * sigma * sigma; + + for y in 0..size { + for x in 0..size { + let x_diff = (x as f32 - half_size).powi(2); + let y_diff = (y as f32 - half_size).powi(2); + let value = (-(x_diff + y_diff) / two_sigma_squared).exp() + / (std::f32::consts::PI * two_sigma_squared); + data.push(Luma { l: value }); + } + } + + Image::from_data(size, size, data).unwrap() +} + +pub enum StructuringElementShape { + Rectangle, + Disk, + Cross, +} +/// Creates a square structuring element of given size, filled with ones. +/// Used in morphological operations. Shape has to be specified. +pub fn structuring_element(shape: StructuringElementShape, size: (usize, usize)) -> Image { + let (width, height) = size; + + match shape { + StructuringElementShape::Rectangle => { + Image::from_data(width, height, vec![Luma { l: 1.0 }; width * height]).unwrap() + } + StructuringElementShape::Disk => { + let mut data = Vec::with_capacity(width * height); + let half_width = width as f32 / 2.0; + let half_height = height as f32 / 2.0; + for y in 0..height { + for x in 0..width { + let dx = (x as f32 - half_width).powi(2); + let dy = (y as f32 - half_height).powi(2); + if dx + dy <= (half_width * half_width) { + data.push(Luma { l: 1.0 }); + } else { + data.push(Luma { l: 0.0 }); + } + } + } + Image::from_data(width, height, data).unwrap() + } + StructuringElementShape::Cross => { + let mut data = vec![Luma { l: 0.0 }; width * height]; + for i in 0..width { + data[i + (height / 2) * width] = Luma { l: 1.0 }; + } + for i in 0..height { + data[(width / 2) + i * width] = Luma { l: 1.0 }; + } + Image::from_data(width, height, data).unwrap() + } + } +} diff --git a/glance-imgproc/src/lib.rs b/glance-imgproc/src/lib.rs index e683f2f..ecab2c4 100644 --- a/glance-imgproc/src/lib.rs +++ b/glance-imgproc/src/lib.rs @@ -1,6 +1,10 @@ -mod error; +pub mod affine_transformations; +pub mod kernels; +pub mod linear_filters; +pub mod nonlinear_filters; pub mod point_ops; +mod error; pub use error::{Error, Result}; #[cfg(test)] @@ -8,8 +12,12 @@ mod tests { use std::path::PathBuf; use crate::Result; - use glance_core::img::Image; + use crate::affine_transformations::AffineTransformationsExtLuma; + use crate::kernels; + use crate::linear_filters::{BorderMode, ConvolutionExtLuma}; + use crate::nonlinear_filters::NonLinearFilterExtLuma; use glance_core::img::pixel::Rgba; + use glance_core::img::{Image, pixel::Luma}; use crate::point_ops::{PointOpsExtLuma, PointOpsExtRgba}; @@ -111,4 +119,89 @@ mod tests { Ok(()) } + + #[test] + fn sobel_convolve() -> Result<()> { + let mut dir_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + dir_path.push("../media/test_imgs/"); + let path = dir_path.join("lenna.png"); + + let img = Image::::open(path)?; + let horizontal_kernel = kernels::sobel_x(); + let vertical_kernel = kernels::sobel_y(); + + let horizontal_img = img + .clone() + .convolve_2d(horizontal_kernel, BorderMode::Replicate) + .apply(|f| f32::powf(f, 2.0)); + let vertical_img = img + .convolve_2d(vertical_kernel, BorderMode::Replicate) + .apply(|f| f32::powf(f, 2.0)); + + let img = horizontal_img + .lerp(&vertical_img, 0.5) + .apply(f32::sqrt) + .normalize(); + + if std::env::var("NO_DISPLAY").is_err() { + img.display("sobel_convolve")?; + } + + Ok(()) + } + + #[test] + fn laplacian_convolve() -> Result<()> { + let mut dir_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + dir_path.push("../media/test_imgs/"); + let path = dir_path.join("lenna.png"); + + let img = Image::::open(path)?; + let laplacian_kernel = kernels::laplacian_3x3(); + let gaussian_kernel = kernels::gaussian_filter(3, 0.1); + + if std::env::var("NO_DISPLAY").is_err() { + img.convolve_2d(gaussian_kernel, BorderMode::Replicate) + .convolve_2d(laplacian_kernel, BorderMode::Replicate) + .apply(f32::abs) + .normalize() + .display("laplacian_convolve")?; + } + + Ok(()) + } + + #[test] + fn nonlinear_filters() -> Result<()> { + let mut dir_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + dir_path.push("../media/test_imgs/"); + let path = dir_path.join("noisy.png"); + + let img = Image::::open(path)?; + let median_blurred_img = img.clone().median_blur(5); + + if std::env::var("NO_DISPLAY").is_err() { + img.vstack(&median_blurred_img)? + .display("nonlinear_filters_median_blur")?; + } + + Ok(()) + } + + #[test] + fn affine_transformations() -> Result<()> { + let mut dir_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + dir_path.push("../media/test_imgs/"); + let path = dir_path.join("lenna.png"); + + let img = Image::::open(path)?; + + if std::env::var("NO_DISPLAY").is_err() { + img.scale((2.0, 2.0)) + .translate((-50.0, -50.0)) + .display("affine_transformations_rotate")?; + } + + Ok(()) + } } diff --git a/glance-imgproc/src/linear_filters.rs b/glance-imgproc/src/linear_filters.rs new file mode 100644 index 0000000..e424029 --- /dev/null +++ b/glance-imgproc/src/linear_filters.rs @@ -0,0 +1,213 @@ +use glance_core::{ + CoreError, Result, + img::{ + Image, + pixel::{Luma, Pixel, Rgba}, + }, +}; +use rayon::iter::{IndexedParallelIterator, ParallelIterator}; + +#[derive(Debug, Clone)] +pub enum BorderMode { + /// Replicate the border pixels + Replicate, + /// Wrap around the image edges + Wrap, + /// Use a constant value for out-of-bounds pixels + Constant(f32), + /// Reflect the image edges + Reflect, +} + +pub trait LinearFilterExtRgba { + fn convolve_2d(self, kernel: Image, border_mode: BorderMode) -> Image; +} + +impl LinearFilterExtRgba for Image { + fn convolve_2d(self, kernel: Image, border_mode: BorderMode) -> Image { + let (kernel_width, kernel_height) = kernel.dimensions(); + if kernel_width % 2 == 0 || kernel_height % 2 == 0 { + panic!("Kernel size must be odd in both dimensions"); + } + let (input_width, input_height) = self.dimensions(); + if input_width < kernel_width || input_height < kernel_height { + panic!("Input image must be larger than the kernel"); + } + + let kernel_half_width = kernel_width / 2; + let kernel_half_height = kernel_height / 2; + + let convolved_pixels = self + .par_pixels() + .enumerate() + .map(|(idx, _pixel)| { + let (x, y) = (idx % input_width, idx / input_width); + let mut r_sum = 0.0; + let mut g_sum = 0.0; + let mut b_sum = 0.0; + let alpha = _pixel.a; + + for ky in 0..kernel_height { + for kx in 0..kernel_width { + let kernel_value = kernel.get_pixel((kx, ky)).unwrap(); + let mut input_x = x as isize + kx as isize - kernel_half_width as isize; + let mut input_y = y as isize + ky as isize - kernel_half_height as isize; + + let input_pixel = match border_mode { + BorderMode::Constant(value) => { + if input_x < 0 + || input_y < 0 + || input_x >= input_width as isize + || input_y >= input_height as isize + { + &Rgba { + r: value, + g: value, + b: value, + a: alpha, + } + } else { + self.get_pixel((input_x as usize, input_y as usize)) + .unwrap() + } + } + BorderMode::Replicate => { + input_x = input_x.clamp(0, input_width as isize - 1); + input_y = input_y.clamp(0, input_height as isize - 1); + self.get_pixel((input_x as usize, input_y as usize)) + .unwrap() + } + BorderMode::Wrap => { + input_x = (input_x + input_width as isize) % input_width as isize; + input_y = (input_y + input_height as isize) % input_height as isize; + self.get_pixel((input_x as usize, input_y as usize)) + .unwrap() + } + BorderMode::Reflect => { + if input_x < 0 { + input_x = -input_x; + } else if input_x >= input_width as isize { + input_x = 2 * (input_width as isize - 1) - input_x; + } + if input_y < 0 { + input_y = -input_y; + } else if input_y >= input_height as isize { + input_y = 2 * (input_height as isize - 1) - input_y; + } + self.get_pixel((input_x as usize, input_y as usize)) + .unwrap() + } + }; + + let input_x = input_x as usize; + let input_y = input_y as usize; + + if input_x < input_width && input_y < input_height { + r_sum += input_pixel.r * kernel_value.l; + g_sum += input_pixel.g * kernel_value.l; + b_sum += input_pixel.b * kernel_value.l; + } + } + } + + Rgba { + r: r_sum, + g: g_sum, + b: b_sum, + a: alpha, + } + }) + .collect(); + + Image::from_data(input_width, input_height, convolved_pixels).unwrap() + } +} + +pub trait ConvolutionExtLuma { + fn convolve_2d(self, kernel: Image, border_mode: BorderMode) -> Image; +} + +impl ConvolutionExtLuma for Image { + fn convolve_2d(self, kernel: Image, border_mode: BorderMode) -> Image { + let (kernel_width, kernel_height) = kernel.dimensions(); + if kernel_width % 2 == 0 || kernel_height % 2 == 0 { + panic!("Kernel size must be odd in both dimensions"); + } + let (input_width, input_height) = self.dimensions(); + if input_width < kernel_width || input_height < kernel_height { + panic!("Input image must be larger than the kernel"); + } + + let kernel_half_width = kernel_width / 2; + let kernel_half_height = kernel_height / 2; + + let convolved_pixels = self + .par_pixels() + .enumerate() + .map(|(idx, _pixel)| { + let (x, y) = (idx % input_width, idx / input_width); + let mut l_sum = 0.0; + + for ky in 0..kernel_height { + for kx in 0..kernel_width { + let kernel_value = kernel.get_pixel((kx, ky)).unwrap(); + let mut input_x = x as isize + kx as isize - kernel_half_width as isize; + let mut input_y = y as isize + ky as isize - kernel_half_height as isize; + + let input_pixel = match border_mode { + BorderMode::Constant(value) => { + if input_x < 0 + || input_y < 0 + || input_x >= input_width as isize + || input_y >= input_height as isize + { + &Luma { l: value } + } else { + self.get_pixel((input_x as usize, input_y as usize)) + .unwrap() + } + } + BorderMode::Replicate => { + input_x = input_x.clamp(0, input_width as isize - 1); + input_y = input_y.clamp(0, input_height as isize - 1); + self.get_pixel((input_x as usize, input_y as usize)) + .unwrap() + } + BorderMode::Wrap => { + input_x = (input_x + input_width as isize) % input_width as isize; + input_y = (input_y + input_height as isize) % input_height as isize; + self.get_pixel((input_x as usize, input_y as usize)) + .unwrap() + } + BorderMode::Reflect => { + if input_x < 0 { + input_x = -input_x; + } else if input_x >= input_width as isize { + input_x = 2 * (input_width as isize - 1) - input_x; + } + if input_y < 0 { + input_y = -input_y; + } else if input_y >= input_height as isize { + input_y = 2 * (input_height as isize - 1) - input_y; + } + self.get_pixel((input_x as usize, input_y as usize)) + .unwrap() + } + }; + + let input_x = input_x as usize; + let input_y = input_y as usize; + + if input_x < input_width && input_y < input_height { + l_sum += input_pixel.l * kernel_value.l; + } + } + } + + Luma { l: l_sum } + }) + .collect(); + + Image::from_data(input_width, input_height, convolved_pixels).unwrap() + } +} diff --git a/glance-imgproc/src/nonlinear_filters.rs b/glance-imgproc/src/nonlinear_filters.rs new file mode 100644 index 0000000..772a299 --- /dev/null +++ b/glance-imgproc/src/nonlinear_filters.rs @@ -0,0 +1,165 @@ +use glance_core::img::{Image, pixel::Luma}; +use rayon::iter::{IndexedParallelIterator, ParallelIterator}; + +pub trait NonLinearFilterExtLuma { + // Non-linear filters + fn median_blur(self, kernel_size: usize) -> Image; + // Morphological operations + fn dilate(self, kernel: &Image) -> Image; + fn erode(self, kernel: &Image) -> Image; +} + +impl NonLinearFilterExtLuma for Image { + /// Applies a median blur to the image using a square kernel of the given size. + fn median_blur(self, kernel_size: usize) -> Image { + if kernel_size % 2 == 0 { + panic!("Kernel size must be odd"); + } + let (width, height) = self.dimensions(); + if width < kernel_size || height < kernel_size { + panic!("Input image must be larger than the kernel"); + } + + let half_kernel = kernel_size / 2; + let half_kernel = half_kernel as isize; + + let new_pixels = self + .par_pixels() + .enumerate() + .map(|(idx, _pixel)| { + let (x, y) = (idx % width, idx / width); + let mut values: Vec = Vec::with_capacity(kernel_size * kernel_size); + + for ky in -half_kernel..=half_kernel { + for kx in -half_kernel..=half_kernel { + let input_x = x as isize + kx; + let input_y = y as isize + ky; + + if input_x < 0 + || input_y < 0 + || input_x >= width as isize + || input_y >= height as isize + { + continue; // Skip out-of-bounds pixels + } + + let input_pixel = self + .get_pixel((input_x as usize, input_y as usize)) + .unwrap(); + values.push(input_pixel.l); + } + } + + values.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let median_value = values[values.len() / 2]; + Luma { l: median_value } + }) + .collect(); + + Image::from_data(width, height, new_pixels).unwrap() + } + + fn dilate(self, kernel: &Image) -> Image { + let (kernel_width, kernel_height) = kernel.dimensions(); + if kernel_width % 2 == 0 || kernel_height % 2 == 0 { + panic!("Kernel size must be odd in both dimensions"); + } + let (input_width, input_height) = self.dimensions(); + if input_width < kernel_width || input_height < kernel_height { + panic!("Input image must be larger than the kernel"); + } + + let kernel_half_width = kernel_width / 2; + let kernel_half_height = kernel_height / 2; + + let new_pixels = self + .par_pixels() + .enumerate() + .map(|(idx, _pixel)| { + let mut max_value: f32 = 0.0; + let (x, y) = (idx % input_width, idx / input_width); + + for ky in 0..kernel_height { + for kx in 0..kernel_width { + let kernel_value = kernel.get_pixel((kx, ky)).unwrap().l; + let input_x = x as isize + kx as isize - kernel_half_width as isize; + let input_y = y as isize + ky as isize - kernel_half_height as isize; + + if input_x < 0 || input_y < 0 { + continue; // Skip out-of-bounds pixels + } + + let input_x = input_x as usize; + let input_y = input_y as usize; + + if input_x >= input_width || input_y >= input_height { + continue; // Skip out-of-bounds pixels + } + + let input_pixel = self.get_pixel((input_x, input_y)).unwrap(); + if kernel_value == 0.0 { + continue; + } + max_value = max_value.max(input_pixel.l * kernel_value); + } + } + + Luma { l: max_value } + }) + .collect(); + + Image::from_data(input_width, input_height, new_pixels).unwrap() + } + + fn erode(self, kernel: &Image) -> Image { + let (kernel_width, kernel_height) = kernel.dimensions(); + if kernel_width % 2 == 0 || kernel_height % 2 == 0 { + panic!("Kernel size must be odd in both dimensions"); + } + let (input_width, input_height) = self.dimensions(); + if input_width < kernel_width || input_height < kernel_height { + panic!("Input image must be larger than the kernel"); + } + + let kernel_half_width = kernel_width / 2; + let kernel_half_height = kernel_height / 2; + + let new_pixels = self + .par_pixels() + .enumerate() + .map(|(idx, _pixel)| { + let mut min_value: f32 = f32::MAX; + let (x, y) = (idx % input_width, idx / input_width); + + for ky in 0..kernel_height { + for kx in 0..kernel_width { + let kernel_value = kernel.get_pixel((kx, ky)).unwrap().l; + let input_x = x as isize + kx as isize - kernel_half_width as isize; + let input_y = y as isize + ky as isize - kernel_half_height as isize; + + if input_x < 0 || input_y < 0 { + continue; // Skip out-of-bounds pixels + } + + let input_x = input_x as usize; + let input_y = input_y as usize; + + if input_x >= input_width || input_y >= input_height { + continue; // Skip out-of-bounds pixels + } + + let input_pixel = self.get_pixel((input_x, input_y)).unwrap(); + if kernel_value == 0.0 { + continue; + } + min_value = min_value.min(input_pixel.l * kernel_value); + } + } + + Luma { l: min_value } + }) + .collect(); + + Image::from_data(input_width, input_height, new_pixels).unwrap() + } +} diff --git a/glance-imgproc/src/point_ops.rs b/glance-imgproc/src/point_ops.rs index 120bab5..dec4918 100644 --- a/glance-imgproc/src/point_ops.rs +++ b/glance-imgproc/src/point_ops.rs @@ -29,6 +29,7 @@ pub trait PointOpsExtRgba { pub trait PointOpsExtLuma { fn invert(self) -> Self; fn gamma(self, gamma: f32) -> Self; + fn lerp(self, other: &Image, alpha: f32) -> Image; fn threshold(self, threshold: f32, max_intensity: f32, kind: ThresholdType) -> Image; fn histrogram_equalize(self) -> Self; } @@ -214,7 +215,7 @@ impl PointOpsExtLuma for Image { fn histrogram_equalize(mut self) -> Self { let (width, height) = self.dimensions(); let pixel_count = (width * height) as u32; - let channel_max = 255 as usize; + let channel_max = 255_usize; // Find histogram let mut hist = vec![0u32; channel_max + 1]; @@ -252,4 +253,26 @@ impl PointOpsExtLuma for Image { self } + + /// Linearly interpolates between two images of the same dimensions. + /// The alpha parameter controls the interpolation factor. + fn lerp(self, other: &Image, alpha: f32) -> Image { + let (width, height) = self.dimensions(); + if (width, height) != other.dimensions() { + panic!( + "Cannot lerp images of different dimensions: {:?} and {:?}", + (width, height), + other.dimensions() + ); + } + let lerped_pixels = self + .pixels() + .zip(other.pixels()) + .map(|(px1, px2)| Luma { + l: px1.l * (1.0 - alpha) + px2.l * alpha, + }) + .collect::>(); + + Image::from_data(width, height, lerped_pixels).unwrap() + } } diff --git a/media/test_imgs/j.png b/media/test_imgs/j.png new file mode 100644 index 0000000..f2b39f6 Binary files /dev/null and b/media/test_imgs/j.png differ diff --git a/media/test_imgs/lenna.png b/media/test_imgs/lenna.png new file mode 100644 index 0000000..59ef68a Binary files /dev/null and b/media/test_imgs/lenna.png differ diff --git a/media/test_imgs/noisy.png b/media/test_imgs/noisy.png new file mode 100644 index 0000000..a5a6334 Binary files /dev/null and b/media/test_imgs/noisy.png differ