Add image adjustments pipeline (brightness, contrast, crop, effects)
This commit is contained in:
@@ -7,6 +7,7 @@ use rayon::prelude::*;
|
|||||||
use crate::encoder::OutputEncoder;
|
use crate::encoder::OutputEncoder;
|
||||||
use crate::error::{PixstripError, Result};
|
use crate::error::{PixstripError, Result};
|
||||||
use crate::loader::ImageLoader;
|
use crate::loader::ImageLoader;
|
||||||
|
use crate::operations::adjustments::apply_adjustments;
|
||||||
use crate::operations::resize::resize_image;
|
use crate::operations::resize::resize_image;
|
||||||
use crate::operations::watermark::apply_watermark;
|
use crate::operations::watermark::apply_watermark;
|
||||||
use crate::operations::{Flip, Rotation};
|
use crate::operations::{Flip, Rotation};
|
||||||
@@ -340,6 +341,13 @@ impl PipelineExecutor {
|
|||||||
img = resize_image(&img, config)?;
|
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)
|
// Watermark (after resize so watermark is at correct scale)
|
||||||
if let Some(ref config) = job.watermark {
|
if let Some(ref config) = job.watermark {
|
||||||
img = apply_watermark(img, config)?;
|
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 metadata;
|
||||||
pub mod rename;
|
pub mod rename;
|
||||||
pub mod resize;
|
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 ---
|
// --- Rename ---
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub struct ProcessingJob {
|
|||||||
pub resize: Option<ResizeConfig>,
|
pub resize: Option<ResizeConfig>,
|
||||||
pub rotation: Option<Rotation>,
|
pub rotation: Option<Rotation>,
|
||||||
pub flip: Option<Flip>,
|
pub flip: Option<Flip>,
|
||||||
|
pub adjustments: Option<AdjustmentsConfig>,
|
||||||
pub convert: Option<ConvertConfig>,
|
pub convert: Option<ConvertConfig>,
|
||||||
pub compress: Option<CompressConfig>,
|
pub compress: Option<CompressConfig>,
|
||||||
pub metadata: Option<MetadataConfig>,
|
pub metadata: Option<MetadataConfig>,
|
||||||
@@ -31,6 +32,7 @@ impl ProcessingJob {
|
|||||||
resize: None,
|
resize: None,
|
||||||
rotation: None,
|
rotation: None,
|
||||||
flip: None,
|
flip: None,
|
||||||
|
adjustments: None,
|
||||||
convert: None,
|
convert: None,
|
||||||
compress: None,
|
compress: None,
|
||||||
metadata: None,
|
metadata: None,
|
||||||
@@ -49,6 +51,7 @@ impl ProcessingJob {
|
|||||||
if self.resize.is_some() { count += 1; }
|
if self.resize.is_some() { count += 1; }
|
||||||
if self.rotation.is_some() { count += 1; }
|
if self.rotation.is_some() { count += 1; }
|
||||||
if self.flip.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.convert.is_some() { count += 1; }
|
||||||
if self.compress.is_some() { count += 1; }
|
if self.compress.is_some() { count += 1; }
|
||||||
if self.metadata.is_some() { count += 1; }
|
if self.metadata.is_some() { count += 1; }
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ impl Preset {
|
|||||||
resize: self.resize.clone(),
|
resize: self.resize.clone(),
|
||||||
rotation: self.rotation,
|
rotation: self.rotation,
|
||||||
flip: self.flip,
|
flip: self.flip,
|
||||||
|
adjustments: None,
|
||||||
convert: self.convert.clone(),
|
convert: self.convert.clone(),
|
||||||
compress: self.compress.clone(),
|
compress: self.compress.clone(),
|
||||||
metadata: self.metadata.clone(),
|
metadata: self.metadata.clone(),
|
||||||
|
|||||||
@@ -1163,6 +1163,34 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) {
|
|||||||
_ => pixstrip_core::operations::Flip::None,
|
_ => 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
|
// Watermark
|
||||||
if cfg.watermark_enabled {
|
if cfg.watermark_enabled {
|
||||||
let position = match cfg.watermark_position {
|
let position = match cfg.watermark_position {
|
||||||
|
|||||||
Reference in New Issue
Block a user