use std::collections::VecDeque;
use std::fmt;
use rayon::prelude::*;
use crate::cost::{CostMap, CostProvider};
use crate::image_io::buffer::{CoverSource, ImageBuffer};
use crate::image_io::jpeg_detect::luminance_plane;
const HIGH_PASS_KERNEL: [[f32; 3]; 3] = [[0.0, -1.0, 0.0], [-1.0, 4.0, -1.0], [0.0, -1.0, 0.0]];
const FIRST_SMOOTHING_SIGMA: f32 = 1.0;
const SECOND_SMOOTHING_SIGMA: f32 = 1.5;
const INVERSION_EPSILON: f32 = 1e-6;
const RED_CHANNEL_PENALTY: f32 = 1.20;
const SMOOTH_PERCENTILE: f32 = 0.05;
const MAX_SMOOTH_RATIO: f32 = 0.30;
const MAX_SMOOTH_REGION_RATIO: f32 = 0.10;
const TEXTURE_PERCENTILE: f32 = 0.95;
const MIN_TEXTURE_COST: f32 = 0.10;
#[derive(Debug)]
pub enum CostError {
ExcessiveSmoothRegions {
ratio: f32,
},
LargeSmoothRegion {
size: usize,
},
InsufficientGlobalTexture,
}
impl fmt::Display for CostError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CostError::ExcessiveSmoothRegions { ratio } => write!(
f,
"{:.1}% of the image is too smooth to hide anything; choose a container with \
more texture",
ratio * 100.0
),
CostError::LargeSmoothRegion { size } => write!(
f,
"the image contains a single flat area of {size} pixels; choose a container \
without large uniform surfaces"
),
CostError::InsufficientGlobalTexture => write!(
f,
"the image has no textured region anywhere; choose a container with more detail"
),
}
}
}
impl std::error::Error for CostError {}
#[derive(Debug, Default, Clone, Copy)]
pub struct HillCostProvider;
impl HillCostProvider {
pub fn new() -> Self {
Self
}
}
impl CostProvider for HillCostProvider {
type Error = CostError;
fn compute<'img>(&self, image: &'img ImageBuffer) -> Result<CostMap<'img>, CostError> {
let (width, height) = image.dimensions();
let width = width as usize;
let height = height as usize;
let color_space = image.color_space();
let luma = luminance_plane(image.pixels(), width * height, color_space);
let residual = high_pass(&luma, width, height);
drop(luma);
let smoothed = convolve_separable(
&residual,
width,
height,
&gaussian_kernel(FIRST_SMOOTHING_SIGMA),
);
drop(residual);
let second = convolve_separable(
&smoothed,
width,
height,
&gaussian_kernel(SECOND_SMOOTHING_SIGMA),
);
drop(smoothed);
let mut costs: Vec<f32> = second
.into_par_iter()
.map(|texture| 1.0 / (texture + INVERSION_EPSILON))
.collect();
if color_space.has_explicit_red_channel() {
costs
.par_iter_mut()
.for_each(|cost| *cost *= RED_CHANNEL_PENALTY);
}
validate(&costs, width, height)?;
Ok(CostMap::new(image, costs))
}
}
fn reflect(coord: isize, len: usize) -> usize {
if len <= 1 {
return 0;
}
let len = len as isize;
let period = 2 * (len - 1);
let mut folded = coord % period;
if folded < 0 {
folded += period;
}
if folded >= len {
folded = period - folded;
}
folded as usize
}
fn high_pass(luma: &[f32], width: usize, height: usize) -> Vec<f32> {
let mut output = vec![0.0f32; luma.len()];
if width == 0 || height == 0 {
return output;
}
output
.par_chunks_mut(width)
.enumerate()
.for_each(|(y, row)| {
for (x, value) in row.iter_mut().enumerate() {
let mut accumulator = 0.0f32;
for (ky, weights) in HIGH_PASS_KERNEL.iter().enumerate() {
let sy = reflect(y as isize + ky as isize - 1, height);
for (kx, &weight) in weights.iter().enumerate() {
if weight == 0.0 {
continue;
}
let sx = reflect(x as isize + kx as isize - 1, width);
accumulator += weight * luma.get(sy * width + sx).copied().unwrap_or(0.0);
}
}
*value = accumulator.abs();
}
});
output
}
fn gaussian_kernel(sigma: f32) -> Vec<f32> {
let radius = (2.0 * sigma).ceil().max(1.0) as usize;
let mut taps: Vec<f32> = (0..=2 * radius)
.map(|tap| {
let offset = tap as f32 - radius as f32;
(-(offset * offset) / (2.0 * sigma * sigma)).exp()
})
.collect();
let sum: f32 = taps.iter().sum();
if sum > 0.0 {
for tap in &mut taps {
*tap /= sum;
}
}
taps
}
fn convolve_separable(input: &[f32], width: usize, height: usize, taps: &[f32]) -> Vec<f32> {
let horizontal = convolve_axis(input, width, height, taps, Axis::Horizontal);
convolve_axis(&horizontal, width, height, taps, Axis::Vertical)
}
#[derive(Clone, Copy)]
enum Axis {
Horizontal,
Vertical,
}
fn convolve_axis(input: &[f32], width: usize, height: usize, taps: &[f32], axis: Axis) -> Vec<f32> {
let mut output = vec![0.0f32; input.len()];
if width == 0 || height == 0 || taps.is_empty() {
return output;
}
let radius = (taps.len() / 2) as isize;
output
.par_chunks_mut(width)
.enumerate()
.for_each(|(y, row)| {
for (x, value) in row.iter_mut().enumerate() {
let mut accumulator = 0.0f32;
for (tap_index, &tap) in taps.iter().enumerate() {
let offset = tap_index as isize - radius;
let (sx, sy) = match axis {
Axis::Horizontal => (reflect(x as isize + offset, width), y),
Axis::Vertical => (x, reflect(y as isize + offset, height)),
};
accumulator += tap * input.get(sy * width + sx).copied().unwrap_or(0.0);
}
*value = accumulator;
}
});
output
}
fn percentile(sorted: &[f32], fraction: f32) -> f32 {
if sorted.is_empty() {
return 0.0;
}
let rank = (fraction * sorted.len() as f32).ceil() as usize;
let index = rank.saturating_sub(1).min(sorted.len() - 1);
sorted.get(index).copied().unwrap_or(0.0)
}
fn largest_smooth_region(costs: &[f32], width: usize, height: usize, threshold: f32) -> usize {
if width == 0 || height == 0 {
return 0;
}
let is_smooth = |index: usize| costs.get(index).is_some_and(|&cost| cost < threshold);
let mut visited = vec![false; costs.len()];
let mut queue: VecDeque<usize> = VecDeque::new();
let mut largest = 0usize;
for start in 0..costs.len() {
if visited[start] || !is_smooth(start) {
continue;
}
visited[start] = true;
queue.push_back(start);
let mut size = 0usize;
while let Some(index) = queue.pop_front() {
size += 1;
let x = index % width;
let y = index / width;
let neighbours = [
(x > 0).then(|| index - 1),
(x + 1 < width).then(|| index + 1),
(y > 0).then(|| index - width),
(y + 1 < height).then(|| index + width),
];
for neighbour in neighbours.into_iter().flatten() {
if !visited[neighbour] && is_smooth(neighbour) {
visited[neighbour] = true;
queue.push_back(neighbour);
}
}
}
largest = largest.max(size);
}
largest
}
fn validate(costs: &[f32], width: usize, height: usize) -> Result<(), CostError> {
if costs.is_empty() {
return Err(CostError::InsufficientGlobalTexture);
}
let mut sorted = costs.to_vec();
sorted.par_sort_unstable_by(f32::total_cmp);
let theta_smooth = percentile(&sorted, SMOOTH_PERCENTILE);
let smooth_pixels = costs
.par_iter()
.filter(|&&cost| cost < theta_smooth)
.count();
let ratio = smooth_pixels as f32 / costs.len() as f32;
if ratio > MAX_SMOOTH_RATIO {
return Err(CostError::ExcessiveSmoothRegions { ratio });
}
let largest = largest_smooth_region(costs, width, height, theta_smooth);
if largest as f32 > MAX_SMOOTH_REGION_RATIO * costs.len() as f32 {
return Err(CostError::LargeSmoothRegion { size: largest });
}
if percentile(&sorted, TEXTURE_PERCENTILE) < MIN_TEXTURE_COST {
return Err(CostError::InsufficientGlobalTexture);
}
Ok(())
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
#![allow(clippy::panic)]
use super::*;
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
use crate::image_io::buffer::ColorSpace;
const SIDE: u32 = 64;
fn grainy(seed: u64, amplitude: i16, channels: usize) -> Vec<u8> {
let mut rng = StdRng::seed_from_u64(seed);
let samples = (SIDE * SIDE) as usize * channels;
(0..samples)
.map(|_| (128 + rng.random_range(-amplitude..=amplitude)).clamp(0, 255) as u8)
.collect()
}
#[test]
fn coordinates_are_reflected_rather_than_padded() {
assert_eq!(reflect(0, 10), 0);
assert_eq!(reflect(9, 10), 9);
assert_eq!(reflect(-1, 10), 1);
assert_eq!(reflect(10, 10), 8);
assert!(reflect(1_000, 10) < 10);
assert!(reflect(-1_000, 10) < 10);
assert_eq!(reflect(7, 1), 0);
assert_eq!(reflect(-7, 0), 0);
}
#[test]
fn the_gaussian_taps_sum_to_one() {
for sigma in [1.0f32, 1.5] {
let taps = gaussian_kernel(sigma);
assert_eq!(taps.len(), 2 * (2.0 * sigma).ceil() as usize + 1);
let sum: f32 = taps.iter().sum();
assert!((sum - 1.0).abs() < 1e-5, "taps for {sigma} summed to {sum}");
assert_eq!(taps.first(), taps.last());
}
}
#[test]
fn quantiles_are_read_by_nearest_rank() {
let sorted: Vec<f32> = (0..100).map(|value| value as f32).collect();
assert_eq!(percentile(&sorted, 0.0), 0.0);
assert_eq!(percentile(&sorted, 0.05), 4.0);
assert_eq!(percentile(&sorted, 0.95), 94.0);
assert_eq!(percentile(&sorted, 1.0), 99.0);
assert_eq!(percentile(&[], 0.5), 0.0);
}
#[test]
fn smooth_regions_do_not_merge_across_a_corner() {
let expensive = 10.0f32;
let cheap = 0.0f32;
let costs = vec![
cheap, cheap, expensive, expensive, cheap, cheap, expensive, expensive, expensive, expensive, cheap, cheap, expensive, expensive, cheap, cheap,
];
assert_eq!(largest_smooth_region(&costs, 4, 4, 1.0), 4);
assert_eq!(largest_smooth_region(&[cheap; 16], 4, 4, 1.0), 16);
assert_eq!(largest_smooth_region(&costs, 0, 0, 1.0), 0);
}
#[test]
fn an_empty_cost_map_has_no_texture() {
let error = validate(&[], 0, 0)
.map(|_| ())
.expect_err("a map with no pixels must be refused");
assert!(
matches!(error, CostError::InsufficientGlobalTexture),
"got: {error:?}"
);
}
#[test]
fn a_grainy_container_produces_a_usable_map() {
let image = ImageBuffer::new(grainy(1, 3, 3), SIDE, SIDE, ColorSpace::Rgb8);
let map = match HillCostProvider::new().compute(&image) {
Ok(map) => map,
Err(error) => panic!("photographic grain must be usable: {error}"),
};
assert_eq!(map.pixel_count(), image.pixel_count());
assert_eq!(map.costs().len(), image.pixel_count());
assert!(map
.costs()
.iter()
.all(|cost| cost.is_finite() && *cost > 0.0));
}
#[test]
fn a_container_of_pure_noise_is_refused() {
let image = ImageBuffer::new(grainy(2, 127, 3), SIDE, SIDE, ColorSpace::Rgb8);
let error = HillCostProvider::new()
.compute(&image)
.map(|_| ())
.expect_err("full-range noise must be refused");
assert!(
matches!(error, CostError::InsufficientGlobalTexture),
"got: {error:?}"
);
}
#[test]
fn the_red_channel_penalty_applies_only_to_colour() {
let pixel_count = (SIDE * SIDE) as usize;
let rgb = grainy(3, 3, 3);
let luma: Vec<u8> = luminance_plane(&rgb, pixel_count, ColorSpace::Rgb8)
.into_iter()
.map(|level| level as u8)
.collect();
let colour_image = ImageBuffer::new(rgb, SIDE, SIDE, ColorSpace::Rgb8);
let gray_image = ImageBuffer::new(luma, SIDE, SIDE, ColorSpace::Luma8);
let provider = HillCostProvider::new();
let colour = provider
.compute(&colour_image)
.expect("grain must be usable");
let gray = provider.compute(&gray_image).expect("grain must be usable");
for (index, (with, without)) in colour.costs().iter().zip(gray.costs()).enumerate() {
let ratio = with / without;
assert!(
(ratio - RED_CHANNEL_PENALTY).abs() < 1e-4,
"pixel {index} cost {ratio} times as much in colour as in grayscale"
);
}
}
#[test]
fn every_refusal_explains_itself() {
let excessive = CostError::ExcessiveSmoothRegions { ratio: 0.42 }.to_string();
assert!(excessive.contains("42.0%"), "got: {excessive}");
let region = CostError::LargeSmoothRegion { size: 12_345 }.to_string();
assert!(region.contains("12345"), "got: {region}");
assert!(CostError::InsufficientGlobalTexture
.to_string()
.contains("detail"));
}
}