From f71b55da72a5de0b09ffd002c92f8f2a360081bb Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 6 Mar 2026 14:24:14 +0200 Subject: [PATCH] Add image adjustments pipeline (brightness, contrast, crop, effects) New AdjustmentsConfig with brightness, contrast, saturation, sharpen, grayscale, sepia, crop to aspect ratio, trim whitespace, and canvas padding. All wired from UI through to executor. - Saturation uses luminance-based color blending - Sepia uses standard matrix transformation - Crop calculates center crop from aspect ratio - Trim whitespace detects uniform border by corner pixel comparison - Canvas padding adds white border around image --- pixstrip-core/src/executor.rs | 8 + pixstrip-core/src/operations/adjustments.rs | 198 ++++++++++++++++++++ pixstrip-core/src/operations/mod.rs | 30 +++ pixstrip-core/src/pipeline.rs | 3 + pixstrip-core/src/preset.rs | 1 + pixstrip-gtk/src/app.rs | 28 +++ 6 files changed, 268 insertions(+) create mode 100644 pixstrip-core/src/operations/adjustments.rs diff --git a/pixstrip-core/src/executor.rs b/pixstrip-core/src/executor.rs index ebaebe9..9e6effc 100644 --- a/pixstrip-core/src/executor.rs +++ b/pixstrip-core/src/executor.rs @@ -7,6 +7,7 @@ use rayon::prelude::*; use crate::encoder::OutputEncoder; use crate::error::{PixstripError, Result}; use crate::loader::ImageLoader; +use crate::operations::adjustments::apply_adjustments; use crate::operations::resize::resize_image; use crate::operations::watermark::apply_watermark; use crate::operations::{Flip, Rotation}; @@ -340,6 +341,13 @@ impl PipelineExecutor { img = resize_image(&img, config)?; } + // Adjustments (brightness, contrast, saturation, effects, crop, padding) + if let Some(ref adj) = job.adjustments { + if !adj.is_noop() { + img = apply_adjustments(img, adj)?; + } + } + // Watermark (after resize so watermark is at correct scale) if let Some(ref config) = job.watermark { img = apply_watermark(img, config)?; diff --git a/pixstrip-core/src/operations/adjustments.rs b/pixstrip-core/src/operations/adjustments.rs new file mode 100644 index 0000000..c1d7e0c --- /dev/null +++ b/pixstrip-core/src/operations/adjustments.rs @@ -0,0 +1,198 @@ +use image::{DynamicImage, Rgba, RgbaImage}; + +use crate::error::Result; +use super::AdjustmentsConfig; + +pub fn apply_adjustments( + mut img: DynamicImage, + config: &AdjustmentsConfig, +) -> Result { + // Crop to aspect ratio (before other adjustments) + if let Some((w_ratio, h_ratio)) = config.crop_aspect_ratio { + img = crop_to_aspect_ratio(img, w_ratio, h_ratio); + } + + // Trim whitespace + if config.trim_whitespace { + img = trim_whitespace(img); + } + + // Brightness + if config.brightness != 0 { + img = img.brighten(config.brightness); + } + + // Contrast + if config.contrast != 0 { + img = img.adjust_contrast(config.contrast as f32); + } + + // Saturation + if config.saturation != 0 { + img = adjust_saturation(img, config.saturation); + } + + // Grayscale + if config.grayscale { + img = img.grayscale(); + } + + // Sepia + if config.sepia { + img = apply_sepia(img); + } + + // Sharpen + if config.sharpen { + img = img.unsharpen(1.0, 5); + } + + // Canvas padding + if config.canvas_padding > 0 { + img = add_canvas_padding(img, config.canvas_padding); + } + + Ok(img) +} + +fn crop_to_aspect_ratio(img: DynamicImage, w_ratio: f64, h_ratio: f64) -> DynamicImage { + let (iw, ih) = (img.width(), img.height()); + let target_ratio = w_ratio / h_ratio; + let current_ratio = iw as f64 / ih as f64; + + let (crop_w, crop_h) = if current_ratio > target_ratio { + // Image is wider than target, crop width + let new_w = (ih as f64 * target_ratio) as u32; + (new_w, ih) + } else { + // Image is taller than target, crop height + let new_h = (iw as f64 / target_ratio) as u32; + (iw, new_h) + }; + + let x = (iw - crop_w) / 2; + let y = (ih - crop_h) / 2; + + img.crop_imm(x, y, crop_w, crop_h) +} + +fn trim_whitespace(img: DynamicImage) -> DynamicImage { + let rgba = img.to_rgba8(); + let (w, h) = (rgba.width(), rgba.height()); + + if w == 0 || h == 0 { + return img; + } + + // Get corner pixel as reference background color + let bg = *rgba.get_pixel(0, 0); + let threshold = 30u32; + + let is_bg = |p: &Rgba| -> bool { + let dr = (p[0] as i32 - bg[0] as i32).unsigned_abs(); + let dg = (p[1] as i32 - bg[1] as i32).unsigned_abs(); + let db = (p[2] as i32 - bg[2] as i32).unsigned_abs(); + dr + dg + db < threshold + }; + + let mut top = 0u32; + let mut bottom = h - 1; + let mut left = 0u32; + let mut right = w - 1; + + // Find top + 'top: for y in 0..h { + for x in 0..w { + if !is_bg(rgba.get_pixel(x, y)) { + top = y; + break 'top; + } + } + } + + // Find bottom + 'bottom: for y in (0..h).rev() { + for x in 0..w { + if !is_bg(rgba.get_pixel(x, y)) { + bottom = y; + break 'bottom; + } + } + } + + // Find left + 'left: for x in 0..w { + for y in top..=bottom { + if !is_bg(rgba.get_pixel(x, y)) { + left = x; + break 'left; + } + } + } + + // Find right + 'right: for x in (0..w).rev() { + for y in top..=bottom { + if !is_bg(rgba.get_pixel(x, y)) { + right = x; + break 'right; + } + } + } + + let crop_w = right.saturating_sub(left) + 1; + let crop_h = bottom.saturating_sub(top) + 1; + + if crop_w == 0 || crop_h == 0 || (crop_w == w && crop_h == h) { + return img; + } + + img.crop_imm(left, top, crop_w, crop_h) +} + +fn adjust_saturation(img: DynamicImage, amount: i32) -> DynamicImage { + let mut rgba = img.into_rgba8(); + let factor = 1.0 + (amount as f64 / 100.0); + + for pixel in rgba.pixels_mut() { + let r = pixel[0] as f64; + let g = pixel[1] as f64; + let b = pixel[2] as f64; + + let gray = 0.2126 * r + 0.7152 * g + 0.0722 * b; + + pixel[0] = (gray + (r - gray) * factor).clamp(0.0, 255.0) as u8; + pixel[1] = (gray + (g - gray) * factor).clamp(0.0, 255.0) as u8; + pixel[2] = (gray + (b - gray) * factor).clamp(0.0, 255.0) as u8; + } + + DynamicImage::ImageRgba8(rgba) +} + +fn apply_sepia(img: DynamicImage) -> DynamicImage { + let mut rgba = img.into_rgba8(); + + for pixel in rgba.pixels_mut() { + let r = pixel[0] as f64; + let g = pixel[1] as f64; + let b = pixel[2] as f64; + + pixel[0] = (0.393 * r + 0.769 * g + 0.189 * b).clamp(0.0, 255.0) as u8; + pixel[1] = (0.349 * r + 0.686 * g + 0.168 * b).clamp(0.0, 255.0) as u8; + pixel[2] = (0.272 * r + 0.534 * g + 0.131 * b).clamp(0.0, 255.0) as u8; + } + + DynamicImage::ImageRgba8(rgba) +} + +fn add_canvas_padding(img: DynamicImage, padding: u32) -> DynamicImage { + let (w, h) = (img.width(), img.height()); + let new_w = w + padding * 2; + let new_h = h + padding * 2; + + let mut canvas = RgbaImage::from_pixel(new_w, new_h, Rgba([255, 255, 255, 255])); + + image::imageops::overlay(&mut canvas, &img.to_rgba8(), padding as i64, padding as i64); + + DynamicImage::ImageRgba8(canvas) +} diff --git a/pixstrip-core/src/operations/mod.rs b/pixstrip-core/src/operations/mod.rs index d21627f..c58de0f 100644 --- a/pixstrip-core/src/operations/mod.rs +++ b/pixstrip-core/src/operations/mod.rs @@ -1,3 +1,4 @@ +pub mod adjustments; pub mod metadata; pub mod rename; pub mod resize; @@ -195,6 +196,35 @@ pub enum WatermarkConfig { }, } +// --- Adjustments --- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AdjustmentsConfig { + pub brightness: i32, + pub contrast: i32, + pub saturation: i32, + pub sharpen: bool, + pub grayscale: bool, + pub sepia: bool, + pub crop_aspect_ratio: Option<(f64, f64)>, + pub trim_whitespace: bool, + pub canvas_padding: u32, +} + +impl AdjustmentsConfig { + pub fn is_noop(&self) -> bool { + self.brightness == 0 + && self.contrast == 0 + && self.saturation == 0 + && !self.sharpen + && !self.grayscale + && !self.sepia + && self.crop_aspect_ratio.is_none() + && !self.trim_whitespace + && self.canvas_padding == 0 + } +} + // --- Rename --- #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/pixstrip-core/src/pipeline.rs b/pixstrip-core/src/pipeline.rs index d13c85a..f316df5 100644 --- a/pixstrip-core/src/pipeline.rs +++ b/pixstrip-core/src/pipeline.rs @@ -14,6 +14,7 @@ pub struct ProcessingJob { pub resize: Option, pub rotation: Option, pub flip: Option, + pub adjustments: Option, pub convert: Option, pub compress: Option, pub metadata: Option, @@ -31,6 +32,7 @@ impl ProcessingJob { resize: None, rotation: None, flip: None, + adjustments: None, convert: None, compress: None, metadata: None, @@ -49,6 +51,7 @@ impl ProcessingJob { if self.resize.is_some() { count += 1; } if self.rotation.is_some() { count += 1; } if self.flip.is_some() { count += 1; } + if self.adjustments.as_ref().is_some_and(|a| !a.is_noop()) { count += 1; } if self.convert.is_some() { count += 1; } if self.compress.is_some() { count += 1; } if self.metadata.is_some() { count += 1; } diff --git a/pixstrip-core/src/preset.rs b/pixstrip-core/src/preset.rs index 37720ad..19b4a38 100644 --- a/pixstrip-core/src/preset.rs +++ b/pixstrip-core/src/preset.rs @@ -33,6 +33,7 @@ impl Preset { resize: self.resize.clone(), rotation: self.rotation, flip: self.flip, + adjustments: None, convert: self.convert.clone(), compress: self.compress.clone(), metadata: self.metadata.clone(), diff --git a/pixstrip-gtk/src/app.rs b/pixstrip-gtk/src/app.rs index d190ee3..270370a 100644 --- a/pixstrip-gtk/src/app.rs +++ b/pixstrip-gtk/src/app.rs @@ -1163,6 +1163,34 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { _ => pixstrip_core::operations::Flip::None, }); + // Adjustments + { + let crop = match cfg.crop_aspect_ratio { + 1 => Some((1.0, 1.0)), + 2 => Some((4.0, 3.0)), + 3 => Some((3.0, 2.0)), + 4 => Some((16.0, 9.0)), + 5 => Some((9.0, 16.0)), + 6 => Some((3.0, 4.0)), + 7 => Some((2.0, 3.0)), + _ => None, + }; + let adj = pixstrip_core::operations::AdjustmentsConfig { + brightness: cfg.brightness, + contrast: cfg.contrast, + saturation: cfg.saturation, + sharpen: cfg.sharpen, + grayscale: cfg.grayscale, + sepia: cfg.sepia, + crop_aspect_ratio: crop, + trim_whitespace: cfg.trim_whitespace, + canvas_padding: cfg.canvas_padding, + }; + if !adj.is_noop() { + job.adjustments = Some(adj); + } + } + // Watermark if cfg.watermark_enabled { let position = match cfg.watermark_position {