use anyhow::{Context, Result};
use clap::{Args, Subcommand, ValueEnum};
use seiza_stretch::{
ColorStrategy, GhsParams, ImageShape, LayeredParams, MedianAnchorParams, StretchConfig,
StretchModel, StretchParams,
};
use std::path::PathBuf;
#[derive(Args)]
pub(crate) struct StretchArgs {
input: PathBuf,
#[arg(short, long)]
output: PathBuf,
#[arg(long, value_enum, default_value = "linked")]
color_strategy: ColorStrategyArg,
#[arg(long, default_value_t = crate::preview::MAXIMUM_SAMPLES)]
max_analysis_samples: usize,
#[command(subcommand)]
model: StretchModelArgs,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum ColorStrategyArg {
Linked,
Unlinked,
LuminancePreserving,
}
impl From<ColorStrategyArg> for ColorStrategy {
fn from(value: ColorStrategyArg) -> Self {
match value {
ColorStrategyArg::Linked => Self::Linked,
ColorStrategyArg::Unlinked => Self::Unlinked,
ColorStrategyArg::LuminancePreserving => Self::LuminancePreserving,
}
}
}
impl ColorStrategyArg {
fn summary(self) -> &'static str {
match self {
Self::Linked => "linked channels",
Self::Unlinked => "unlinked channels",
Self::LuminancePreserving => "luminance-preserving",
}
}
}
#[derive(Subcommand)]
enum StretchModelArgs {
Identity,
Linear {
#[arg(long)]
black: f64,
#[arg(long)]
white: f64,
},
Asinh {
#[arg(long)]
black: f64,
#[arg(long)]
white: f64,
#[arg(long, default_value_t = 10.0)]
strength: f64,
},
PercentileAsinh {
#[arg(long, default_value_t = 0.01)]
black_percentile: f64,
#[arg(long, default_value_t = 0.995)]
white_percentile: f64,
#[arg(long, default_value_t = 10.0)]
strength: f64,
},
Mtf {
#[arg(long)]
shadows: f64,
#[arg(long)]
midtone: f64,
#[arg(long)]
highlights: f64,
},
Ghs {
#[arg(long, default_value_t = 1.0)]
stretch_factor: f64,
#[arg(long, default_value_t = 0.0, allow_negative_numbers = true)]
local_intensity: f64,
#[arg(long, default_value_t = 0.0)]
symmetry_point: f64,
#[arg(long, default_value_t = 0.0)]
protect_shadows: f64,
#[arg(long, default_value_t = 1.0)]
protect_highlights: f64,
#[arg(long, default_value_t = 0.0, allow_negative_numbers = true)]
black: f64,
#[arg(long, default_value_t = 1.0, allow_negative_numbers = true)]
white: f64,
},
AutoMtf {
#[arg(long, default_value_t = 0.2)]
target_median: f64,
#[arg(long, default_value_t = -2.8, allow_negative_numbers = true)]
shadows_clip: f64,
},
MedianAnchor {
#[arg(long, default_value_t = 0.25)]
target_median: f64,
#[arg(long, default_value_t = 2.7)]
black_point_sigma: f64,
#[arg(long)]
no_black_clip: bool,
#[arg(long, default_value_t = 0.0)]
curves_boost: f64,
#[arg(long, default_value_t = 0.0)]
hdr_amount: f64,
#[arg(long, default_value_t = 0.75)]
hdr_knee: f64,
#[arg(long)]
normalize: bool,
},
Layered {
#[arg(long, default_value_t = 0.15)]
target_background: f64,
#[arg(long, default_value_t = 0.5)]
aggressiveness: f64,
#[arg(long, default_value_t = 0.0)]
dynamic_range_compression: f64,
#[arg(long, default_value_t = 256)]
scale_separation: u32,
#[arg(long, default_value_t = 0.0)]
contrast_recovery: f64,
#[arg(long, default_value_t = 0.0)]
saturation_amount: f64,
#[arg(long, default_value_t = 0.5)]
saturation_boost: f64,
#[arg(long)]
no_lightness_mask: bool,
},
}
impl StretchModelArgs {
fn model(&self) -> StretchModel {
match *self {
Self::Identity => StretchModel::Identity,
Self::Linear { black, white } => StretchModel::Linear { black, white },
Self::Asinh {
black,
white,
strength,
} => StretchModel::Asinh {
black,
white,
strength,
},
Self::PercentileAsinh {
black_percentile,
white_percentile,
strength,
} => StretchModel::PercentileAsinh {
black_percentile,
white_percentile,
strength,
},
Self::Mtf {
shadows,
midtone,
highlights,
} => StretchModel::Mtf {
shadows,
midtone,
highlights,
},
Self::Ghs {
stretch_factor,
local_intensity,
symmetry_point,
protect_shadows,
protect_highlights,
black,
white,
} => StretchModel::Ghs(GhsParams {
stretch_factor,
local_intensity,
symmetry_point,
protect_shadows,
protect_highlights,
black,
white,
}),
Self::AutoMtf {
target_median,
shadows_clip,
} => StretchModel::AutoMtf(StretchParams {
target_median,
shadows_clip,
}),
Self::MedianAnchor {
target_median,
black_point_sigma,
no_black_clip,
curves_boost,
hdr_amount,
hdr_knee,
normalize,
} => StretchModel::MedianAnchor(MedianAnchorParams {
target_median,
black_point_sigma: (!no_black_clip).then_some(black_point_sigma),
curves_boost,
hdr_amount,
hdr_knee,
normalize,
}),
Self::Layered {
target_background,
aggressiveness,
dynamic_range_compression,
scale_separation,
contrast_recovery,
saturation_amount,
saturation_boost,
no_lightness_mask,
} => StretchModel::Layered(LayeredParams {
target_background,
aggressiveness,
dynamic_range_compression,
scale_separation,
contrast_recovery,
saturation_amount,
saturation_boost,
lightness_mask: !no_lightness_mask,
}),
}
}
fn summary(&self) -> String {
match *self {
Self::Identity => "identity clamp to [0, 1]".to_string(),
Self::Linear { black, white } => format!("linear black {black}, white {white}"),
Self::Asinh {
black,
white,
strength,
} => format!("asinh black {black}, white {white}, strength {strength}"),
Self::PercentileAsinh {
black_percentile,
white_percentile,
strength,
} => format!(
"percentile asinh black {black_percentile}, white {white_percentile}, strength {strength}"
),
Self::Mtf {
shadows,
midtone,
highlights,
} => format!("MTF shadows {shadows}, midtone {midtone}, highlights {highlights}"),
Self::Ghs {
stretch_factor,
local_intensity,
symmetry_point,
protect_shadows,
protect_highlights,
black,
white,
} => format!(
"generalized hyperbolic stretch factor {stretch_factor}, local intensity {local_intensity}, symmetry {symmetry_point}, shadow protection {protect_shadows}, highlight protection {protect_highlights}, black {black}, white {white}"
),
Self::AutoMtf {
target_median,
shadows_clip,
} => format!("auto MTF target median {target_median}, shadows clip {shadows_clip}"),
Self::MedianAnchor {
target_median,
black_point_sigma,
no_black_clip,
curves_boost,
hdr_amount,
hdr_knee,
normalize,
} => format!(
"median anchor target median {target_median}, black point {}, curves boost {curves_boost}, highlight compression {hdr_amount} above {hdr_knee}{}",
if no_black_clip {
"at the minimum".to_string()
} else {
format!("{black_point_sigma} sigma below the median")
},
if normalize { ", normalized" } else { "" }
),
Self::Layered {
target_background,
aggressiveness,
dynamic_range_compression,
scale_separation,
contrast_recovery,
saturation_amount,
saturation_boost,
no_lightness_mask,
} => format!(
"layered target background {target_background}, aggressiveness {aggressiveness}, dynamic range compression {dynamic_range_compression}, scale separation {scale_separation} px, contrast recovery {contrast_recovery}, saturation {saturation_amount} (boost {saturation_boost}{})",
if no_lightness_mask {
", no lightness mask"
} else {
""
}
),
}
}
}
pub(crate) fn run(args: StretchArgs) -> Result<()> {
crate::provenance::validate_path_roles([
("stretch input".into(), args.input.as_path()),
("stretch output".into(), args.output.as_path()),
])?;
let frame = crate::common::open_frame(&args.input, "stretch input")?;
let config = StretchConfig {
model: args.model.model(),
color_strategy: args.color_strategy.into(),
max_analysis_samples: args.max_analysis_samples,
};
let plan = config
.resolve_for(&frame.image.data, frame.image.channels)
.context("could not resolve stretch model")?;
let shape = ImageShape::new(frame.image.width, frame.image.height, frame.image.channels);
let pixels = plan
.apply_u8_image(&frame.image.data, shape)
.context("could not apply stretch model")?;
crate::preview::write_display_image(
&args.output,
frame.image.width,
frame.image.height,
frame.image.channels,
pixels,
)?;
crate::common::wrote(
&args.output,
format_args!(
"{}, {}, display-referred",
args.model.summary(),
args.color_strategy.summary(),
),
);
Ok(())
}