use std::collections::VecDeque;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Blend {
pub first: u32,
pub weight_next: f32,
pub slope: f32,
pub gamma: f32,
pub rms: f32,
pub margin: f32,
}
impl Blend {
pub fn dominant(&self) -> u32 {
if self.weight_next < 0.5 {
self.first
} else {
self.first + 1
}
}
pub fn is_mixed(&self) -> bool {
(0.05..=0.95).contains(&self.weight_next)
}
}
pub const MAX_RMS: f32 = 0.2;
pub const MIN_MARGIN: f32 = 1.25;
const GAMMA_RANGE: (f64, f64) = (0.7, 3.5);
const MIN_EACH_WAY: usize = 2;
const MEASURE_WEIGHTS: std::ops::RangeInclusive<f32> = 0.12..=0.88;
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ToneCurve {
recent: VecDeque<f32>,
}
impl ToneCurve {
const KEEP: usize = 31;
pub fn gamma(&self) -> f32 {
if self.recent.is_empty() {
return 1.0;
}
let mut v: Vec<f32> = self.recent.iter().copied().collect();
v.sort_by(f32::total_cmp);
v[v.len() / 2]
}
#[cfg(test)]
pub(crate) fn known(gamma: f32) -> Self {
let mut tone = ToneCurve::default();
tone.observe(gamma);
tone
}
fn observe(&mut self, gamma: f32) {
if self.recent.len() == Self::KEEP {
self.recent.pop_front();
}
self.recent.push_back(gamma);
}
}
#[derive(Clone, Debug)]
pub(crate) struct Cells {
rows: Vec<[Group; 4]>,
}
const STAYED_DARK: usize = 0;
const WENT_LIGHT: usize = 1;
const WENT_DARK: usize = 2;
const STAYED_LIGHT: usize = 3;
#[derive(Clone, Copy, Debug, Default)]
struct Group {
n: f64,
sum: f64,
sum_sq: f64,
}
impl Group {
fn error(&self, predicted: f64) -> f64 {
if self.n == 0.0 {
return 0.0;
}
(self.sum_sq - 2.0 * predicted * self.sum + self.n * predicted * predicted).max(0.0)
}
}
impl Cells {
pub fn new(rows: usize) -> Self {
Cells {
rows: vec![[Group::default(); 4]; rows],
}
}
pub fn add(&mut self, row: usize, s: f32, a: bool, b: bool) {
let g = &mut self.rows[row][2 * usize::from(a) + usize::from(b)];
let s = f64::from(s);
g.n += 1.0;
g.sum += s;
g.sum_sq += s * s;
}
fn y(&self, r: usize) -> f64 {
(r as f64 + 0.5) / self.rows.len() as f64 - 0.5
}
fn count(&self, kind: usize) -> f64 {
self.rows.iter().map(|g| g[kind].n).sum()
}
}
pub(crate) fn fit_candidates(
candidates: impl IntoIterator<Item = u32>,
mut cells: impl FnMut(u32) -> Option<Cells>,
slope: bool,
tone: &mut ToneCurve,
) -> Option<Blend> {
let prior = f64::from(tone.gamma());
let (mut fits, groups): (Vec<Blend>, Vec<Cells>) = candidates
.into_iter()
.filter_map(|m| {
let c = cells(m)?;
Some((fit_at(&c, m, slope, prior)?, c))
})
.unzip();
let mut measured: Vec<bool> = vec![false; fits.len()];
let best = lowest(&fits, |_| true)?;
let other = lowest(&fits, |f| f.dominant() != fits[best].dominant());
for i in [Some(best), other].into_iter().flatten() {
if let Some((fit, ok)) = measure(&groups[i], slope, fits[i]) {
(fits[i], measured[i]) = (fit, ok);
}
}
let winner = lowest(&fits, |_| true)?;
let chosen = choose(&fits)?;
if measured[winner] {
tone.observe(fits[winner].gamma);
}
Some(chosen)
}
fn lowest(fits: &[Blend], keep: impl Fn(&Blend) -> bool) -> Option<usize> {
(0..fits.len())
.filter(|&i| keep(&fits[i]))
.min_by(|&i, &j| fits[i].rms.total_cmp(&fits[j].rms))
}
fn measure(cells: &Cells, slope: bool, fit: Blend) -> Option<(Blend, bool)> {
let first = fit.first;
let each_way = MIN_EACH_WAY as f64;
if cells.count(WENT_LIGHT) < each_way || cells.count(WENT_DARK) < each_way || !fit.is_mixed() {
return Some((fit, false));
}
let (lo, hi) = (GAMMA_RANGE.0.ln(), GAMMA_RANGE.1.ln());
let error = |lg: f64| fit_at(cells, first, slope, lg.exp()).map_or(f32::INFINITY, |b| b.rms);
const STEPS: usize = 8;
let step = (hi - lo) / STEPS as f64;
let scan: Vec<f32> = (0..=STEPS).map(|k| error(lo + step * k as f64)).collect();
let k = (0..=STEPS).min_by(|&i, &j| scan[i].total_cmp(&scan[j]))?;
let (mut a, mut b) = (
lo + step * k.saturating_sub(1) as f64,
lo + step * (k + 1).min(STEPS) as f64,
);
let r = (5f64.sqrt() - 1.0) / 2.0;
let (mut c, mut d) = (b - r * (b - a), a + r * (b - a));
let (mut ec, mut ed) = (error(c), error(d));
for _ in 0..12 {
if ec < ed {
(b, d, ed) = (d, c, ec);
c = b - r * (b - a);
ec = error(c);
} else {
(a, c, ec) = (c, d, ed);
d = a + r * (b - a);
ed = error(d);
}
}
let found = fit_at(cells, first, slope, ((a + b) / 2.0).exp())?;
let found = if found.rms < fit.rms { found } else { fit };
Some(if MEASURE_WEIGHTS.contains(&found.weight_next) {
(found, true)
} else {
(fit, false)
})
}
fn fit_at(cells: &Cells, first: u32, slope: bool, gamma: f64) -> Option<Blend> {
let linear = gamma == 1.0;
let to_light = |s: f64| if linear { s } else { s.max(0.0).powf(gamma) };
let to_signal = |x: f64| {
if linear {
x
} else {
x.max(0.0).powf(1.0 / gamma)
}
};
let changed = [(WENT_LIGHT, 0.0, 1.0), (WENT_DARK, 1.0, -1.0)];
let (mut s00, mut s01, mut s11, mut r0, mut r1) = (0.0f64, 0.0, 0.0, 0.0, 0.0);
let mut n = 0.0;
for (r, groups) in cells.rows.iter().enumerate() {
n += groups.iter().map(|g| g.n).sum::<f64>();
for (kind, a, d) in changed {
let g = groups[kind];
if g.n == 0.0 {
continue;
}
let (x0, x1) = (d, if slope { d * cells.y(r) } else { 0.0 });
let res = to_light(g.sum / g.n) - a;
s00 += g.n * x0 * x0;
s01 += g.n * x0 * x1;
s11 += g.n * x1 * x1;
r0 += g.n * x0 * res;
r1 += g.n * x1 * res;
}
}
if n == 0.0 {
return None;
}
let det = s00 * s11 - s01 * s01;
let (b0, b1) = if slope && det.abs() > 1e-9 {
((r0 * s11 - r1 * s01) / det, (s00 * r1 - s01 * r0) / det)
} else if s00 > 0.0 {
(r0 / s00, 0.0)
} else {
(0.0, 0.0)
};
let mut sq = 0.0;
for (r, g) in cells.rows.iter().enumerate() {
sq += g[STAYED_DARK].error(0.0) + g[STAYED_LIGHT].error(1.0);
let beta = (b0 + b1 * cells.y(r)).clamp(0.0, 1.0);
if g[WENT_LIGHT].n > 0.0 {
sq += g[WENT_LIGHT].error(to_signal(beta));
}
if g[WENT_DARK].n > 0.0 {
sq += g[WENT_DARK].error(to_signal(1.0 - beta));
}
}
Some(Blend {
first,
weight_next: b0.clamp(0.0, 1.0) as f32,
slope: b1 as f32,
gamma: gamma as f32,
rms: (sq / n).sqrt() as f32,
margin: 0.0,
})
}
fn choose(fits: &[Blend]) -> Option<Blend> {
let best = fits.iter().min_by(|a, b| a.rms.total_cmp(&b.rms))?;
let runner = fits
.iter()
.filter(|f| f.dominant() != best.dominant())
.map(|f| f.rms)
.fold(f32::INFINITY, f32::min);
let mut fit = Blend {
margin: runner / best.rms.max(1e-6),
..*best
};
if !fit.is_mixed() && fit.weight_next >= 0.5 {
fit.first += 1;
fit.weight_next = 0.0;
}
(fit.rms < MAX_RMS && fit.margin >= MIN_MARGIN).then_some(fit)
}
#[cfg(test)]
pub(crate) mod curves {
use crate::pattern::canvas::{BLACK, LumaImage, WHITE};
pub type Curve = fn(f64) -> f64;
pub fn srgb(x: f64) -> f64 {
if x <= 0.003_130_8 {
12.92 * x
} else {
1.055 * x.powf(1.0 / 2.4) - 0.055
}
}
pub fn gamma22(x: f64) -> f64 {
x.powf(1.0 / 2.2)
}
pub fn light(v: f64) -> f64 {
let (b, w) = (f64::from(BLACK), f64::from(WHITE));
((v - b) / (w - b)).clamp(0.0, 1.0)
}
pub fn encode(light: f64, curve: Curve) -> u8 {
let (b, w) = (f64::from(BLACK), f64::from(WHITE));
(b + (w - b) * curve(light.clamp(0.0, 1.0))).round() as u8
}
pub fn mix_through(a: &LumaImage, b: &LumaImage, w: f64, curve: Curve) -> LumaImage {
let mut out = a.clone();
for (o, (&x, &y)) in out.data.iter_mut().zip(a.data.iter().zip(&b.data)) {
let mixed = light(f64::from(x)) * (1.0 - w) + light(f64::from(y)) * w;
*o = encode(mixed, curve);
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn picture(rows: usize, weight: f64, slope: f64, gamma: f64) -> Vec<(usize, f32, bool, bool)> {
let mut seed = 99u32;
let mut noise = || {
seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12345);
0.05 * ((seed >> 16) as f64 / 32768.0 - 1.0)
};
let mut out = Vec::new();
for r in 0..rows {
let y = (r as f64 + 0.5) / rows as f64 - 0.5;
let beta = (weight + slope * y).clamp(0.0, 1.0);
for k in 0..(5 + r % 4) {
let (a, b) = ((k + r) % 3 == 0, (k * 7 + r) % 4 < 2);
let light = if a { 1.0 - beta } else { 0.0 } + if b { beta } else { 0.0 };
out.push((r, (light.powf(1.0 / gamma) + noise()) as f32, a, b));
}
}
out
}
fn grouped(rows: usize, picture: &[(usize, f32, bool, bool)]) -> Cells {
let mut cells = Cells::new(rows);
for &(r, s, a, b) in picture {
cells.add(r, s, a, b);
}
cells
}
#[test]
fn grouped_error_is_exact() {
let rows = 25;
for gamma in [1.0, 1.6, 2.2] {
let picture = picture(rows, 0.4, 0.3, gamma);
let fit = fit_at(&grouped(rows, &picture), 7, true, gamma).unwrap();
let sq: f64 = picture
.iter()
.map(|&(r, s, a, b)| {
let y = (r as f64 + 0.5) / rows as f64 - 0.5;
let beta =
(f64::from(fit.weight_next) + f64::from(fit.slope) * y).clamp(0.0, 1.0);
let light = if a { 1.0 - beta } else { 0.0 } + if b { beta } else { 0.0 };
(f64::from(s) - light.powf(1.0 / gamma)).powi(2)
})
.sum();
let rms = (sq / picture.len() as f64).sqrt();
assert!(
(f64::from(fit.rms) - rms).abs() < 1e-5,
"γ {gamma}: {} vs {rms}",
fit.rms
);
assert!((fit.weight_next - 0.4).abs() < 0.02, "γ {gamma}: {fit:?}");
}
}
#[test]
fn linear_fit_is_cell_by_cell_least_squares() {
let rows = 21;
let picture = picture(rows, 0.65, -0.2, 1.0);
let fit = fit_at(&grouped(rows, &picture), 0, true, 1.0).unwrap();
let (mut s00, mut s01, mut s11, mut r0, mut r1) = (0.0f64, 0.0, 0.0, 0.0, 0.0);
for &(r, s, a, b) in picture.iter().filter(|c| c.2 != c.3) {
let y = (r as f64 + 0.5) / rows as f64 - 0.5;
let d = if b { 1.0 } else { -1.0 };
let res = f64::from(s) - if a { 1.0 } else { 0.0 };
(s00, s01, s11) = (s00 + d * d, s01 + d * d * y, s11 + d * d * y * y);
(r0, r1) = (r0 + d * res, r1 + d * y * res);
}
let det = s00 * s11 - s01 * s01;
let (b0, b1) = ((r0 * s11 - r1 * s01) / det, (s00 * r1 - s01 * r0) / det);
assert!(
(f64::from(fit.weight_next) - b0).abs() < 1e-6,
"{fit:?} vs {b0}"
);
assert!((f64::from(fit.slope) - b1).abs() < 1e-6, "{fit:?} vs {b1}");
}
#[test]
fn no_cells_no_fit() {
assert_eq!(fit_at(&Cells::new(3), 0, true, 1.0), None);
}
}