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:
@@ -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)?;
|
||||
|
||||
198
pixstrip-core/src/operations/adjustments.rs
Normal file
198
pixstrip-core/src/operations/adjustments.rs
Normal 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)
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user