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
This commit is contained in:
2026-03-06 14:24:14 +02:00
parent 8212969e9d
commit f71b55da72
6 changed files with 268 additions and 0 deletions

View File

@@ -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)?;

View File

@@ -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<DynamicImage> {
// 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<u8>| -> 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)
}

View File

@@ -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)]

View File

@@ -14,6 +14,7 @@ pub struct ProcessingJob {
pub resize: Option<ResizeConfig>,
pub rotation: Option<Rotation>,
pub flip: Option<Flip>,
pub adjustments: Option<AdjustmentsConfig>,
pub convert: Option<ConvertConfig>,
pub compress: Option<CompressConfig>,
pub metadata: Option<MetadataConfig>,
@@ -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; }

View File

@@ -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(),

View File

@@ -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 {