use crate::{Image, Result, Scorer};
const SHARPNESS_K: f32 = 0.01;
const CONTRAST_REF: f32 = 0.25;
const SHARPNESS_WEIGHT: f32 = 0.7;
#[must_use = "quality_score is a pure computation; the result should be used"]
pub fn quality_score(image: &Image) -> f32 {
let (w, h, luma) = to_luma(image);
let sharpness = laplacian_variance(&luma, w, h);
let contrast = std_dev(&luma);
let sharpness_term = 1.0 - (-sharpness / SHARPNESS_K).exp();
let contrast_term = (contrast / CONTRAST_REF).min(1.0);
(SHARPNESS_WEIGHT * sharpness_term + (1.0 - SHARPNESS_WEIGHT) * contrast_term).clamp(0.0, 1.0)
}
fn to_luma(image: &Image) -> (usize, usize, Vec<f32>) {
let n = image.width as usize * image.height as usize;
let mut luma = Vec::with_capacity(n);
for px in image.rgb.chunks_exact(3) {
let (r, g, b) = (px[0] as f32, px[1] as f32, px[2] as f32);
luma.push((0.299 * r + 0.587 * g + 0.114 * b) / 255.0);
}
(image.width as usize, image.height as usize, luma)
}
fn laplacian_variance(luma: &[f32], w: usize, h: usize) -> f32 {
if w < 3 || h < 3 {
return 0.0;
}
let at = |x: usize, y: usize| luma[y * w + x];
let mut responses = Vec::with_capacity((w - 2) * (h - 2));
for y in 1..h - 1 {
for x in 1..w - 1 {
let lap = at(x - 1, y) + at(x + 1, y) + at(x, y - 1) + at(x, y + 1) - 4.0 * at(x, y);
responses.push(lap);
}
}
variance(&responses)
}
#[inline]
fn std_dev(v: &[f32]) -> f32 {
variance(v).sqrt()
}
#[inline]
fn variance(v: &[f32]) -> f32 {
if v.is_empty() {
return 0.0;
}
let mean = v.iter().sum::<f32>() / v.len() as f32;
v.iter().map(|x| (x - mean).powi(2)).sum::<f32>() / v.len() as f32
}
#[derive(Debug, Clone, Default)]
pub struct QualityScorer;
impl Scorer for QualityScorer {
fn score(&self, _prompt: &str, image: &Image) -> Result<f32> {
Ok(quality_score(image))
}
}
pub struct QualityWeighted {
base: Box<dyn Scorer>,
weight: f32,
}
impl std::fmt::Debug for QualityWeighted {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("QualityWeighted")
.field("weight", &self.weight)
.finish_non_exhaustive()
}
}
impl QualityWeighted {
pub fn new(base: Box<dyn Scorer>, weight: f32) -> Self {
Self { base, weight: weight.clamp(0.0, 1.0) }
}
}
impl Scorer for QualityWeighted {
fn score(&self, prompt: &str, image: &Image) -> Result<f32> {
let base = self.base.score(prompt, image)?;
let quality = quality_score(image);
Ok((1.0 - self.weight) * base + self.weight * quality)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn checkerboard(size: u32, cell: u32) -> Image {
let mut rgb = Vec::with_capacity((size * size * 3) as usize);
for y in 0..size {
for x in 0..size {
let v = if ((x / cell) + (y / cell)) % 2 == 0 { 255 } else { 0 };
rgb.extend_from_slice(&[v, v, v]);
}
}
Image::new(size, size, rgb).unwrap()
}
fn box_blur(image: &Image) -> Image {
let (w, h) = (image.width as usize, image.height as usize);
let (_, _, luma) = to_luma(image);
let mut out = vec![0u8; w * h * 3];
for y in 0..h {
for x in 0..w {
let mut sum = 0.0;
let mut count = 0.0;
for dy in -1i32..=1 {
for dx in -1i32..=1 {
let (nx, ny) = (x as i32 + dx, y as i32 + dy);
if nx >= 0 && nx < w as i32 && ny >= 0 && ny < h as i32 {
sum += luma[ny as usize * w + nx as usize];
count += 1.0;
}
}
}
let v = ((sum / count) * 255.0).round() as u8;
let idx = (y * w + x) * 3;
out[idx] = v;
out[idx + 1] = v;
out[idx + 2] = v;
}
}
Image::new(image.width, image.height, out).unwrap()
}
fn solid(size: u32, v: u8) -> Image {
Image::new(size, size, vec![v; (size * size * 3) as usize]).unwrap()
}
#[test]
fn scores_are_in_range() {
for img in [checkerboard(32, 4), box_blur(&checkerboard(32, 4)), solid(32, 128)] {
let q = quality_score(&img);
assert!((0.0..=1.0).contains(&q), "out of range: {q}");
}
}
#[test]
fn flat_image_scores_near_zero() {
assert!(quality_score(&solid(32, 128)) < 0.05);
}
#[test]
fn sharp_beats_blurred() {
let sharp = checkerboard(48, 4);
let blurred = box_blur(&sharp);
let (qs, qb) = (quality_score(&sharp), quality_score(&blurred));
assert!(qs > qb, "sharp {qs:.3} should beat blurred {qb:.3}");
}
#[test]
fn weighting_blends_base_and_quality() {
struct One;
impl Scorer for One {
fn score(&self, _p: &str, _i: &Image) -> Result<f32> {
Ok(1.0)
}
}
let img = solid(32, 128);
let blended = QualityWeighted::new(Box::new(One), 0.5);
let s = blended.score("x", &img).unwrap();
assert!(s < 1.0 && s > 0.4 && s < 0.6, "blend was {s}");
}
}