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 napi::bindgen_prelude::*;
use napi_derive::napi;
#[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"];
}
#[napi(object)]
pub struct NapiMsSsimOptions {
pub weights: Option<Vec<f64>>,
pub method: Option<String>,
}
#[napi(object)]
pub struct NapiHitchhikersOptions {
pub window_stride: Option<u32>,
pub cov_pooling: Option<bool>,
}
#[napi(object)]
pub struct NapiPerceptualOptions {
pub weights: Option<Vec<f64>>,
pub method: Option<String>,
pub color: Option<String>,
pub chroma_weight: Option<f64>,
pub chroma_subsample: Option<u32>,
pub pooling: Option<String>,
pub deviation_weight: Option<f64>,
}
#[napi(object)]
pub struct NapiSsimOptions {
pub metric: Option<String>,
pub min_score: Option<f64>,
pub window_size: Option<u32>,
pub k1: Option<f64>,
pub k2: Option<f64>,
pub bit_depth: Option<u32>,
pub return_map: Option<bool>,
pub compression: Option<u8>,
pub quality: Option<u8>,
pub ms_ssim: Option<NapiMsSsimOptions>,
pub hitchhikers: Option<NapiHitchhikersOptions>,
pub perceptual: Option<NapiPerceptualOptions>,
}
#[napi(object)]
pub struct NapiSsimResult {
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<Float32Array>,
pub map_width: u32,
pub map_height: u32,
}
fn invalid(message: impl AsRef<str>) -> Error {
Error::new(Status::InvalidArg, message.as_ref().to_string())
}
fn parse_method(name: &str) -> Result<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) -> Result<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) -> Result<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"
))),
}
}
fn metric_of(options: &Option<NapiSsimOptions>) -> Result<Metric> {
let Some(name) = options.as_ref().and_then(|o| o.metric.as_deref()) else {
return Ok(Metric::Ssim);
};
Metric::parse(name).ok_or_else(|| {
invalid(format!(
"Unknown metric '{name}'. Expected one of: {}",
Metric::NAMES.join(", ")
))
})
}
fn shared_options(options: &Option<NapiSsimOptions>) -> Result<SsimOptions> {
let defaults = SsimOptions::default();
let Some(options) = options else {
return Ok(defaults);
};
let window_size = options.window_size.unwrap_or(defaults.window_size as u32);
if window_size == 0 {
return Err(invalid("windowSize must be greater than 0"));
}
Ok(SsimOptions {
window_size: window_size as usize,
k1: options.k1.unwrap_or(defaults.k1),
k2: options.k2.unwrap_or(defaults.k2),
bit_depth: options.bit_depth.unwrap_or(defaults.bit_depth),
})
}
fn weights_or_default(weights: Option<Vec<f64>>) -> Result<Vec<f64>> {
match weights {
None => Ok(DEFAULT_WEIGHTS.to_vec()),
Some(weights) if weights.is_empty() => Err(invalid("weights must not be empty")),
Some(weights) => Ok(weights),
}
}
fn ms_ssim_options(options: &Option<NapiSsimOptions>) -> Result<MsSsimOptions> {
let Some(source) = options.as_ref().and_then(|o| o.ms_ssim.as_ref()) else {
return Ok(MsSsimOptions::default());
};
Ok(MsSsimOptions {
weights: weights_or_default(source.weights.clone())?,
method: match source.method.as_deref() {
Some(name) => parse_method(name)?,
None => MsSsimMethod::default(),
},
})
}
fn hitchhikers_options(options: &Option<NapiSsimOptions>) -> Result<HitchhikersOptions> {
let Some(source) = options.as_ref().and_then(|o| o.hitchhikers.as_ref()) else {
return Ok(HitchhikersOptions::default());
};
if source.window_stride == Some(0) {
return Err(invalid("windowStride must be greater than 0"));
}
Ok(HitchhikersOptions {
window_stride: source.window_stride.map(|stride| stride as usize),
cov_pooling: source.cov_pooling.unwrap_or(true),
})
}
fn perceptual_options(options: &Option<NapiSsimOptions>) -> Result<PerceptualOptions> {
let defaults = PerceptualOptions::default();
let Some(source) = options.as_ref().and_then(|o| o.perceptual.as_ref()) else {
return Ok(defaults);
};
Ok(PerceptualOptions {
weights: weights_or_default(source.weights.clone())?,
method: match source.method.as_deref() {
Some(name) => parse_method(name)?,
None => defaults.method,
},
color: match source.color.as_deref() {
Some(name) => parse_color_space(name)?,
None => defaults.color,
},
chroma_weight: source.chroma_weight.unwrap_or(defaults.chroma_weight),
chroma_subsample: source.chroma_subsample.unwrap_or(defaults.chroma_subsample),
pooling: match source.pooling.as_deref() {
Some(name) => parse_pooling(name)?,
None => defaults.pooling,
},
deviation_weight: source.deviation_weight.unwrap_or(defaults.deviation_weight),
})
}
impl From<SsimError> for Error {
fn from(error: SsimError) -> Self {
Error::new(Status::InvalidArg, error.to_string())
}
}
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 run(
metric: Metric,
image1: Rgba8<'_>,
image2: Rgba8<'_>,
options: &Option<NapiSsimOptions>,
) -> Result<SsimOutcome> {
let shared = shared_options(options)?;
if metric == Metric::PerceptualSsim {
let perceptual = perceptual_options(options)?;
return Ok(perceptual_ssim(image1, image2, &shared, &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, &ms_ssim_options(options)?)?,
Metric::HitchhikersSsim => {
hitchhikers_ssim(&plane1, &plane2, &shared, &hitchhikers_options(options)?)?
}
Metric::PerceptualSsim => unreachable!("handled above"),
})
}
fn shape(
metric: Metric,
outcome: SsimOutcome,
options: &Option<NapiSsimOptions>,
) -> NapiSsimResult {
let min_score = options.as_ref().and_then(|o| o.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;
let return_map = options.as_ref().and_then(|o| o.return_map).unwrap_or(false);
NapiSsimResult {
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: return_map.then(|| Float32Array::new(outcome.map)),
}
}
fn write_map(
path: &str,
width: u32,
height: u32,
outcome: &SsimOutcome,
options: &Option<NapiSsimOptions>,
) -> Result<()> {
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,
);
let compression = options.as_ref().and_then(|o| o.compression).unwrap_or(0);
let quality = options.as_ref().and_then(|o| o.quality).unwrap_or(90);
blazediff_shared::save_image(&image, path, compression, quality)
.map_err(|e| Error::new(Status::GenericFailure, format!("Failed to save map: {e}")))
}
fn compare_images(
image1: Image,
image2: Image,
map_output: Option<String>,
options: Option<NapiSsimOptions>,
) -> Result<NapiSsimResult> {
let metric = metric_of(&options)?;
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(metric, outcome, &options))
}
#[napi]
pub fn compare(
base_path: String,
compare_path: String,
map_output: Option<String>,
options: Option<NapiSsimOptions>,
) -> Result<NapiSsimResult> {
let (image1, image2) =
blazediff_shared::load_image_pair(&base_path, &compare_path).map_err(|e| {
Error::new(
Status::GenericFailure,
format!("Failed to load images: {e}"),
)
})?;
compare_images(image1, image2, map_output, options)
}
#[napi]
pub fn compare_buffers(
base: &[u8],
comparison: &[u8],
map_output: Option<String>,
options: Option<NapiSsimOptions>,
) -> Result<NapiSsimResult> {
let (image1, image2) = blazediff_shared::decode_image_pair(base, comparison).map_err(|e| {
Error::new(
Status::GenericFailure,
format!("Failed to load images: {e}"),
)
})?;
compare_images(image1, image2, map_output, options)
}
#[napi]
pub fn compare_rgba(
base: &[u8],
comparison: &[u8],
width: u32,
height: u32,
options: Option<NapiSsimOptions>,
) -> Result<NapiSsimResult> {
let metric = metric_of(&options)?;
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(metric, outcome, &options))
}
#[napi]
pub fn render_map(
map: Float32Array,
map_width: u32,
map_height: u32,
width: u32,
height: u32,
) -> Result<Buffer> {
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.as_ref(),
map_width as usize,
map_height as usize,
);
Ok(output.into())
}
#[napi]
pub fn metrics() -> Vec<String> {
Metric::NAMES.iter().map(|name| name.to_string()).collect()
}