use rand::rngs::StdRng;
use rand::seq::index;
use rand::SeedableRng;
use rayon::prelude::*;
use sha3::{Digest, Sha3_256};
use crate::image_io::buffer::ColorSpace;
use crate::image_io::phash::luminance;
const BLOCK_SIZE: usize = 8;
const SEED_SAMPLE_LEN: usize = 1024;
const SAMPLE_PERCENT: usize = 5;
const MIN_SAMPLES: usize = 50;
const MAX_SAMPLES: usize = 10_000;
const ARTIFACT_THRESHOLD: f32 = 1.3;
const DIVISION_EPSILON: f32 = 1e-6;
#[derive(Default)]
struct StepEnergies {
boundary_sum: f32,
boundary_pairs: usize,
interior_sum: f32,
interior_pairs: usize,
}
impl StepEnergies {
fn absorb(&mut self, other: &StepEnergies) {
self.boundary_sum += other.boundary_sum;
self.boundary_pairs += other.boundary_pairs;
self.interior_sum += other.interior_sum;
self.interior_pairs += other.interior_pairs;
}
fn ratio(&self) -> Option<f32> {
if self.boundary_pairs == 0 || self.interior_pairs == 0 {
return None;
}
let boundary = self.boundary_sum / self.boundary_pairs as f32;
let interior = self.interior_sum / self.interior_pairs as f32;
Some(boundary / (interior + DIVISION_EPSILON))
}
}
fn sampling_seed(pixels: &[u8]) -> [u8; 32] {
let head = &pixels[..SEED_SAMPLE_LEN.min(pixels.len())];
let mut hasher = Sha3_256::new();
hasher.update(head);
hasher.finalize().into()
}
pub(crate) fn luminance_plane(
pixels: &[u8],
pixel_count: usize,
color_space: ColorSpace,
) -> Vec<f32> {
let bytes_per_pixel = color_space.bytes_per_pixel();
(0..pixel_count)
.into_par_iter()
.map(|index| {
let offset = index * bytes_per_pixel;
pixels
.get(offset..offset + bytes_per_pixel)
.map_or(0.0, |sample| luminance(sample, color_space) as f32)
})
.collect()
}
fn block_energies(luma: &[f32], width: usize, height: usize, x0: usize, y0: usize) -> StepEnergies {
let at = |x: usize, y: usize| luma.get(y * width + x).copied();
let mut energies = StepEnergies::default();
for index in 0..BLOCK_SIZE {
let y = y0 + index;
for dx in 0..BLOCK_SIZE {
let x = x0 + dx;
if x + 1 >= width {
break;
}
let (Some(here), Some(next)) = (at(x, y), at(x + 1, y)) else {
break;
};
let step = (here - next).abs();
if dx == BLOCK_SIZE - 1 {
energies.boundary_sum += step;
energies.boundary_pairs += 1;
} else {
energies.interior_sum += step;
energies.interior_pairs += 1;
}
}
let x = x0 + index;
for dy in 0..BLOCK_SIZE {
let y = y0 + dy;
if y + 1 >= height {
break;
}
let (Some(here), Some(below)) = (at(x, y), at(x, y + 1)) else {
break;
};
let step = (here - below).abs();
if dy == BLOCK_SIZE - 1 {
energies.boundary_sum += step;
energies.boundary_pairs += 1;
} else {
energies.interior_sum += step;
energies.interior_pairs += 1;
}
}
}
energies
}
pub fn detect_jpeg_artifacts(
pixels: &[u8],
width: u32,
height: u32,
color_space: ColorSpace,
) -> Option<f32> {
let width = width as usize;
let height = height as usize;
let pixel_count = width.checked_mul(height)?;
let expected_len = pixel_count.checked_mul(color_space.bytes_per_pixel())?;
if expected_len == 0 || pixels.len() < expected_len {
return None;
}
let blocks_x = width / BLOCK_SIZE;
let blocks_y = height / BLOCK_SIZE;
let total_blocks = blocks_x.checked_mul(blocks_y)?;
if total_blocks == 0 {
return None;
}
let sample_size = total_blocks
.saturating_mul(SAMPLE_PERCENT)
.saturating_div(100)
.clamp(MIN_SAMPLES, MAX_SAMPLES)
.min(total_blocks);
let luma = luminance_plane(pixels, pixel_count, color_space);
let mut rng = StdRng::from_seed(sampling_seed(pixels));
let mut pooled = StepEnergies::default();
for block in index::sample(&mut rng, total_blocks, sample_size) {
let x0 = (block % blocks_x) * BLOCK_SIZE;
let y0 = (block / blocks_x) * BLOCK_SIZE;
pooled.absorb(&block_energies(&luma, width, height, x0, y0));
}
pooled.ratio().filter(|&ratio| ratio > ARTIFACT_THRESHOLD)
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic)]
use super::*;
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
const SIDE: u32 = 64;
fn image_from<F: Fn(usize, usize) -> u8>(side: u32, level: F) -> Vec<u8> {
let side = side as usize;
(0..side * side)
.map(|index| level(index % side, index / side))
.collect()
}
fn clean_image(seed: u64) -> Vec<u8> {
let mut rng = StdRng::seed_from_u64(seed);
(0..(SIDE * SIDE) as usize).map(|_| rng.random()).collect()
}
#[test]
fn the_luma_plane_reproduces_a_grayscale_image() {
let pixels = image_from(4, |x, y| (x * 4 + y) as u8);
let plane = luminance_plane(&pixels, 16, ColorSpace::Luma8);
assert_eq!(plane.len(), 16);
for (index, value) in plane.iter().enumerate() {
assert_eq!(*value, pixels[index] as f32);
}
}
#[test]
fn the_sampling_seed_follows_the_image() {
let first = clean_image(1);
let second = clean_image(2);
assert_eq!(sampling_seed(&first), sampling_seed(&first));
assert_ne!(sampling_seed(&first), sampling_seed(&second));
}
#[test]
fn an_empty_population_yields_no_ratio() {
assert!(StepEnergies::default().ratio().is_none());
let boundary_only = StepEnergies {
boundary_sum: 10.0,
boundary_pairs: 4,
interior_sum: 0.0,
interior_pairs: 0,
};
assert!(boundary_only.ratio().is_none());
}
#[test]
fn uncorrelated_noise_shows_no_block_structure() {
assert_eq!(
detect_jpeg_artifacts(&clean_image(3), SIDE, SIDE, ColorSpace::Luma8),
None
);
}
#[test]
fn tiled_content_is_reported_as_blocking() {
let tiled = image_from(
SIDE,
|x, y| {
if (x / 8 + y / 8) % 2 == 0 {
100
} else {
160
}
},
);
match detect_jpeg_artifacts(&tiled, SIDE, SIDE, ColorSpace::Luma8) {
Some(ratio) => assert!(ratio > ARTIFACT_THRESHOLD, "ratio was {ratio}"),
None => panic!("a tiled image must be reported as blocking"),
}
}
#[test]
fn there_is_no_verdict_without_a_complete_block() {
assert_eq!(detect_jpeg_artifacts(&[], 0, 0, ColorSpace::Rgb8), None);
assert_eq!(
detect_jpeg_artifacts(&[0u8; 10], SIDE, SIDE, ColorSpace::Luma8),
None
);
assert_eq!(
detect_jpeg_artifacts(&[0u8; 16], 4, 4, ColorSpace::Luma8),
None
);
}
}