mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
Merge pull request #2534 from klosixgaming-arch/fix/2022-render-banding
export: dither 16/32 bpc renders on the way to 8 bits, and let Render Settings Color Depth set the render's depth (#2022)
This commit is contained in:
@@ -376,7 +376,7 @@ async fn gif_export(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_
|
||||
let end = (i + batch).min(st.total);
|
||||
let frames: Vec<gif::Frame<'static>> = job
|
||||
.frames(comp, (i..end).collect(), |_, img| {
|
||||
let mut px = job.pixels(&img, comp, job.output.channels, w, h);
|
||||
let mut px = job.pixels_with(&img, comp, job.output.channels, w, h, false);
|
||||
gif::Frame::from_rgba_speed(w as u16, h as u16, &mut px, 10)
|
||||
})
|
||||
.await;
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use effectcraft_project::render_queue::{
|
||||
AlphaMode, Channels, ColorDepth, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path,
|
||||
};
|
||||
use effectcraft_project::{Comp, Project};
|
||||
use effectcraft_project::render_queue::{AlphaMode, Channels, CurrentOrOff, EffectsMode, FrameSample, RenderLog, ResizeQuality, SwitchOverride, log_path};
|
||||
use effectcraft_project::{BitDepth, Comp, Project};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::{Backend, LayerCache, RenderOpts, Renderer};
|
||||
use effectcraft_time::Tick;
|
||||
@@ -220,6 +218,9 @@ impl<'a> Cx<'a> {
|
||||
pub fn new(job: &'a Job<'a>) -> Cx<'a> {
|
||||
let mut project = job.project.clone();
|
||||
apply_overrides(&mut project, job);
|
||||
// Render Settings ▸ Color Depth overrides the project's depth for the whole render (every
|
||||
// layer and effect quantises at it), not just the finished frame (#2022).
|
||||
project.settings.bit_depth = job.settings.color_depth.resolve(project.settings.bit_depth);
|
||||
let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new()));
|
||||
let cache = LayerCache::default();
|
||||
// With deferred readbacks, buffers computed from a missed pass's placeholders are not kept.
|
||||
@@ -360,14 +361,6 @@ impl<'a> Cx<'a> {
|
||||
if second == first { a } else { interleave(&a, &self.render_at(second).await, upper_first) }
|
||||
}
|
||||
};
|
||||
let mut img = img;
|
||||
// Color Depth: renders run at the project's depth; another depth quantises the frame.
|
||||
let depth = self.job.settings.color_depth;
|
||||
if depth != ColorDepth::Current
|
||||
&& let Some(levels) = depth.resolve(self.project.settings.bit_depth).levels()
|
||||
{
|
||||
img.quantize(levels);
|
||||
}
|
||||
let img = self.crop_resize(img);
|
||||
if let Some(ft) = &self.frame_times {
|
||||
ft.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((i, t0.elapsed().as_secs_f64()));
|
||||
@@ -384,8 +377,10 @@ impl<'a> Cx<'a> {
|
||||
return None;
|
||||
}
|
||||
let FrameSample::Progressive(t) = self.job.settings.sample(comp, i) else { return None };
|
||||
let depth = self.job.settings.color_depth;
|
||||
if depth != ColorDepth::Current && depth.resolve(self.project.settings.bit_depth).levels().is_some() {
|
||||
// Above 8 bpc the colour is dithered on its way to 8 bits (`Cx::pixels`), which the
|
||||
// accelerator's conversion doesn't do: those frames render as usual so H.264 doesn't
|
||||
// band (#2022). The project's depth already includes Render Settings ▸ Color Depth.
|
||||
if self.project.settings.bit_depth != BitDepth::Bpc8 {
|
||||
return None;
|
||||
}
|
||||
let om = self.job.output;
|
||||
@@ -453,12 +448,24 @@ impl<'a> Cx<'a> {
|
||||
|
||||
/// 8-bit RGBA of the requested channels cropped/padded to `w`×`h` (codec rounding): RGB over
|
||||
/// the comp background (opaque), RGB + Alpha straight or premultiplied, or the alpha matte.
|
||||
/// When the render runs above 8 bpc, the colour is dithered on its way to 8 bits, as After
|
||||
/// Effects dithers colours converted down to 8 bpc for rendering and export, so gradients
|
||||
/// rendered at 16 or 32 bpc don't band (#2022). An 8 bpc render has no finer values to
|
||||
/// dither, so its output stays byte for byte what it was.
|
||||
pub fn pixels(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32) -> Vec<u8> {
|
||||
let dither = self.project.settings.bit_depth != BitDepth::Bpc8;
|
||||
self.pixels_with(img, comp, channels, w, h, dither)
|
||||
}
|
||||
|
||||
/// [`Cx::pixels`], dithering the colour or not (a GIF palette gains nothing from dither noise).
|
||||
pub fn pixels_with(&self, img: &Image, comp: &Comp, channels: Channels, w: u32, h: u32, dither: bool) -> Vec<u8> {
|
||||
let px = match channels {
|
||||
Channels::Rgb if dither => img.to_rgba8_over_dithered(comp.background),
|
||||
Channels::Rgb => img.to_rgba8_over(comp.background),
|
||||
Channels::Rgba => match self.job.output.alpha_mode {
|
||||
AlphaMode::Straight => img.to_rgba8(),
|
||||
AlphaMode::Premultiplied => premultiplied8(img),
|
||||
Channels::Rgba => match (self.job.output.alpha_mode, dither) {
|
||||
(AlphaMode::Straight, true) => img.to_rgba8_dithered(),
|
||||
(AlphaMode::Straight, false) => img.to_rgba8(),
|
||||
(AlphaMode::Premultiplied, _) => premultiplied8(img, dither),
|
||||
},
|
||||
Channels::Alpha => img
|
||||
.data
|
||||
@@ -619,15 +626,22 @@ fn bicubic(src: &Image, w: u32, h: u32) -> Image {
|
||||
out
|
||||
}
|
||||
|
||||
/// Premultiplied 8-bit RGBA (colour matted with black).
|
||||
fn premultiplied8(img: &Image) -> Vec<u8> {
|
||||
img.data
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
let a = p[3].clamp(0.0, 1.0);
|
||||
[0, 1, 2].map(|c| (p[c].clamp(0.0, a) * 255.0 + 0.5) as u8).into_iter().chain([(a * 255.0 + 0.5) as u8])
|
||||
})
|
||||
.collect()
|
||||
/// Premultiplied 8-bit RGBA (colour matted with black), the colour dithered when `dither`.
|
||||
fn premultiplied8(img: &Image, dither: bool) -> Vec<u8> {
|
||||
let w = img.width.max(1) as usize;
|
||||
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);
|
||||
// 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(a8);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -636,6 +650,19 @@ mod tests {
|
||||
use rayon::prelude::*;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
/// 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.as_chunks::<4>().0.iter().all(|p| p[..3].iter().all(|c| *c <= p[3])), "a colour byte is above its alpha");
|
||||
assert!(px.as_chunks::<4>().0.iter().all(|p| p[3] == 100));
|
||||
let plain = premultiplied8(&img, false);
|
||||
assert!(plain.as_chunks::<4>().0.iter().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};
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Gradients exported at 16 bpc (or with a 16 bpc Color Depth override) don't band (#2022): the
|
||||
//! float frame is dithered on its way to 8 bits, and the Render Settings' Color Depth reaches the
|
||||
//! render itself.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_export::render_queue::*;
|
||||
use effectcraft_export::{Job, JobOptions, Sink, export};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, LayerSource, Project, Solid};
|
||||
use effectcraft_render::NoFootage;
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
const W: u32 = 1024;
|
||||
const H: u32 = 16;
|
||||
/// The ramp runs from this grey to `HI` across the comp: about 25 eight-bit codes in 1024 px,
|
||||
/// so plain rounding makes bands about 40 px wide.
|
||||
const LO: f64 = 0.20;
|
||||
const HI: f64 = 0.30;
|
||||
/// Width of the column blocks whose mean is compared with the ideal ramp.
|
||||
const WINDOW: u32 = 8;
|
||||
|
||||
/// A grey horizontal ramp over a comp of the given project depth.
|
||||
fn ramp_project(depth: BitDepth) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = depth;
|
||||
let mut comp = Comp::new(W, H, FrameRate::new(25, 1), Tick::from_seconds_f64(1.0));
|
||||
comp.background = [0.0, 0.0, 0.0];
|
||||
let sid = p.add_item("Ramp", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: W, height: H, pixel_aspect: 1.0 }));
|
||||
let mut layer = build::layer(&mut p, &comp, "Ramp", LayerSource::Solid { item: sid }, (W, H), None);
|
||||
let spec = effectcraft_effects::find("ec.generate.gradientramp").expect("gradient ramp effect");
|
||||
let mut next = p.next_id;
|
||||
let mut group = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [W as f64, H as f64]);
|
||||
p.next_id = next;
|
||||
for (k, v) in [
|
||||
("start", Value::Vec2([0.0, 0.0])),
|
||||
("end", Value::Vec2([W as f64, 0.0])),
|
||||
("startColor", Value::Color([LO, LO, LO, 1.0])),
|
||||
("endColor", Value::Color([HI, HI, HI, 1.0])),
|
||||
] {
|
||||
group.get_mut(k).expect("ramp parameter").value = v;
|
||||
}
|
||||
layer.props.sub_mut("effects").expect("effects group").children.push(group.into());
|
||||
comp.layers.push(layer);
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into()));
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
/// Export frame 0 as PNG with the Render Settings' Color Depth `depth` and return the grey of
|
||||
/// every row.
|
||||
fn export_rows(p: &Project, cid: ItemId, depth: ColorDepth) -> Vec<Vec<u8>> {
|
||||
let files: Arc<Mutex<Vec<(String, Vec<u8>)>>> = Default::default();
|
||||
let f = files.clone();
|
||||
let sink: Box<Sink> = Box::new(move |path: &str, data: Vec<u8>| f.lock().unwrap().push((path.to_string(), data)));
|
||||
let settings =
|
||||
RenderSettings { time_span: TimeSpan::Custom { start: Tick::ZERO, end: Tick::from_seconds_f64(0.04) }, color_depth: depth, ..Default::default() };
|
||||
let om = OutputModule::for_format(OutputFormat::PngSequence);
|
||||
let job = Job {
|
||||
project: p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &settings,
|
||||
output: &om,
|
||||
path: "/banding/f_[#####].png",
|
||||
sink: Some(&*sink),
|
||||
options: JobOptions::default(),
|
||||
nested_switches: true,
|
||||
};
|
||||
export(&job, &mut |_| true).expect("export");
|
||||
let files = files.lock().unwrap();
|
||||
let (_, png) = files.iter().find(|(path, _)| path.ends_with(".png")).expect("a PNG");
|
||||
let img = image::load_from_memory(png).expect("png").to_rgba8();
|
||||
(0..H).map(|y| (0..W).map(|x| img.get_pixel(x, y).0[1]).collect()).collect()
|
||||
}
|
||||
|
||||
/// The ideal ramp at column `x`, in 8-bit codes.
|
||||
fn ideal(x: f64) -> f64 {
|
||||
255.0 * (LO + (HI - LO) * (x + 0.5) / W as f64)
|
||||
}
|
||||
|
||||
/// Longest run of equal neighbours in the row: a band's width.
|
||||
fn longest_run(row: &[u8]) -> usize {
|
||||
let (mut best, mut run) = (1, 1);
|
||||
for w in row.windows(2) {
|
||||
run = if w[0] == w[1] { run + 1 } else { 1 };
|
||||
best = best.max(run);
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Horizontal neighbours with different values, over all rows: ~25 per row when every band is a
|
||||
/// flat step, hundreds when the steps are dithered.
|
||||
fn transitions(rows: &[Vec<u8>]) -> usize {
|
||||
rows.iter().map(|r| r.windows(2).filter(|w| w[0] != w[1]).count()).sum()
|
||||
}
|
||||
|
||||
/// Largest difference, in 8-bit codes, between the mean of a `WINDOW`-column block over all rows
|
||||
/// and the ideal ramp's mean over it. Plain rounding leaves up to ~0.4 codes of it beside a step
|
||||
/// edge; dither averages it away (its noise on a mean of `WINDOW * H` samples is under 0.05).
|
||||
fn worst_window_error(rows: &[Vec<u8>]) -> f64 {
|
||||
(0..W / WINDOW)
|
||||
.map(|i| {
|
||||
let (x0, x1) = ((i * WINDOW) as usize, ((i + 1) * WINDOW) as usize);
|
||||
let sum: f64 = rows.iter().flat_map(|r| &r[x0..x1]).map(|&v| v as f64).sum();
|
||||
let mean = sum / (rows.len() * (x1 - x0)) as f64;
|
||||
(mean - ideal((x0 + x1) as f64 / 2.0 - 1.0)).abs()
|
||||
})
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_16_bpc_gradient_export_is_dithered() {
|
||||
let (p, cid) = ramp_project(BitDepth::Bpc16);
|
||||
let rows = export_rows(&p, cid, ColorDepth::Current);
|
||||
let (steps, err) = (transitions(&rows), worst_window_error(&rows));
|
||||
eprintln!("16 bpc project: {steps} transitions, worst {WINDOW}-px mean error {err:.3} codes");
|
||||
// Undithered there are ~25 flat steps per row (400 transitions) and the block means are off
|
||||
// by up to ~0.4 codes.
|
||||
assert!(steps > 2000, "the steps survive: {steps} transitions");
|
||||
assert!(err < 0.25, "the local mean is off by {err} codes");
|
||||
// Dither never moves a value a whole code from the ideal ramp.
|
||||
for row in &rows {
|
||||
for (x, &v) in row.iter().enumerate() {
|
||||
let want = ideal(x as f64);
|
||||
assert!((v as f64 - want).abs() < 1.05, "x {x}: {v} vs {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_export_is_the_same_every_time() {
|
||||
let (p, cid) = ramp_project(BitDepth::Bpc16);
|
||||
assert_eq!(export_rows(&p, cid, ColorDepth::Current), export_rows(&p, cid, ColorDepth::Current));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_render_color_depth_reaches_the_render() {
|
||||
// An 8 bpc project renders every layer at 8 bits: flat steps, nothing for the output to dither.
|
||||
let (p, cid) = ramp_project(BitDepth::Bpc8);
|
||||
let banded = export_rows(&p, cid, ColorDepth::Current);
|
||||
assert!(longest_run(&banded[0]) >= 30, "an 8 bpc render is quantised before export");
|
||||
assert!(transitions(&banded) < 16 * 30);
|
||||
// The Render Settings' 16 bpc depth overrides the project's: the render keeps its precision
|
||||
// until the final conversion, which dithers.
|
||||
let deep = export_rows(&p, cid, ColorDepth::Bpc16);
|
||||
let (steps, err) = (transitions(&deep), worst_window_error(&deep));
|
||||
eprintln!("8 bpc project, 16 bpc render: {steps} transitions, worst {WINDOW}-px mean error {err:.3} codes");
|
||||
assert!(steps > 2000 && err < 0.25, "a 16 bpc render still bands: {steps} transitions, error {err}");
|
||||
// And 8 bpc on a 16 bpc project renders at 8 bits.
|
||||
let (p16, cid16) = ramp_project(BitDepth::Bpc16);
|
||||
assert_eq!(export_rows(&p16, cid16, ColorDepth::Bpc8), banded);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_export::render_queue::{OutputFormat, OutputModule, RenderSettings, TimeSpan};
|
||||
use effectcraft_export::render_queue::{ColorDepth, OutputFormat, OutputModule, RenderSettings, TimeSpan};
|
||||
use effectcraft_export::{Job, export};
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build};
|
||||
use effectcraft_raster::Image;
|
||||
@@ -147,6 +147,20 @@ fn frames_the_accelerator_cannot_take_fall_back_to_rgba() {
|
||||
assert!(a == c);
|
||||
}
|
||||
|
||||
/// Above 8 bpc the colour is dithered on its way to 8 bits, which the accelerator's planes
|
||||
/// don't do: those frames take the RGBA path, so an H.264 export doesn't band (#2022).
|
||||
#[test]
|
||||
fn renders_above_8_bpc_skip_the_planes() {
|
||||
let d = out_dir("h264-gpu-yuv-16bpc");
|
||||
let (p, cid) = project();
|
||||
let s = RenderSettings { time_span: TimeSpan::LengthOfComp, color_depth: ColorDepth::Bpc16, ..Default::default() };
|
||||
let yuv = Fake::new(true, false, usize::MAX);
|
||||
let a = run(&p, cid, Some(&yuv), &s, &d.join("yuv.mp4"));
|
||||
let c = run(&p, cid, None, &s, &d.join("cpu.mp4"));
|
||||
assert_eq!(yuv.yuv_frames.load(Ordering::SeqCst), 0, "no planes asked for above 8 bpc");
|
||||
assert!(a == c);
|
||||
}
|
||||
|
||||
/// With deferred readbacks the export keeps rendering frames in passes (`Cx::frames`), never
|
||||
/// through the blocking per-frame path the planes use.
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
|
||||
|
||||
mod audio_sample_positions;
|
||||
mod banding;
|
||||
mod h264_gpu_yuv;
|
||||
mod hevc_av1_roundtrip;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -242,7 +242,7 @@ impl Image {
|
||||
}
|
||||
/// Straight-alpha 8-bit RGBA (sRGB-encoded values as stored).
|
||||
pub fn to_rgba8(&self) -> Vec<u8> {
|
||||
self.map_rgba8(|o, p| {
|
||||
self.map_rgba8(|o, p, _, _| {
|
||||
let a = p[3].clamp(0.0, 1.0);
|
||||
let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 };
|
||||
for c in 0..3 {
|
||||
@@ -251,9 +251,20 @@ impl Image {
|
||||
o[3] = (a * 255.0 + 0.5) as u8;
|
||||
})
|
||||
}
|
||||
/// [`Image::to_rgba8`] with the colour dithered ([`dither_u8`]); alpha is rounded.
|
||||
pub fn to_rgba8_dithered(&self) -> Vec<u8> {
|
||||
self.map_rgba8(|o, p, x, y| {
|
||||
let a = p[3].clamp(0.0, 1.0);
|
||||
let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 };
|
||||
for c in 0..3 {
|
||||
o[c] = dither_u8(p[c] * inv, x, y);
|
||||
}
|
||||
o[3] = (a * 255.0 + 0.5) as u8;
|
||||
})
|
||||
}
|
||||
/// Composite over an opaque background colour and return opaque 8-bit RGBA.
|
||||
pub fn to_rgba8_over(&self, bg: [f32; 3]) -> Vec<u8> {
|
||||
self.map_rgba8(|o, p| {
|
||||
self.map_rgba8(|o, p, _, _| {
|
||||
let k = 1.0 - p[3].clamp(0.0, 1.0);
|
||||
for c in 0..3 {
|
||||
o[c] = ((p[c] + bg[c] * k).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
||||
@@ -261,13 +272,23 @@ impl Image {
|
||||
o[3] = 255;
|
||||
})
|
||||
}
|
||||
/// 8-bit RGBA with each pixel written by `f`, row-parallel.
|
||||
fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px) + Sync) -> Vec<u8> {
|
||||
/// [`Image::to_rgba8_over`] with the colour dithered ([`dither_u8`]).
|
||||
pub fn to_rgba8_over_dithered(&self, bg: [f32; 3]) -> Vec<u8> {
|
||||
self.map_rgba8(|o, p, x, y| {
|
||||
let k = 1.0 - p[3].clamp(0.0, 1.0);
|
||||
for c in 0..3 {
|
||||
o[c] = dither_u8(p[c] + bg[c] * k, x, y);
|
||||
}
|
||||
o[3] = 255;
|
||||
})
|
||||
}
|
||||
/// 8-bit RGBA with each pixel written by `f` (given its x and y), row-parallel.
|
||||
fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px, u32, u32) + Sync) -> Vec<u8> {
|
||||
let mut out = vec![0u8; self.data.len() * 4];
|
||||
let w = self.width.max(1) as usize;
|
||||
out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).for_each(|(o, row)| {
|
||||
for (o, p) in o.as_chunks_mut::<4>().0.iter_mut().zip(row) {
|
||||
f(o, p);
|
||||
out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).enumerate().for_each(|(y, (o, row))| {
|
||||
for (x, (o, p)) in o.as_chunks_mut::<4>().0.iter_mut().zip(row).enumerate() {
|
||||
f(o, p, x as u32, y as u32);
|
||||
}
|
||||
});
|
||||
out
|
||||
@@ -342,6 +363,22 @@ pub fn hash_noise(x: u32, y: u32, seed: u32) -> f32 {
|
||||
(h & 0x00ff_ffff) as f32 / 16_777_216.0
|
||||
}
|
||||
|
||||
/// Seed of the output dither: fixed, so a frame converts to the same bytes every time.
|
||||
const DITHER_SEED: u32 = 0x5bd1_e995;
|
||||
|
||||
/// A colour value in 0..1 as an 8-bit code, dithered by pixel position (#2022): the value is
|
||||
/// scaled to 0..255 and rounded up or down against a per-pixel hash threshold, so the codes of
|
||||
/// a smooth gradient average to the gradient instead of stepping in bands. A value already on
|
||||
/// an 8-bit code (anything rendered at 8 bpc) comes out unchanged. Deterministic: depends only
|
||||
/// on the value and the pixel position, never on a clock or a global generator.
|
||||
#[inline]
|
||||
pub fn dither_u8(v: f32, x: u32, y: u32) -> u8 {
|
||||
let s = v.clamp(0.0, 1.0) * 255.0;
|
||||
// The threshold stays inside (1e-4, 1 - 1e-4) so float error on an exact code can't flip it.
|
||||
let t = hash_noise(x, y, DITHER_SEED) * (1.0 - 2e-4) + 1e-4;
|
||||
(s + t).floor() as u8
|
||||
}
|
||||
|
||||
/// A single-channel coverage mask.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Mask {
|
||||
@@ -366,6 +403,35 @@ impl Mask {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dither_keeps_exact_codes_and_averages_the_rest() {
|
||||
// A value on an 8-bit code is that code at every position.
|
||||
for code in 0..=255u32 {
|
||||
let v = code as f32 / 255.0;
|
||||
for (x, y) in [(0, 0), (1, 0), (17, 3), (1919, 1079), (4095, 4095)] {
|
||||
assert_eq!(dither_u8(v, x, y) as u32, code, "code {code} at ({x}, {y})");
|
||||
}
|
||||
}
|
||||
// Out-of-range and NaN values saturate instead of wrapping.
|
||||
assert_eq!((dither_u8(-3.0, 5, 5), dither_u8(7.0, 5, 5), dither_u8(f32::NAN, 5, 5)), (0, 255, 0));
|
||||
// A value between two codes lands on both, in proportion: the mean over a block is the value.
|
||||
let v = (100.0 + 0.3) / 255.0;
|
||||
let n = 64u32;
|
||||
let sum: u32 = (0..n * n).map(|i| dither_u8(v, i % n, i / n) as u32).sum();
|
||||
let mean = sum as f32 / (n * n) as f32;
|
||||
assert!((mean - 100.3).abs() < 0.03, "{mean}");
|
||||
// Deterministic.
|
||||
assert_eq!(dither_u8(v, 9, 4), dither_u8(v, 9, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dithered_conversions_match_the_plain_ones_on_exact_codes() {
|
||||
let src: Vec<u8> = vec![255, 128, 0, 255, 10, 20, 30, 255, 0, 0, 0, 255, 1, 2, 3, 255];
|
||||
let img = Image::from_rgba8(2, 2, &src);
|
||||
assert_eq!(img.to_rgba8_dithered(), img.to_rgba8());
|
||||
assert_eq!(img.to_rgba8_over_dithered([0.0; 3]), img.to_rgba8_over([0.0; 3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgba8_roundtrip() {
|
||||
let src: Vec<u8> = vec![255, 128, 0, 255, 10, 20, 30, 128, 0, 0, 0, 0, 1, 2, 3, 4];
|
||||
|
||||
Reference in New Issue
Block a user