use crate::{
hitchhikers_ssim, ms_ssim, perceptual_ssim, render_map as render_map_into, ssim, ColorSpace,
HitchhikersOptions, MsSsimMethod, MsSsimOptions, PerceptualOptions, Plane, Pooling, Rgba8,
SsimError, SsimOptions, SsimOutcome, DEFAULT_WEIGHTS,
};
use blazediff_shared::Image;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::PyBytes;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Metric {
Ssim,
MsSsim,
HitchhikersSsim,
PerceptualSsim,
}
impl Metric {
fn as_str(self) -> &'static str {
match self {
Metric::Ssim => "ssim",
Metric::MsSsim => "ms-ssim",
Metric::HitchhikersSsim => "hitchhikers-ssim",
Metric::PerceptualSsim => "perceptual-ssim",
}
}
fn parse(name: &str) -> Option<Self> {
match name.trim().to_ascii_lowercase().replace('_', "-").as_str() {
"ssim" => Some(Metric::Ssim),
"ms-ssim" | "msssim" | "multiscale-ssim" => Some(Metric::MsSsim),
"hitchhikers-ssim" | "hitchhikers" | "hssim" => Some(Metric::HitchhikersSsim),
"perceptual-ssim" | "perceptual" => Some(Metric::PerceptualSsim),
_ => None,
}
}
const NAMES: [&'static str; 4] = ["ssim", "ms-ssim", "hitchhikers-ssim", "perceptual-ssim"];
}
struct Options<'a> {
metric: Option<&'a str>,
min_score: Option<f64>,
window_size: Option<u32>,
k1: Option<f64>,
k2: Option<f64>,
bit_depth: Option<u32>,
return_map: Option<bool>,
compression: Option<u8>,
quality: Option<u8>,
weights: Option<Vec<f64>>,
method: Option<&'a str>,
window_stride: Option<u32>,
cov_pooling: Option<bool>,
color: Option<&'a str>,
chroma_weight: Option<f64>,
chroma_subsample: Option<u32>,
pooling: Option<&'a str>,
deviation_weight: Option<f64>,
}
fn invalid(message: impl AsRef<str>) -> PyErr {
PyValueError::new_err(message.as_ref().to_string())
}
impl From<SsimError> for PyErr {
fn from(error: SsimError) -> Self {
PyValueError::new_err(error.to_string())
}
}
fn parse_method(name: &str) -> PyResult<MsSsimMethod> {
match name.trim().to_ascii_lowercase().replace('_', "-").as_str() {
"product" => Ok(MsSsimMethod::Product),
"weighted-sum" | "weightedsum" | "sum" => Ok(MsSsimMethod::WeightedSum),
other => Err(invalid(format!(
"Unknown pooling method '{other}'. Expected one of: product, weighted-sum"
))),
}
}
fn parse_color_space(name: &str) -> PyResult<ColorSpace> {
match name.trim().to_ascii_lowercase().replace('_', "-").as_str() {
"gamma-luma" | "luma" => Ok(ColorSpace::GammaLuma),
"lab" => Ok(ColorSpace::Lab),
other => Err(invalid(format!(
"Unknown color space '{other}'. Expected one of: gamma-luma, lab"
))),
}
}
fn parse_pooling(name: &str) -> PyResult<Pooling> {
match name.trim().to_ascii_lowercase().replace('_', "-").as_str() {
"mean" => Ok(Pooling::Mean),
"mad" | "mean-absolute-deviation" => Ok(Pooling::MeanAbsoluteDeviation),
other => Err(invalid(format!(
"Unknown pooling '{other}'. Expected one of: mean, mad"
))),
}
}
impl Options<'_> {
fn metric(&self) -> PyResult<Metric> {
let Some(name) = self.metric else {
return Ok(Metric::Ssim);
};
Metric::parse(name).ok_or_else(|| {
invalid(format!(
"Unknown metric '{name}'. Expected one of: {}",
Metric::NAMES.join(", ")
))
})
}
fn shared(&self) -> PyResult<SsimOptions> {
let defaults = SsimOptions::default();
let window_size = self.window_size.unwrap_or(defaults.window_size as u32);
if window_size == 0 {
return Err(invalid("window_size must be greater than 0"));
}
Ok(SsimOptions {
window_size: window_size as usize,
k1: self.k1.unwrap_or(defaults.k1),
k2: self.k2.unwrap_or(defaults.k2),
bit_depth: self.bit_depth.unwrap_or(defaults.bit_depth),
})
}
fn weights(&self) -> PyResult<Vec<f64>> {
match &self.weights {
None => Ok(DEFAULT_WEIGHTS.to_vec()),
Some(weights) if weights.is_empty() => Err(invalid("weights must not be empty")),
Some(weights) => Ok(weights.clone()),
}
}
fn ms_ssim(&self) -> PyResult<MsSsimOptions> {
Ok(MsSsimOptions {
weights: self.weights()?,
method: match self.method {
Some(name) => parse_method(name)?,
None => MsSsimMethod::default(),
},
})
}
fn hitchhikers(&self) -> PyResult<HitchhikersOptions> {
if self.window_stride == Some(0) {
return Err(invalid("window_stride must be greater than 0"));
}
Ok(HitchhikersOptions {
window_stride: self.window_stride.map(|stride| stride as usize),
cov_pooling: self.cov_pooling.unwrap_or(true),
})
}
fn perceptual(&self) -> PyResult<PerceptualOptions> {
let defaults = PerceptualOptions::default();
Ok(PerceptualOptions {
weights: self.weights()?,
method: match self.method {
Some(name) => parse_method(name)?,
None => defaults.method,
},
color: match self.color {
Some(name) => parse_color_space(name)?,
None => defaults.color,
},
chroma_weight: self.chroma_weight.unwrap_or(defaults.chroma_weight),
chroma_subsample: self.chroma_subsample.unwrap_or(defaults.chroma_subsample),
pooling: match self.pooling {
Some(name) => parse_pooling(name)?,
None => defaults.pooling,
},
deviation_weight: self.deviation_weight.unwrap_or(defaults.deviation_weight),
})
}
}
#[pyclass(get_all, module = "blazediff_ssim")]
pub struct PySsimResult {
pub match_result: bool,
pub reason: Option<String>,
pub metric: String,
pub score: f64,
pub below_count: u32,
pub below_percentage: f64,
pub map: Option<Py<PyBytes>>,
pub map_width: u32,
pub map_height: u32,
}
#[pymethods]
impl PySsimResult {
fn __repr__(&self) -> String {
format!(
"SsimResult(match_result={}, reason={}, metric='{}', score={}, below_count={}, below_percentage={}, map_width={}, map_height={})",
if self.match_result { "True" } else { "False" },
match &self.reason {
Some(reason) => format!("'{}'", reason),
None => "None".to_string(),
},
self.metric,
self.score,
self.below_count,
self.below_percentage,
self.map_width,
self.map_height,
)
}
}
fn view(image: &Image) -> Rgba8<'_> {
Rgba8::new(&image.data, image.width as usize, image.height as usize)
}
#[inline]
fn count_below(values: &[f32], floor: f32) -> u32 {
values.iter().filter(|value| **value < floor).count() as u32
}
fn map_bytes<'py>(py: Python<'py>, map: &[f32]) -> Bound<'py, PyBytes> {
let mut buffer = Vec::with_capacity(map.len() * 4);
for value in map {
buffer.extend_from_slice(&value.to_le_bytes());
}
PyBytes::new_bound(py, &buffer)
}
fn map_from_bytes(bytes: &[u8]) -> PyResult<Vec<f32>> {
let values = bytes.chunks_exact(4);
if !values.remainder().is_empty() {
return Err(invalid(format!(
"map is {} bytes, which is not a whole number of float32 values",
bytes.len()
)));
}
Ok(values
.map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
.collect())
}
fn run(
metric: Metric,
image1: Rgba8<'_>,
image2: Rgba8<'_>,
options: &Options<'_>,
) -> PyResult<SsimOutcome> {
let shared = options.shared()?;
if metric == Metric::PerceptualSsim {
return Ok(perceptual_ssim(
image1,
image2,
&shared,
&options.perceptual()?,
)?);
}
let plane1 = Plane::from_rgba8(image1)?;
let plane2 = Plane::from_rgba8(image2)?;
Ok(match metric {
Metric::Ssim => ssim(&plane1, &plane2, &shared)?,
Metric::MsSsim => ms_ssim(&plane1, &plane2, &shared, &options.ms_ssim()?)?,
Metric::HitchhikersSsim => {
hitchhikers_ssim(&plane1, &plane2, &shared, &options.hitchhikers()?)?
}
Metric::PerceptualSsim => unreachable!("handled above"),
})
}
fn shape(
py: Python<'_>,
metric: Metric,
outcome: SsimOutcome,
options: &Options<'_>,
) -> PySsimResult {
let min_score = options.min_score.unwrap_or(1.0);
let below_count = count_below(&outcome.map, min_score as f32);
let below_percentage = if outcome.map.is_empty() {
0.0
} else {
(below_count as f64 / outcome.map.len() as f64) * 100.0
};
let match_result = outcome.score >= min_score;
PySsimResult {
match_result,
reason: if match_result {
None
} else {
Some("score-below-threshold".to_string())
},
metric: metric.as_str().to_string(),
score: outcome.score,
below_count,
below_percentage,
map_width: outcome.map_width as u32,
map_height: outcome.map_height as u32,
map: options
.return_map
.unwrap_or(false)
.then(|| map_bytes(py, &outcome.map).unbind()),
}
}
fn write_map(
path: &str,
width: u32,
height: u32,
outcome: &SsimOutcome,
options: &Options<'_>,
) -> PyResult<()> {
let mut image = Image::new(width, height);
render_map_into(
&mut image.data,
width as usize,
height as usize,
&outcome.map,
outcome.map_width,
outcome.map_height,
);
blazediff_shared::save_image(
&image,
path,
options.compression.unwrap_or(0),
options.quality.unwrap_or(90),
)
.map_err(|e| invalid(format!("Failed to save map: {e}")))
}
fn compare_images(
py: Python<'_>,
image1: Image,
image2: Image,
map_output: Option<&str>,
options: &Options<'_>,
) -> PyResult<PySsimResult> {
let metric = options.metric()?;
let outcome = run(metric, view(&image1), view(&image2), options)?;
if let Some(path) = map_output {
write_map(path, image1.width, image1.height, &outcome, options)?;
}
Ok(shape(py, metric, outcome, options))
}
fn load(base_path: &str, compare_path: &str) -> PyResult<(Image, Image)> {
blazediff_shared::load_image_pair(base_path, compare_path)
.map_err(|e| invalid(format!("Failed to load images: {e}")))
}
#[pyfunction]
#[pyo3(signature = (
base_path,
compare_path,
map_output=None,
*,
metric=None,
min_score=None,
window_size=None,
k1=None,
k2=None,
bit_depth=None,
return_map=None,
compression=None,
quality=None,
weights=None,
method=None,
window_stride=None,
cov_pooling=None,
color=None,
chroma_weight=None,
chroma_subsample=None,
pooling=None,
deviation_weight=None,
))]
#[allow(clippy::too_many_arguments)]
fn compare(
py: Python<'_>,
base_path: &str,
compare_path: &str,
map_output: Option<&str>,
metric: Option<&str>,
min_score: Option<f64>,
window_size: Option<u32>,
k1: Option<f64>,
k2: Option<f64>,
bit_depth: Option<u32>,
return_map: Option<bool>,
compression: Option<u8>,
quality: Option<u8>,
weights: Option<Vec<f64>>,
method: Option<&str>,
window_stride: Option<u32>,
cov_pooling: Option<bool>,
color: Option<&str>,
chroma_weight: Option<f64>,
chroma_subsample: Option<u32>,
pooling: Option<&str>,
deviation_weight: Option<f64>,
) -> PyResult<PySsimResult> {
let options = Options {
metric,
min_score,
window_size,
k1,
k2,
bit_depth,
return_map,
compression,
quality,
weights,
method,
window_stride,
cov_pooling,
color,
chroma_weight,
chroma_subsample,
pooling,
deviation_weight,
};
let (image1, image2) = load(base_path, compare_path)?;
compare_images(py, image1, image2, map_output, &options)
}
#[pyfunction]
#[pyo3(signature = (
base,
comparison,
map_output=None,
*,
metric=None,
min_score=None,
window_size=None,
k1=None,
k2=None,
bit_depth=None,
return_map=None,
compression=None,
quality=None,
weights=None,
method=None,
window_stride=None,
cov_pooling=None,
color=None,
chroma_weight=None,
chroma_subsample=None,
pooling=None,
deviation_weight=None,
))]
#[allow(clippy::too_many_arguments)]
fn compare_buffers(
py: Python<'_>,
base: &[u8],
comparison: &[u8],
map_output: Option<&str>,
metric: Option<&str>,
min_score: Option<f64>,
window_size: Option<u32>,
k1: Option<f64>,
k2: Option<f64>,
bit_depth: Option<u32>,
return_map: Option<bool>,
compression: Option<u8>,
quality: Option<u8>,
weights: Option<Vec<f64>>,
method: Option<&str>,
window_stride: Option<u32>,
cov_pooling: Option<bool>,
color: Option<&str>,
chroma_weight: Option<f64>,
chroma_subsample: Option<u32>,
pooling: Option<&str>,
deviation_weight: Option<f64>,
) -> PyResult<PySsimResult> {
let options = Options {
metric,
min_score,
window_size,
k1,
k2,
bit_depth,
return_map,
compression,
quality,
weights,
method,
window_stride,
cov_pooling,
color,
chroma_weight,
chroma_subsample,
pooling,
deviation_weight,
};
let (image1, image2) = blazediff_shared::decode_image_pair(base, comparison)
.map_err(|e| invalid(format!("Failed to load images: {e}")))?;
compare_images(py, image1, image2, map_output, &options)
}
#[pyfunction]
#[pyo3(signature = (
base,
comparison,
width,
height,
*,
metric=None,
min_score=None,
window_size=None,
k1=None,
k2=None,
bit_depth=None,
return_map=None,
weights=None,
method=None,
window_stride=None,
cov_pooling=None,
color=None,
chroma_weight=None,
chroma_subsample=None,
pooling=None,
deviation_weight=None,
))]
#[allow(clippy::too_many_arguments)]
fn compare_rgba(
py: Python<'_>,
base: &[u8],
comparison: &[u8],
width: u32,
height: u32,
metric: Option<&str>,
min_score: Option<f64>,
window_size: Option<u32>,
k1: Option<f64>,
k2: Option<f64>,
bit_depth: Option<u32>,
return_map: Option<bool>,
weights: Option<Vec<f64>>,
method: Option<&str>,
window_stride: Option<u32>,
cov_pooling: Option<bool>,
color: Option<&str>,
chroma_weight: Option<f64>,
chroma_subsample: Option<u32>,
pooling: Option<&str>,
deviation_weight: Option<f64>,
) -> PyResult<PySsimResult> {
let options = Options {
metric,
min_score,
window_size,
k1,
k2,
bit_depth,
return_map,
compression: None,
quality: None,
weights,
method,
window_stride,
cov_pooling,
color,
chroma_weight,
chroma_subsample,
pooling,
deviation_weight,
};
let metric = options.metric()?;
let (width, height) = (width as usize, height as usize);
let outcome = run(
metric,
Rgba8::new(base, width, height),
Rgba8::new(comparison, width, height),
&options,
)?;
Ok(shape(py, metric, outcome, &options))
}
#[pyfunction]
fn render_map<'py>(
py: Python<'py>,
map: &[u8],
map_width: u32,
map_height: u32,
width: u32,
height: u32,
) -> PyResult<Bound<'py, PyBytes>> {
let map = map_from_bytes(map)?;
let (width, height) = (width as usize, height as usize);
let mut output = vec![0u8; width * height * 4];
render_map_into(
&mut output,
width,
height,
&map,
map_width as usize,
map_height as usize,
);
Ok(PyBytes::new_bound(py, &output))
}
#[pyfunction]
fn metrics() -> Vec<String> {
Metric::NAMES.iter().map(|name| name.to_string()).collect()
}
#[pymodule]
fn blazediff_ssim(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(compare, m)?)?;
m.add_function(wrap_pyfunction!(compare_buffers, m)?)?;
m.add_function(wrap_pyfunction!(compare_rgba, m)?)?;
m.add_function(wrap_pyfunction!(render_map, m)?)?;
m.add_function(wrap_pyfunction!(metrics, m)?)?;
m.add_class::<PySsimResult>()?;
Ok(())
}