use crate::error::{PanimgError, Result};
use crate::ops::color::{hsl_to_rgb, rgb_to_hsl};
use crate::ops::{Operation, OperationDescription};
use crate::schema::{CommandSchema, ParamRange, ParamSchema, ParamType};
use image::DynamicImage;
pub struct TiltShiftOp {
pub sigma: f32,
pub focus_position: f32,
pub focus_width: f32,
pub transition: f32,
pub saturation: f32,
}
impl TiltShiftOp {
pub fn new(
sigma: f32,
focus_position: f32,
focus_width: f32,
transition: f32,
saturation: f32,
) -> Result<Self> {
if !(0.1..=100.0).contains(&sigma) {
return Err(PanimgError::InvalidArgument {
message: format!("sigma must be between 0.1 and 100.0, got {sigma}"),
suggestion: "use a value like 8.0 or 12.0".into(),
});
}
if !(0.0..=1.0).contains(&focus_position) {
return Err(PanimgError::InvalidArgument {
message: format!(
"focus-position must be between 0.0 and 1.0, got {focus_position}"
),
suggestion: "0.5 = center, 0.3 = upper third, 0.7 = lower third".into(),
});
}
if !(0.01..=1.0).contains(&focus_width) {
return Err(PanimgError::InvalidArgument {
message: format!("focus-width must be between 0.01 and 1.0, got {focus_width}"),
suggestion: "use a value like 0.15 (15% of image height)".into(),
});
}
if !(0.01..=1.0).contains(&transition) {
return Err(PanimgError::InvalidArgument {
message: format!("transition must be between 0.01 and 1.0, got {transition}"),
suggestion: "use a value like 0.2 (20% of image height)".into(),
});
}
if !(0.0..=3.0).contains(&saturation) {
return Err(PanimgError::InvalidArgument {
message: format!("saturation must be between 0.0 and 3.0, got {saturation}"),
suggestion: "1.0 = unchanged, 1.3 = slightly boosted for miniature look".into(),
});
}
Ok(Self {
sigma,
focus_position,
focus_width,
transition,
saturation,
})
}
}
fn generate_mask(h: u32, focus_position: f32, focus_width: f32, transition: f32) -> Vec<f32> {
let hf = h as f32;
let center = focus_position * hf;
let half_band = focus_width * hf * 0.5;
let trans_pixels = transition * hf;
(0..h)
.map(|row| {
let y = row as f32 + 0.5;
let dist = (y - center).abs() - half_band;
if dist <= 0.0 {
0.0 } else if dist >= trans_pixels {
1.0 } else {
let t = dist / trans_pixels;
t * t * (3.0 - 2.0 * t)
}
})
.collect()
}
impl Operation for TiltShiftOp {
fn name(&self) -> &str {
"tilt-shift"
}
fn apply(&self, img: DynamicImage) -> Result<DynamicImage> {
let (w, h) = (img.width(), img.height());
let blurred = img.blur(self.sigma);
let mask = generate_mask(h, self.focus_position, self.focus_width, self.transition);
let mut out = img.to_rgba8();
let blur_rgba = blurred.to_rgba8();
let apply_sat = (self.saturation - 1.0).abs() > f32::EPSILON;
for y in 0..h {
let t = mask[y as usize];
for x in 0..w {
let need_blend = t > 0.0;
let need_work = need_blend || apply_sat;
if !need_work {
continue;
}
let op = out.get_pixel(x, y);
let (mut r, mut g, mut b) = (op[0], op[1], op[2]);
if need_blend {
let bp = blur_rgba.get_pixel(x, y);
let inv_t = 1.0 - t;
r = (r as f32 * inv_t + bp[0] as f32 * t).round() as u8;
g = (g as f32 * inv_t + bp[1] as f32 * t).round() as u8;
b = (b as f32 * inv_t + bp[2] as f32 * t).round() as u8;
}
if apply_sat {
let (h, s, l) = rgb_to_hsl(r, g, b);
let new_s = (s * self.saturation).clamp(0.0, 1.0);
(r, g, b) = hsl_to_rgb(h, new_s, l);
}
out.put_pixel(x, y, image::Rgba([r, g, b, op[3]]));
}
}
Ok(DynamicImage::ImageRgba8(out))
}
fn describe(&self) -> OperationDescription {
OperationDescription {
operation: "tilt-shift".into(),
params: serde_json::json!({
"sigma": self.sigma,
"focus_position": self.focus_position,
"focus_width": self.focus_width,
"transition": self.transition,
"saturation": self.saturation,
}),
description: format!(
"Tilt-shift effect (sigma={}, focus={:.0}%, width={:.0}%, transition={:.0}%, saturation={})",
self.sigma,
self.focus_position * 100.0,
self.focus_width * 100.0,
self.transition * 100.0,
self.saturation,
),
}
}
fn schema() -> CommandSchema {
CommandSchema {
command: "tilt-shift".into(),
description: "Simulate tilt-shift (miniature/diorama) lens effect".into(),
params: vec![
ParamSchema {
name: "input".into(),
param_type: ParamType::Path,
required: true,
description: "Input image path".into(),
default: None,
choices: None,
range: None,
},
ParamSchema {
name: "output".into(),
param_type: ParamType::Path,
required: true,
description: "Output image path".into(),
default: None,
choices: None,
range: None,
},
ParamSchema {
name: "sigma".into(),
param_type: ParamType::Float,
required: false,
description: "Out-of-focus blur strength (default: 8.0)".into(),
default: Some(serde_json::json!(8.0)),
choices: None,
range: Some(ParamRange {
min: 0.1,
max: 100.0,
}),
},
ParamSchema {
name: "focus_position".into(),
param_type: ParamType::Float,
required: false,
description:
"Vertical center of the focus band (0=top, 1=bottom, default: 0.5)".into(),
default: Some(serde_json::json!(0.5)),
choices: None,
range: Some(ParamRange { min: 0.0, max: 1.0 }),
},
ParamSchema {
name: "focus_width".into(),
param_type: ParamType::Float,
required: false,
description:
"Height of the focus band as fraction of image height (default: 0.15)"
.into(),
default: Some(serde_json::json!(0.15)),
choices: None,
range: Some(ParamRange {
min: 0.01,
max: 1.0,
}),
},
ParamSchema {
name: "transition".into(),
param_type: ParamType::Float,
required: false,
description: "Transition zone width as fraction of image height (default: 0.2)"
.into(),
default: Some(serde_json::json!(0.2)),
choices: None,
range: Some(ParamRange {
min: 0.01,
max: 1.0,
}),
},
ParamSchema {
name: "saturation".into(),
param_type: ParamType::Float,
required: false,
description: "Saturation multiplier (>1 enhances miniature look, default: 1.0)"
.into(),
default: Some(serde_json::json!(1.0)),
choices: None,
range: Some(ParamRange { min: 0.0, max: 3.0 }),
},
],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use image::{DynamicImage, RgbaImage};
fn test_image() -> DynamicImage {
DynamicImage::ImageRgba8(RgbaImage::from_fn(16, 16, |_x, y| {
let v = (y * 16).min(255) as u8;
image::Rgba([v, v, v, 255])
}))
}
#[test]
fn preserves_dimensions() {
let op = TiltShiftOp::new(4.0, 0.5, 0.15, 0.2, 1.0).unwrap();
let result = op.apply(test_image()).unwrap();
assert_eq!(result.width(), 16);
assert_eq!(result.height(), 16);
}
#[test]
fn focus_band_stays_sharp() {
let op = TiltShiftOp::new(8.0, 0.5, 1.0, 0.01, 1.0).unwrap();
let img = test_image();
let orig = img.to_rgba8();
let result = op.apply(img).unwrap().to_rgba8();
for y in 0..16u32 {
for x in 0..16u32 {
let o = orig.get_pixel(x, y);
let r = result.get_pixel(x, y);
assert!(
(o[0] as i32 - r[0] as i32).unsigned_abs() <= 1,
"pixel ({x},{y}) diverged: orig={}, result={}",
o[0],
r[0]
);
}
}
}
#[test]
fn out_of_focus_region_is_blurred() {
let op = TiltShiftOp::new(10.0, 0.0, 0.01, 0.01, 1.0).unwrap();
let img = test_image();
let orig = img.to_rgba8();
let result = op.apply(img).unwrap().to_rgba8();
let y = 15u32;
let mut any_diff = false;
for x in 0..16u32 {
let o = orig.get_pixel(x, y);
let r = result.get_pixel(x, y);
if o[0] != r[0] {
any_diff = true;
break;
}
}
assert!(
any_diff,
"bottom row should be blurred and differ from original"
);
}
#[test]
fn saturation_boost_changes_colors() {
let img = DynamicImage::ImageRgba8(RgbaImage::from_pixel(
4,
4,
image::Rgba([200, 100, 50, 255]),
));
let op = TiltShiftOp::new(2.0, 0.5, 0.5, 0.2, 2.0).unwrap();
let result = op.apply(img).unwrap().to_rgba8();
let p = result.get_pixel(2, 2);
assert!(p[0] > p[1]);
assert!(p[1] > p[2]);
}
#[test]
fn generate_mask_center_focus() {
let mask = generate_mask(100, 0.5, 0.2, 0.1);
assert_eq!(mask.len(), 100);
assert_eq!(mask[50], 0.0);
assert_eq!(mask[45], 0.0);
assert_eq!(mask[55], 0.0);
assert_eq!(mask[0], 1.0);
assert_eq!(mask[99], 1.0);
}
#[test]
fn generate_mask_smoothstep_monotonic() {
let mask = generate_mask(200, 0.5, 0.1, 0.3);
for i in 100..199 {
assert!(
mask[i + 1] >= mask[i] - f32::EPSILON,
"mask not monotonic at row {i}: {} > {}",
mask[i],
mask[i + 1]
);
}
}
#[test]
fn invalid_params() {
assert!(TiltShiftOp::new(0.0, 0.5, 0.15, 0.2, 1.0).is_err()); assert!(TiltShiftOp::new(101.0, 0.5, 0.15, 0.2, 1.0).is_err()); assert!(TiltShiftOp::new(8.0, -0.1, 0.15, 0.2, 1.0).is_err()); assert!(TiltShiftOp::new(8.0, 1.1, 0.15, 0.2, 1.0).is_err()); assert!(TiltShiftOp::new(8.0, 0.5, 0.0, 0.2, 1.0).is_err()); assert!(TiltShiftOp::new(8.0, 0.5, 0.15, 0.0, 1.0).is_err()); assert!(TiltShiftOp::new(8.0, 0.5, 0.15, 0.2, -0.1).is_err()); assert!(TiltShiftOp::new(8.0, 0.5, 0.15, 0.2, 3.1).is_err()); }
}