use crate::{milo, render_map as render_map_into, MiloError, MiloOptions, MiloOutcome, Rgba8};
use blazediff_shared::Image;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::PyBytes;
struct Options {
max_error: Option<f64>,
threads: Option<u32>,
return_maps: Option<bool>,
compression: Option<u8>,
quality: Option<u8>,
}
fn invalid(message: impl AsRef<str>) -> PyErr {
PyValueError::new_err(message.as_ref().to_string())
}
impl From<MiloError> for PyErr {
fn from(error: MiloError) -> Self {
PyValueError::new_err(error.to_string())
}
}
impl Options {
fn milo(&self) -> MiloOptions {
MiloOptions {
threads: self.threads.map(|threads| threads as usize),
}
}
}
#[pyclass(get_all, module = "blazediff_milo")]
pub struct PyMiloResult {
pub match_result: bool,
pub reason: Option<String>,
pub raw_error: f64,
pub mos: f64,
pub mask: Option<Py<PyBytes>>,
pub error_map: Option<Py<PyBytes>>,
pub width: u32,
pub height: u32,
}
#[pymethods]
impl PyMiloResult {
fn __repr__(&self) -> String {
format!(
"MiloResult(match_result={}, reason={}, raw_error={}, mos={}, width={}, height={})",
if self.match_result { "True" } else { "False" },
match &self.reason {
Some(reason) => format!("'{}'", reason),
None => "None".to_string(),
},
self.raw_error,
self.mos,
self.width,
self.height,
)
}
}
fn view(image: &Image) -> Rgba8<'_> {
Rgba8::new(&image.data, image.width as usize, image.height as usize)
}
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 shape(py: Python<'_>, outcome: MiloOutcome, options: &Options) -> PyMiloResult {
let max_error = options.max_error.unwrap_or(0.0);
let match_result = outcome.raw_error <= max_error;
let return_maps = options.return_maps.unwrap_or(false);
PyMiloResult {
match_result,
reason: if match_result {
None
} else {
Some("error-above-threshold".to_string())
},
raw_error: outcome.raw_error,
mos: outcome.mos,
width: outcome.width as u32,
height: outcome.height as u32,
mask: return_maps.then(|| map_bytes(py, &outcome.mask).unbind()),
error_map: return_maps.then(|| map_bytes(py, &outcome.error_map).unbind()),
}
}
fn write_map(path: &str, outcome: &MiloOutcome, options: &Options) -> PyResult<()> {
let mut image = Image::new(outcome.width as u32, outcome.height as u32);
render_map_into(
&mut image.data,
outcome.width,
outcome.height,
&outcome.error_map,
);
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<PyMiloResult> {
let outcome = milo(view(&image1), view(&image2), &options.milo())?;
if let Some(path) = map_output {
write_map(path, &outcome, options)?;
}
Ok(shape(py, outcome, options))
}
#[pyfunction]
#[pyo3(signature = (
base_path,
compare_path,
map_output=None,
*,
max_error=None,
threads=None,
return_maps=None,
compression=None,
quality=None,
))]
fn compare(
py: Python<'_>,
base_path: &str,
compare_path: &str,
map_output: Option<&str>,
max_error: Option<f64>,
threads: Option<u32>,
return_maps: Option<bool>,
compression: Option<u8>,
quality: Option<u8>,
) -> PyResult<PyMiloResult> {
let options = Options {
max_error,
threads,
return_maps,
compression,
quality,
};
let (image1, image2) = blazediff_shared::load_image_pair(base_path, compare_path)
.map_err(|e| invalid(format!("Failed to load images: {e}")))?;
compare_images(py, image1, image2, map_output, &options)
}
#[pyfunction]
#[pyo3(signature = (
base,
comparison,
map_output=None,
*,
max_error=None,
threads=None,
return_maps=None,
compression=None,
quality=None,
))]
fn compare_buffers(
py: Python<'_>,
base: &[u8],
comparison: &[u8],
map_output: Option<&str>,
max_error: Option<f64>,
threads: Option<u32>,
return_maps: Option<bool>,
compression: Option<u8>,
quality: Option<u8>,
) -> PyResult<PyMiloResult> {
let options = Options {
max_error,
threads,
return_maps,
compression,
quality,
};
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,
*,
max_error=None,
threads=None,
return_maps=None,
))]
fn compare_rgba(
py: Python<'_>,
base: &[u8],
comparison: &[u8],
width: u32,
height: u32,
max_error: Option<f64>,
threads: Option<u32>,
return_maps: Option<bool>,
) -> PyResult<PyMiloResult> {
let options = Options {
max_error,
threads,
return_maps,
compression: None,
quality: None,
};
let (width, height) = (width as usize, height as usize);
let outcome = milo(
Rgba8::new(base, width, height),
Rgba8::new(comparison, width, height),
&options.milo(),
)?;
Ok(shape(py, outcome, &options))
}
#[pyfunction]
fn render_map<'py>(
py: Python<'py>,
map: &[u8],
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);
Ok(PyBytes::new_bound(py, &output))
}
#[pymodule]
fn blazediff_milo(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_class::<PyMiloResult>()?;
Ok(())
}