G1: keep dithered premultiplied colour at or below alpha (#2022)

Dither can round a colour byte up while alpha rounds down (colour = alpha = 100.25/255 gives
alpha 100, colour 101), breaking the premultiplied invariant. Clamp each dithered colour byte
to the written alpha byte.

Test: dithered_premultiplied_colour_never_exceeds_alpha.
This commit is contained in:
Claude
2026-10-29 03:06:31 -05:00
parent 85e061fe0c
commit 9bfbce5117
+17 -2
View File
@@ -573,12 +573,14 @@ fn premultiplied8(img: &Image, dither: bool) -> Vec<u8> {
let mut out = Vec::with_capacity(img.data.len() * 4);
for (i, p) in img.data.iter().enumerate() {
let a = p[3].clamp(0.0, 1.0);
let a8 = (a * 255.0 + 0.5) as u8;
let (x, y) = ((i % w) as u32, (i / w) as u32);
for c in 0..3 {
let v = p[c].clamp(0.0, a);
out.push(if dither { effectcraft_raster::dither_u8(v, x, y) } else { (v * 255.0 + 0.5) as u8 });
// Dither may round up past the rounded alpha; premultiplied colour never exceeds it.
out.push(if dither { effectcraft_raster::dither_u8(v, x, y).min(a8) } else { (v * 255.0 + 0.5) as u8 });
}
out.push((a * 255.0 + 0.5) as u8);
out.push(a8);
}
out
}
@@ -588,6 +590,19 @@ mod tests {
use super::*;
use rayon::prelude::*;
/// Dither may round a colour up while alpha rounds down; premultiplied colour stays at or
/// below the written alpha at every pixel.
#[test]
fn dithered_premultiplied_colour_never_exceeds_alpha() {
let v = 100.25 / 255.0;
let img = Image::filled(64, 64, [v, v, v, v]);
let px = premultiplied8(&img, true);
assert!(px.chunks_exact(4).all(|p| p[..3].iter().all(|c| *c <= p[3])), "a colour byte is above its alpha");
assert!(px.chunks_exact(4).all(|p| p[3] == 100));
let plain = premultiplied8(&img, false);
assert!(plain.chunks_exact(4).all(|p| p == [100, 100, 100, 100]));
}
/// Frames `0..n` through [`map_frames`] with `cap`, recording the most running at once.
fn run_capped(n: u64, cap: Option<usize>) -> (Vec<u64>, usize) {
use std::sync::atomic::{AtomicUsize, Ordering};