Add image adjustments pipeline (brightness, contrast, crop, effects)

This commit is contained in:
2026-03-06 14:24:14 +02:00
parent 600b36279e
commit f3dc164018
6 changed files with 268 additions and 0 deletions

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