A mask layer is an ordinary develop chain plus a rule about where it applies. Nothing in the chain knows it is being masked, so every operation that works globally now works locally and a newly declared op in `ops/` arrives with local support already done. The composer emits each layer after the global chain and before the conversion out of camera space, which is what a photographer means by "and *then* lift the shadows on her face". Op fragments write to a `c` they expect to own, so a layer block shadows it and copies the result back out through a carrier — assigning the outer one from inside is impossible precisely because it is shadowed. The fused dispatch survives: three global adjustments and two masked ones remain one shader, one read, one write. Masks rasterise on the GPU and never exist in CPU memory (ARCH §5.4). That is the whole reason darktable's brush masks lag, and it is architectural rather than tuning, so it is not a thing to inherit and fix later. The rasteriser is a render pass rather than the compute shader it obviously wants to be, and the format is why: R8Unorm is not a core storage format, so a compute path has to widen masks to four bytes per pixel — 768 MB across eight layers of a 24 MP export, against 192 MB at one byte. A colour attachment takes R8Unorm happily. The array slice comes from the attached view, so no slot uniform exists to disagree with where the pass writes. Region masks index a compacted label field rather than the watershed's raw basin roots, because a root is a sparse index into pixel space and indexing a per-region array by one would need a table the size of the image. Changing a selection then costs a few kilobytes, not a re-upload. Stored as region ids, not as pixels: diffable, mergeable per-field under FR-NC-9, and cheap in a sidecar. The ids only mean anything alongside the segmentation that produced them, so each layer carries that signature and is treated as stale rather than applied when it does not match — a confidently wrong mask being much worse than an absent one. Seven device tests render actual frames and read them back. The unit tests either side check halves that would both pass if the two agreed with each other and were both wrong; a mask sampled with x and y swapped satisfies them and fails these.
1729 lines
69 KiB
Rust
1729 lines
69 KiB
Rust
//! The adjust pass — runs `dr-pipeline`'s generated shader.
|
|
//!
|
|
//! Takes the demosaiced texture, applies the composed operation chain, and
|
|
//! writes a display-ready RGBA8 texture. One dispatch, whatever the number of
|
|
//! active operations, because the operations were fused into one shader
|
|
//! before they got here.
|
|
//!
|
|
//! # The pipeline cache
|
|
//!
|
|
//! Compiling a shader takes milliseconds — fine once, ruinous per frame while
|
|
//! a slider is moving. Pipelines are therefore cached by the composed
|
|
//! shader's `structure_hash`, which covers the operation set and their order
|
|
//! but not their values. Dragging a slider re-uploads a uniform buffer and
|
|
//! reuses the compiled pipeline; enabling an operation compiles once and then
|
|
//! also reuses.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use dr_pipeline::ComposedShader;
|
|
use wgpu::util::DeviceExt;
|
|
|
|
use crate::readback::await_mapping;
|
|
use crate::{DemosaicedImage, GpuContext, GpuError};
|
|
|
|
/// Leading floats the composer reserves before any operation's own uniforms:
|
|
/// three padded matrix rows, the as-shot white balance, and framing's block.
|
|
///
|
|
/// Imported rather than restated. It was a local literal, which was a latent
|
|
/// bug of exactly the kind that is invisible until it is severe: growing the
|
|
/// reserved block on the pipeline side would leave this short, and every
|
|
/// operation's uniforms would silently shift out from under the shader that
|
|
/// reads them.
|
|
const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS;
|
|
|
|
/// Runs composed operation chains against demosaiced images.
|
|
pub struct AdjustPass {
|
|
ctx: GpuContext,
|
|
bind_group_layout: wgpu::BindGroupLayout,
|
|
pipeline_layout: wgpu::PipelineLayout,
|
|
/// Compiled pipelines by structure hash (ARCH §5.6).
|
|
cache: HashMap<u64, wgpu::ComputePipeline>,
|
|
/// TRACES: FR-DSP-1 | AC-8
|
|
/// Output textures, written alternately, each reallocated only when the
|
|
/// size changes.
|
|
///
|
|
/// **Two, and the second one is not an optimisation — it is what makes the
|
|
/// zero-copy path visible.** Since S1 the compositor is handed this
|
|
/// texture rather than a copy of its pixels, and Slint decides whether to
|
|
/// repaint by comparing the image property against its previous value. Two
|
|
/// images wrapping the *same* `wgpu::Texture` compare equal, so a pass
|
|
/// that always wrote one texture would recompute every frame on the GPU
|
|
/// and never once be asked to show it. Alternating makes each frame a
|
|
/// genuinely different value, which is the only thing that makes it a
|
|
/// different picture as far as the property system is concerned.
|
|
///
|
|
/// It also settles the question of whether the compositor is still
|
|
/// sampling last frame while this frame's dispatch overwrites it. Both go
|
|
/// through one queue, so submission order already answers that — but not
|
|
/// having to rely on it is worth a texture.
|
|
targets: [Option<Target>; 2],
|
|
/// Which of [`Self::targets`] the last render wrote.
|
|
current: usize,
|
|
/// Bound at `@binding(3)` when the edit carries no mask layers.
|
|
empty_masks: wgpu::TextureView,
|
|
}
|
|
|
|
struct Target {
|
|
texture: wgpu::Texture,
|
|
view: wgpu::TextureView,
|
|
width: u32,
|
|
height: u32,
|
|
}
|
|
|
|
impl AdjustPass {
|
|
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
|
|
|
pub fn new(ctx: &GpuContext) -> Self {
|
|
let bind_group_layout =
|
|
ctx.device
|
|
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
|
label: Some("adjust-bgl"),
|
|
entries: &[
|
|
// The demosaiced source.
|
|
wgpu::BindGroupLayoutEntry {
|
|
binding: 0,
|
|
visibility: wgpu::ShaderStages::COMPUTE,
|
|
ty: wgpu::BindingType::Texture {
|
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
|
view_dimension: wgpu::TextureViewDimension::D2,
|
|
multisampled: false,
|
|
},
|
|
count: None,
|
|
},
|
|
wgpu::BindGroupLayoutEntry {
|
|
binding: 1,
|
|
visibility: wgpu::ShaderStages::COMPUTE,
|
|
ty: wgpu::BindingType::Buffer {
|
|
ty: wgpu::BufferBindingType::Uniform,
|
|
has_dynamic_offset: false,
|
|
min_binding_size: None,
|
|
},
|
|
count: None,
|
|
},
|
|
wgpu::BindGroupLayoutEntry {
|
|
binding: 2,
|
|
visibility: wgpu::ShaderStages::COMPUTE,
|
|
ty: wgpu::BindingType::StorageTexture {
|
|
access: wgpu::StorageTextureAccess::WriteOnly,
|
|
format: Self::FORMAT,
|
|
view_dimension: wgpu::TextureViewDimension::D2,
|
|
},
|
|
count: None,
|
|
},
|
|
// The local-adjustment masks. Present in every layout
|
|
// whether or not the edit has any, because the layout
|
|
// is built once here and the generated shader declares
|
|
// the binding unconditionally for exactly that reason.
|
|
wgpu::BindGroupLayoutEntry {
|
|
binding: 3,
|
|
visibility: wgpu::ShaderStages::COMPUTE,
|
|
ty: wgpu::BindingType::Texture {
|
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
|
view_dimension: wgpu::TextureViewDimension::D2Array,
|
|
multisampled: false,
|
|
},
|
|
count: None,
|
|
},
|
|
],
|
|
});
|
|
|
|
let pipeline_layout = ctx
|
|
.device
|
|
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
|
label: Some("adjust-layout"),
|
|
bind_group_layouts: &[Some(&bind_group_layout)],
|
|
immediate_size: 0,
|
|
});
|
|
|
|
// A 1x1 single-layer mask, bound when the edit has no local
|
|
// adjustments. The generated shader never samples it — no layer block
|
|
// is emitted — but a bind group must still satisfy the layout.
|
|
let empty = ctx.device.create_texture(&wgpu::TextureDescriptor {
|
|
label: Some("adjust-empty-masks"),
|
|
size: wgpu::Extent3d {
|
|
width: 1,
|
|
height: 1,
|
|
depth_or_array_layers: 1,
|
|
},
|
|
mip_level_count: 1,
|
|
sample_count: 1,
|
|
dimension: wgpu::TextureDimension::D2,
|
|
format: crate::MaskArray::FORMAT,
|
|
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
|
view_formats: &[],
|
|
});
|
|
let empty_masks = empty.create_view(&wgpu::TextureViewDescriptor {
|
|
label: Some("adjust-empty-masks-view"),
|
|
dimension: Some(wgpu::TextureViewDimension::D2Array),
|
|
..Default::default()
|
|
});
|
|
|
|
Self {
|
|
ctx: ctx.clone(),
|
|
bind_group_layout,
|
|
pipeline_layout,
|
|
cache: HashMap::new(),
|
|
targets: [None, None],
|
|
current: 0,
|
|
empty_masks,
|
|
}
|
|
}
|
|
|
|
/// Compile a composed shader, or return the cached pipeline.
|
|
///
|
|
/// Compilation errors carry the generated source, since a stray line
|
|
/// number against code nobody wrote is otherwise very hard to act on.
|
|
fn pipeline(&mut self, shader: &ComposedShader) -> Result<&wgpu::ComputePipeline, GpuError> {
|
|
if !self.cache.contains_key(&shader.structure_hash) {
|
|
// A validation error here is a codegen bug, not a user error.
|
|
// Push an error scope so it surfaces as a Result rather than a
|
|
// panic from wgpu's default handler.
|
|
//
|
|
// Since wgpu 29 the scope is a guard rather than a device-level
|
|
// push/pop pair, which is the better shape: an early return from
|
|
// this function pops it on drop instead of leaving a scope open on
|
|
// the device for whatever ran next to fall into.
|
|
let scope = self
|
|
.ctx
|
|
.device
|
|
.push_error_scope(wgpu::ErrorFilter::Validation);
|
|
|
|
let module = self
|
|
.ctx
|
|
.device
|
|
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
|
label: Some("adjust-generated"),
|
|
source: wgpu::ShaderSource::Wgsl(shader.source.as_str().into()),
|
|
});
|
|
|
|
let pipeline =
|
|
self.ctx
|
|
.device
|
|
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
|
label: Some("adjust-pipeline"),
|
|
layout: Some(&self.pipeline_layout),
|
|
module: &module,
|
|
entry_point: Some("main"),
|
|
compilation_options: Default::default(),
|
|
cache: None,
|
|
});
|
|
|
|
if let Some(err) = pollster::block_on(scope.pop()) {
|
|
return Err(GpuError::ShaderCompilation(format!(
|
|
"{err}\n\n--- generated source ---\n{}",
|
|
numbered(&shader.source)
|
|
)));
|
|
}
|
|
|
|
self.cache.insert(shader.structure_hash, pipeline);
|
|
}
|
|
|
|
Ok(self
|
|
.cache
|
|
.get(&shader.structure_hash)
|
|
.expect("just inserted"))
|
|
}
|
|
|
|
/// Move to the other output texture and make sure it is the right size.
|
|
///
|
|
/// The rotation is unconditional; the reallocation is not. Steady-state
|
|
/// rendering at one viewport size therefore allocates nothing and simply
|
|
/// ping-pongs between two textures — see [`Self::targets`] for why there
|
|
/// are two. A resize reallocates whichever one comes up next, so the two
|
|
/// converge on the new size over two frames rather than in one lump.
|
|
fn ensure_target(&mut self, width: u32, height: u32) {
|
|
self.current ^= 1;
|
|
let slot = &mut self.targets[self.current];
|
|
if slot
|
|
.as_ref()
|
|
.is_some_and(|t| t.width == width && t.height == height)
|
|
{
|
|
return;
|
|
}
|
|
|
|
let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
|
|
label: Some("adjust-output"),
|
|
size: wgpu::Extent3d {
|
|
width,
|
|
height,
|
|
depth_or_array_layers: 1,
|
|
},
|
|
mip_level_count: 1,
|
|
sample_count: 1,
|
|
dimension: wgpu::TextureDimension::D2,
|
|
format: Self::FORMAT,
|
|
// STORAGE_BINDING to write from compute, TEXTURE_BINDING so the
|
|
// compositor can sample it, COPY_SRC for `export_pixels`.
|
|
//
|
|
// RENDER_ATTACHMENT is never used by this pass and is required
|
|
// anyway: Slint rejects an imported texture that lacks it
|
|
// (`TextureImportError::InvalidUsage`), because a compositor
|
|
// handed a texture has to assume it may need to draw into it. The
|
|
// format is likewise not a free choice — `Rgba8Unorm` and
|
|
// `Rgba8UnormSrgb` are the only two the import accepts, which is
|
|
// why `FORMAT` is what it is.
|
|
usage: wgpu::TextureUsages::STORAGE_BINDING
|
|
| wgpu::TextureUsages::TEXTURE_BINDING
|
|
| wgpu::TextureUsages::RENDER_ATTACHMENT
|
|
| wgpu::TextureUsages::COPY_SRC,
|
|
view_formats: &[],
|
|
});
|
|
let view = texture.create_view(&Default::default());
|
|
self.targets[self.current] = Some(Target {
|
|
texture,
|
|
view,
|
|
width,
|
|
height,
|
|
});
|
|
}
|
|
|
|
/// Render one frame at the requested output size.
|
|
///
|
|
/// `width`/`height` are the *display* size, which is normally far smaller
|
|
/// than the image. Rendering at viewport resolution rather than sensor
|
|
/// resolution is what keeps slider interaction inside the frame budget
|
|
/// (FR-DSP-1).
|
|
pub fn render(
|
|
&mut self,
|
|
source: &DemosaicedImage,
|
|
shader: &ComposedShader,
|
|
width: u32,
|
|
height: u32,
|
|
) -> Result<&wgpu::Texture, GpuError> {
|
|
self.render_masked(source, shader, width, height, None)
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// Render one frame with local adjustments applied.
|
|
///
|
|
/// `masks` must be the array [`crate::MaskPass`] rasterised for *this*
|
|
/// edit: the generated shader addresses slices by index, and an array
|
|
/// built from a different stack applies each layer's adjustment through
|
|
/// another layer's mask. Passing `None` is correct only for an edit with
|
|
/// no active mask layers.
|
|
pub fn render_masked(
|
|
&mut self,
|
|
source: &DemosaicedImage,
|
|
shader: &ComposedShader,
|
|
width: u32,
|
|
height: u32,
|
|
masks: Option<&crate::MaskArray>,
|
|
) -> Result<&wgpu::Texture, GpuError> {
|
|
let (width, height) = (width.max(1), height.max(1));
|
|
self.ensure_target(width, height);
|
|
|
|
// Base uniforms: the camera matrix and as-shot white balance, which
|
|
// every generated shader reads regardless of which operations are
|
|
// active. Framing's slots follow them and are filled by the composer,
|
|
// which is why only the first sixteen are written here.
|
|
let mut uniforms = shader.uniforms.clone();
|
|
if uniforms.len() < RESERVED_FIELDS {
|
|
uniforms.resize(RESERVED_FIELDS, 0.0);
|
|
}
|
|
let m = source.color_matrix();
|
|
let wb = source.as_shot_wb();
|
|
// Rows padded to vec4 for std140 alignment.
|
|
uniforms[0..4].copy_from_slice(&[m[0], m[1], m[2], 0.0]);
|
|
uniforms[4..8].copy_from_slice(&[m[3], m[4], m[5], 0.0]);
|
|
uniforms[8..12].copy_from_slice(&[m[6], m[7], m[8], 0.0]);
|
|
// The fourth slot is the non-linear flag, not padding: it tells the
|
|
// shader whether to linearise the sampled texel before any operation
|
|
// runs. See `DemosaicedImage::is_non_linear`.
|
|
let non_linear = if source.is_non_linear() { 1.0 } else { 0.0 };
|
|
uniforms[12..16].copy_from_slice(&[wb[0], wb[1], wb[2], non_linear]);
|
|
|
|
let params_buf = self
|
|
.ctx
|
|
.device
|
|
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
|
label: Some("adjust-params"),
|
|
contents: bytemuck::cast_slice(&uniforms),
|
|
usage: wgpu::BufferUsages::UNIFORM,
|
|
});
|
|
|
|
// Borrow order: compile first, since `pipeline` takes &mut self.
|
|
let _ = self.pipeline(shader)?;
|
|
let pipeline = self
|
|
.cache
|
|
.get(&shader.structure_hash)
|
|
.expect("compiled above");
|
|
let target = self.targets[self.current].as_ref().expect("ensured above");
|
|
|
|
let bind_group = self
|
|
.ctx
|
|
.device
|
|
.create_bind_group(&wgpu::BindGroupDescriptor {
|
|
label: Some("adjust-bg"),
|
|
layout: &self.bind_group_layout,
|
|
entries: &[
|
|
wgpu::BindGroupEntry {
|
|
binding: 0,
|
|
resource: wgpu::BindingResource::TextureView(source.view()),
|
|
},
|
|
wgpu::BindGroupEntry {
|
|
binding: 1,
|
|
resource: params_buf.as_entire_binding(),
|
|
},
|
|
wgpu::BindGroupEntry {
|
|
binding: 2,
|
|
resource: wgpu::BindingResource::TextureView(&target.view),
|
|
},
|
|
wgpu::BindGroupEntry {
|
|
binding: 3,
|
|
resource: wgpu::BindingResource::TextureView(
|
|
masks.map_or(&self.empty_masks, |m| m.view()),
|
|
),
|
|
},
|
|
],
|
|
});
|
|
|
|
let mut enc = self
|
|
.ctx
|
|
.device
|
|
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
|
label: Some("adjust-encoder"),
|
|
});
|
|
{
|
|
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
|
label: Some("adjust-pass"),
|
|
timestamp_writes: None,
|
|
});
|
|
pass.set_pipeline(pipeline);
|
|
pass.set_bind_group(0, &bind_group, &[]);
|
|
pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
|
|
}
|
|
self.ctx.queue.submit(Some(enc.finish()));
|
|
|
|
Ok(&self.targets[self.current]
|
|
.as_ref()
|
|
.expect("ensured above")
|
|
.texture)
|
|
}
|
|
|
|
/// How many distinct pipelines are compiled. Exposed for tests asserting
|
|
/// that slider movement does not recompile.
|
|
pub fn cached_pipelines(&self) -> usize {
|
|
self.cache.len()
|
|
}
|
|
|
|
/// The texture the last render wrote, if there has been one.
|
|
pub fn output(&self) -> Option<&wgpu::Texture> {
|
|
self.targets[self.current].as_ref().map(|t| &t.texture)
|
|
}
|
|
|
|
/// TRACES: FR-EXP-9 | AC-8
|
|
/// Copy the output to the CPU **for export**.
|
|
///
|
|
/// This method had a twin, `read_output`, which performed exactly the same
|
|
/// transfer for the display path. Spike S1 deleted the twin and left this
|
|
/// one, and the difference between them is worth writing down because it
|
|
/// is the whole of AC-8.
|
|
///
|
|
/// Reading pixels back to *display* them is what ARCH §6.1 forbids: the
|
|
/// compositor could have sampled that texture where it stood, and the
|
|
/// round-trip cost 96% of the frame at 4K — ~7 ms against a 0.28 ms
|
|
/// compute pass. There is now no method that does it, which is a stronger
|
|
/// guarantee than a feature gate: the display readback cannot be called
|
|
/// back into existence by turning something on.
|
|
///
|
|
/// Reading them back to *encode a file* is not a shortcut around anything.
|
|
/// A JPEG is made of bytes on the CPU and there is no path to one that
|
|
/// does not pass through here, so this is ungated and belongs in a
|
|
/// shipping build.
|
|
pub fn export_pixels(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
|
|
self.copy_output()
|
|
}
|
|
|
|
/// The transfer itself.
|
|
fn copy_output(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
|
|
let Some(target) = self.targets[self.current].as_ref() else {
|
|
return Err(GpuError::Readback("nothing rendered yet".into()));
|
|
};
|
|
let (w, h) = (target.width, target.height);
|
|
|
|
let unpadded = w * 4;
|
|
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
|
let padded = unpadded.div_ceil(align) * align;
|
|
|
|
let buf = self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
|
label: Some("adjust-readback"),
|
|
size: (padded * h) as u64,
|
|
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
|
mapped_at_creation: false,
|
|
});
|
|
|
|
let mut enc = self.ctx.device.create_command_encoder(&Default::default());
|
|
enc.copy_texture_to_buffer(
|
|
wgpu::TexelCopyTextureInfo {
|
|
texture: &target.texture,
|
|
mip_level: 0,
|
|
origin: wgpu::Origin3d::ZERO,
|
|
aspect: wgpu::TextureAspect::All,
|
|
},
|
|
wgpu::TexelCopyBufferInfo {
|
|
buffer: &buf,
|
|
layout: wgpu::TexelCopyBufferLayout {
|
|
offset: 0,
|
|
bytes_per_row: Some(padded),
|
|
rows_per_image: Some(h),
|
|
},
|
|
},
|
|
wgpu::Extent3d {
|
|
width: w,
|
|
height: h,
|
|
depth_or_array_layers: 1,
|
|
},
|
|
);
|
|
self.ctx.queue.submit(Some(enc.finish()));
|
|
|
|
let slice = buf.slice(..);
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
slice.map_async(wgpu::MapMode::Read, move |r| {
|
|
let _ = tx.send(r);
|
|
});
|
|
|
|
// Polled rather than parked, and bounded rather than spun forever —
|
|
// see `readback::await_mapping`, which the histogram's own transfer
|
|
// shares for exactly the same reasons.
|
|
await_mapping(&self.ctx, &rx)?;
|
|
|
|
let data = slice.get_mapped_range();
|
|
let mut out = Vec::with_capacity((unpadded * h) as usize);
|
|
for row in 0..h {
|
|
let start = (row * padded) as usize;
|
|
out.extend_from_slice(&data[start..start + unpadded as usize]);
|
|
}
|
|
drop(data);
|
|
buf.unmap();
|
|
Ok((out, w, h))
|
|
}
|
|
}
|
|
|
|
/// Number the lines of generated source, so a compiler error can be located.
|
|
fn numbered(src: &str) -> String {
|
|
src.lines()
|
|
.enumerate()
|
|
.map(|(i, l)| format!("{:>4} | {l}", i + 1))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use dr_decode::{CfaPattern, CropRect, RawImage};
|
|
use dr_pipeline::ops::{colour_mixer, exposure, saturation};
|
|
use dr_pipeline::EditGraph;
|
|
|
|
use crate::Demosaicer;
|
|
|
|
fn ctx() -> Option<GpuContext> {
|
|
match pollster::block_on(GpuContext::new_headless()) {
|
|
Ok(c) => Some(c),
|
|
Err(e) => {
|
|
eprintln!("skipping: no GPU adapter ({e})");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A flat mid-grey image, so an operation's effect is unambiguous.
|
|
fn grey_image(ctx: &GpuContext, level: u16) -> DemosaicedImage {
|
|
let size = 16u32;
|
|
let mut data = vec![0u16; (size * size) as usize];
|
|
for v in data.iter_mut() {
|
|
*v = level;
|
|
}
|
|
let raw = RawImage {
|
|
width: size,
|
|
height: size,
|
|
data,
|
|
cfa_pattern: CfaPattern::Rggb,
|
|
black_level: [0; 4],
|
|
white_level: 16383,
|
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
|
// Identity, so the test reasons about the operations alone
|
|
// rather than about a camera's colour response.
|
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
|
crop: CropRect {
|
|
x: 0,
|
|
y: 0,
|
|
width: size,
|
|
height: size,
|
|
},
|
|
};
|
|
Demosaicer::new(ctx)
|
|
.expect("demosaicer")
|
|
.run(&raw)
|
|
.expect("demosaic")
|
|
}
|
|
|
|
/// A white disc on black, centred in a `w`x`h` frame.
|
|
///
|
|
/// The one shape that makes anisotropy unmissable: any transform that
|
|
/// scales the axes unequally returns it as an ellipse, and the ratio of
|
|
/// the ellipse's axes *is* the error.
|
|
fn disc_rgba(w: u32, h: u32, radius: f32) -> Vec<u8> {
|
|
let mut rgba = vec![0u8; (w * h * 4) as usize];
|
|
for y in 0..h {
|
|
for x in 0..w {
|
|
let dx = x as f32 - w as f32 / 2.0;
|
|
let dy = y as f32 - h as f32 / 2.0;
|
|
let v = if (dx * dx + dy * dy).sqrt() < radius {
|
|
255
|
|
} else {
|
|
0
|
|
};
|
|
let i = ((y * w + x) * 4) as usize;
|
|
rgba[i] = v;
|
|
rgba[i + 1] = v;
|
|
rgba[i + 2] = v;
|
|
rgba[i + 3] = 255;
|
|
}
|
|
}
|
|
rgba
|
|
}
|
|
|
|
fn read_centre(ctx: &GpuContext, tex: &wgpu::Texture) -> [u8; 4] {
|
|
let (w, h) = (tex.width(), tex.height());
|
|
read_pixel(ctx, tex, w / 2, h / 2)
|
|
}
|
|
|
|
/// One pixel, by coordinate. What the geometry tests need: proving a
|
|
/// rotation moved content requires looking somewhere other than the
|
|
/// centre, which every rotation leaves fixed.
|
|
fn read_pixel(ctx: &GpuContext, tex: &wgpu::Texture, x: u32, y: u32) -> [u8; 4] {
|
|
let w = tex.width();
|
|
let h = tex.height();
|
|
let unpadded = w * 4;
|
|
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
|
let padded = unpadded.div_ceil(align) * align;
|
|
|
|
let buf = ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
|
label: Some("adjust-readback"),
|
|
size: (padded * h) as u64,
|
|
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
|
mapped_at_creation: false,
|
|
});
|
|
|
|
let mut enc = ctx.device.create_command_encoder(&Default::default());
|
|
enc.copy_texture_to_buffer(
|
|
wgpu::TexelCopyTextureInfo {
|
|
texture: tex,
|
|
mip_level: 0,
|
|
origin: wgpu::Origin3d::ZERO,
|
|
aspect: wgpu::TextureAspect::All,
|
|
},
|
|
wgpu::TexelCopyBufferInfo {
|
|
buffer: &buf,
|
|
layout: wgpu::TexelCopyBufferLayout {
|
|
offset: 0,
|
|
bytes_per_row: Some(padded),
|
|
rows_per_image: Some(h),
|
|
},
|
|
},
|
|
wgpu::Extent3d {
|
|
width: w,
|
|
height: h,
|
|
depth_or_array_layers: 1,
|
|
},
|
|
);
|
|
ctx.queue.submit(Some(enc.finish()));
|
|
|
|
let slice = buf.slice(..);
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
slice.map_async(wgpu::MapMode::Read, move |r| {
|
|
let _ = tx.send(r);
|
|
});
|
|
ctx.device
|
|
.poll(wgpu::PollType::wait_indefinitely())
|
|
.expect("poll");
|
|
rx.recv().expect("map").expect("map ok");
|
|
|
|
let data = slice.get_mapped_range();
|
|
let off = (y.min(h - 1) * padded + x.min(w - 1) * 4) as usize;
|
|
let px = [data[off], data[off + 1], data[off + 2], data[off + 3]];
|
|
drop(data);
|
|
buf.unmap();
|
|
px
|
|
}
|
|
|
|
#[test]
|
|
fn a_neutral_graph_produces_a_compilable_shader() {
|
|
// The first thing that could go wrong with codegen: the empty case.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = grey_image(&ctx, 4000);
|
|
let shader = EditGraph::default_chain().compose();
|
|
|
|
pass.render(&img, &shader, 16, 16)
|
|
.expect("a neutral chain must compile");
|
|
}
|
|
|
|
#[test]
|
|
fn every_operation_generates_compilable_wgsl() {
|
|
// The test that justifies the whole codegen approach. Each operation
|
|
// is compiled on its own, so a WGSL error names the operation that
|
|
// caused it rather than surfacing only in some combination.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = grey_image(&ctx, 4000);
|
|
|
|
// Cases derived from the chain itself rather than a hand-written
|
|
// list: every parameter of every operation is exercised, and adding
|
|
// an operation extends the coverage automatically instead of
|
|
// silently going untested.
|
|
let probe = EditGraph::default_chain();
|
|
for cap in probe.capabilities() {
|
|
for p in &cap.params {
|
|
let dr_pipeline::ParamKind::Scalar { min, max, .. } = p.kind else {
|
|
continue;
|
|
};
|
|
// Both extremes: a fragment can be valid at one end of its
|
|
// range and not the other.
|
|
for value in [min, max] {
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(cap.id, p.id, value);
|
|
let shader = g.compose();
|
|
pass.render(&img, &shader, 16, 16).unwrap_or_else(|e| {
|
|
panic!(
|
|
"{}.{} at {value} generated invalid WGSL:\n{e}",
|
|
cap.id, p.id
|
|
)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An image bright on one side and dark on the other, so a transform that
|
|
/// moves content is visible. A flat grey cannot show a rotation at all.
|
|
///
|
|
/// `vertical` puts the bright band at the top; otherwise at the left.
|
|
fn split_image(ctx: &GpuContext, vertical: bool) -> DemosaicedImage {
|
|
let size = 32u32;
|
|
let mut data = vec![0u16; (size * size) as usize];
|
|
for y in 0..size {
|
|
for x in 0..size {
|
|
let near_start = if vertical { y } else { x } < size / 2;
|
|
data[(y * size + x) as usize] = if near_start { 12000 } else { 500 };
|
|
}
|
|
}
|
|
let raw = RawImage {
|
|
width: size,
|
|
height: size,
|
|
data,
|
|
cfa_pattern: CfaPattern::Rggb,
|
|
black_level: [0; 4],
|
|
white_level: 16383,
|
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
|
crop: CropRect {
|
|
x: 0,
|
|
y: 0,
|
|
width: size,
|
|
height: size,
|
|
},
|
|
};
|
|
Demosaicer::new(ctx)
|
|
.expect("demosaicer")
|
|
.run(&raw)
|
|
.expect("demosaic")
|
|
}
|
|
|
|
#[test]
|
|
fn a_quarter_turn_moves_a_vertical_edge_to_a_horizontal_one() {
|
|
// The end-to-end check that the coordinate permutation is wired the
|
|
// right way round. A left-bright image turned 90° clockwise must come
|
|
// out top-bright; getting the sign wrong yields bottom-bright, which
|
|
// compiles perfectly and is simply the wrong image.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = split_image(&ctx, false);
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
g.rotate_quarters(1);
|
|
let (w, h) = g.output_size(32, 32);
|
|
let shader = g.compose();
|
|
let tex = pass.render(&img, &shader, w, h).expect("render");
|
|
|
|
let top = read_pixel(&ctx, tex, w / 2, h / 8)[0];
|
|
let bottom = read_pixel(&ctx, tex, w / 2, h * 7 / 8)[0];
|
|
assert!(
|
|
top > bottom + 40,
|
|
"a left-bright image turned 90° clockwise should be top-bright, \
|
|
got top={top} bottom={bottom}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_horizontal_flip_swaps_the_sides() {
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = split_image(&ctx, false);
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::FLIP_H, 1.0);
|
|
let shader = g.compose();
|
|
let tex = pass.render(&img, &shader, 32, 32).expect("render");
|
|
|
|
let left = read_pixel(&ctx, tex, 4, 16)[0];
|
|
let right = read_pixel(&ctx, tex, 28, 16)[0];
|
|
assert!(
|
|
right > left + 40,
|
|
"flipping a left-bright image should make it right-bright, \
|
|
got left={left} right={right}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn zooming_shows_only_the_region_looked_at() {
|
|
// Zoom is a coordinate map, and a map that type-checks can still
|
|
// sample the wrong place. Checked against content: zoomed into the
|
|
// bright half the frame must be bright edge to edge, and into the
|
|
// dark half, dark — which a wrong origin or extent would break.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = split_image(&ctx, false);
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
g.framing_mut().set_view(dr_pipeline::CropRect {
|
|
x: 0.0,
|
|
y: 0.4,
|
|
width: 0.2,
|
|
height: 0.2,
|
|
});
|
|
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
|
|
let left_near = read_pixel(&ctx, tex, 4, 16)[0];
|
|
let left_far = read_pixel(&ctx, tex, 28, 16)[0];
|
|
|
|
g.framing_mut().set_view(dr_pipeline::CropRect {
|
|
x: 0.8,
|
|
y: 0.4,
|
|
width: 0.2,
|
|
height: 0.2,
|
|
});
|
|
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
|
|
let right_near = read_pixel(&ctx, tex, 4, 16)[0];
|
|
|
|
assert!(
|
|
left_far > 100 && left_near > 100,
|
|
"zoomed into the bright half, both edges should be bright: \
|
|
near={left_near} far={left_far}"
|
|
);
|
|
assert!(
|
|
left_near > right_near + 40,
|
|
"zooming to the far side should show the dark half: \
|
|
left={left_near} right={right_near}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn zooming_does_not_recompile() {
|
|
// The property that makes scroll-wheel zoom smooth: a new zoom *level*
|
|
// is a uniform upload, never a pipeline build. If the magnitude reached
|
|
// the structure hash, every wheel notch would stall on a compile.
|
|
//
|
|
// Entering the zoom at all is the one exception, and it is deliberate
|
|
// — see `zooming_after_an_unzoomed_render_actually_zooms`. So the walk
|
|
// below starts already zoomed, and the count is taken from there.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = split_image(&ctx, false);
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
g.framing_mut().set_view(dr_pipeline::CropRect {
|
|
x: 0.0,
|
|
y: 0.0,
|
|
width: 0.5,
|
|
height: 0.5,
|
|
});
|
|
pass.render(&img, &g.compose(), 32, 32).expect("render");
|
|
let baseline = pass.cached_pipelines();
|
|
|
|
for (i, extent) in [0.4f32, 0.25, 0.125].iter().enumerate() {
|
|
g.framing_mut().set_view(dr_pipeline::CropRect {
|
|
x: 0.0,
|
|
y: 0.0,
|
|
width: *extent,
|
|
height: *extent,
|
|
});
|
|
pass.render(&img, &g.compose(), 32, 32).expect("render");
|
|
assert_eq!(
|
|
pass.cached_pipelines(),
|
|
baseline,
|
|
"zoom step {i} compiled a second pipeline"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn zooming_after_an_unzoomed_render_actually_zooms() {
|
|
// The regression: every earlier zoom test set a view *before* the first
|
|
// render, so the first pipeline compiled was already the one carrying
|
|
// the crop mapping. Real use is the other way round — the image is
|
|
// shown fitted, and only then does the wheel turn.
|
|
//
|
|
// A neutral framing emits a prologue that never reads `u.crop_rect`.
|
|
// While zoom was excluded from the structure hash, that neutral
|
|
// pipeline stayed cached under the same key once zoomed, so the view
|
|
// uploaded on every frame was read by nobody and the canvas never
|
|
// changed. This renders unzoomed first and asserts the pixels move.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = split_image(&ctx, false);
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
|
|
// Fitted: the frame spans both halves, so the two edges differ.
|
|
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
|
|
let fitted_left = read_pixel(&ctx, tex, 4, 16)[0];
|
|
let fitted_right = read_pixel(&ctx, tex, 28, 16)[0];
|
|
assert!(
|
|
(i32::from(fitted_left) - i32::from(fitted_right)).abs() > 40,
|
|
"the unzoomed frame should span both halves: \
|
|
left={fitted_left} right={fitted_right}"
|
|
);
|
|
|
|
// Now zoom into the bright half. Both edges must come up bright.
|
|
g.framing_mut().set_view(dr_pipeline::CropRect {
|
|
x: 0.0,
|
|
y: 0.4,
|
|
width: 0.2,
|
|
height: 0.2,
|
|
});
|
|
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
|
|
let zoomed_left = read_pixel(&ctx, tex, 4, 16)[0];
|
|
let zoomed_right = read_pixel(&ctx, tex, 28, 16)[0];
|
|
|
|
assert!(
|
|
zoomed_left > 100 && zoomed_right > 100,
|
|
"zooming into the bright half after an unzoomed render must show \
|
|
it edge to edge — the neutral pipeline was reused and the view \
|
|
was ignored: left={zoomed_left} right={zoomed_right}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cropping_to_one_half_shows_only_that_half() {
|
|
// The property a crop exists for, checked against content rather than
|
|
// against the output dimensions alone: a crop of the dark side must
|
|
// be dark everywhere, edge to edge.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = split_image(&ctx, false);
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_crop(dr_pipeline::CropRect {
|
|
x: 0.5,
|
|
y: 0.0,
|
|
width: 0.5,
|
|
height: 1.0,
|
|
});
|
|
let (w, h) = g.output_size(32, 32);
|
|
assert_eq!((w, h), (16, 32), "half a 32px frame is 16px wide");
|
|
|
|
let shader = g.compose();
|
|
let tex = pass.render(&img, &shader, w, h).expect("render");
|
|
assert_eq!((tex.width(), tex.height()), (16, 32));
|
|
|
|
for x in [1, w / 2, w - 2] {
|
|
let v = read_pixel(&ctx, tex, x, h / 2)[0];
|
|
assert!(v < 90, "cropped to the dark half, x={x} came out {v}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn straightening_darkens_the_exposed_corners() {
|
|
// Rotating a frame inside its own bounds leaves no source pixel at the
|
|
// corners. They must read black rather than a smeared edge pixel — the
|
|
// difference between "the frame is rotated" and "the image is smudged".
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = split_image(&ctx, false);
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, 30.0);
|
|
let shader = g.compose();
|
|
let tex = pass.render(&img, &shader, 32, 32).expect("render");
|
|
|
|
// The top-left corner of a 30° rotation is off the source.
|
|
let corner = read_pixel(&ctx, tex, 0, 0);
|
|
assert_eq!(
|
|
corner,
|
|
[0, 0, 0, 255],
|
|
"an exposed corner must be black and opaque"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dragging_the_crop_does_not_recompile() {
|
|
// The cache contract for framing, which is what makes an interactive
|
|
// crop drag viable: the rect changes every frame, and each frame must
|
|
// reuse the compiled pipeline.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = grey_image(&ctx, 4000);
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
for i in 1..=10 {
|
|
let inset = i as f32 * 0.02;
|
|
g.set_crop(dr_pipeline::CropRect {
|
|
x: inset,
|
|
y: inset,
|
|
width: 1.0 - 2.0 * inset,
|
|
height: 1.0 - 2.0 * inset,
|
|
});
|
|
let (w, h) = g.output_size(64, 64);
|
|
pass.render(&img, &g.compose(), w, h).expect("render");
|
|
}
|
|
|
|
assert_eq!(
|
|
pass.cached_pipelines(),
|
|
1,
|
|
"ten crop rectangles must share one compiled pipeline"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn straightening_compiles_its_own_pipeline_but_reuses_it() {
|
|
// Straightening changes the sampling path from an integer load to a
|
|
// bilinear fetch, so it *must* compile a second pipeline — and then
|
|
// must stop at two however far the slider travels.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = grey_image(&ctx, 4000);
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
pass.render(&img, &g.compose(), 32, 32).expect("render");
|
|
assert_eq!(pass.cached_pipelines(), 1);
|
|
|
|
for i in 1..=8 {
|
|
g.set_param(
|
|
dr_pipeline::framing::ID,
|
|
dr_pipeline::framing::ANGLE,
|
|
i as f32 * 0.5,
|
|
);
|
|
pass.render(&img, &g.compose(), 32, 32).expect("render");
|
|
}
|
|
assert_eq!(
|
|
pass.cached_pipelines(),
|
|
2,
|
|
"straightening compiles one more pipeline, not one per angle"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_whole_chain_at_once_compiles() {
|
|
// Individually-valid fragments can still collide when combined —
|
|
// duplicate helpers, clashing locals, a malformed uniform block. With
|
|
// every operation active this is the largest shader the pipeline can
|
|
// generate.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = grey_image(&ctx, 4000);
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
for cap in EditGraph::default_chain().capabilities() {
|
|
for (i, p) in cap.params.iter().enumerate() {
|
|
if let dr_pipeline::ParamKind::Scalar { min, max, .. } = p.kind {
|
|
// Stepped away from each parameter's own default by a
|
|
// varying fraction. A single shared value would leave the
|
|
// tone curve inactive: its neutral is the *relationship*
|
|
// between its points, so setting them all alike keeps it
|
|
// on the identity diagonal.
|
|
let step = (max - min) * (0.15 + 0.05 * (i % 4) as f32);
|
|
let v = if p.default + step <= max {
|
|
p.default + step
|
|
} else {
|
|
p.default - step
|
|
};
|
|
g.set_param(cap.id, p.id, v);
|
|
}
|
|
}
|
|
}
|
|
|
|
let shader = g.compose();
|
|
assert_eq!(
|
|
shader.source.matches("---- ").count(),
|
|
// Every operation, plus framing — which emits a stage of its own
|
|
// rather than an operation block, and is not in `descriptors`.
|
|
g.descriptors().len() + 1,
|
|
"every operation and the framing should be active"
|
|
);
|
|
assert!(
|
|
shader.source.contains("---- framing ----"),
|
|
"framing must reach the shader alongside the colour operations"
|
|
);
|
|
|
|
// Cropped, so the render is against an output size that is not the
|
|
// source size — the case where a wrong dispatch or a wrong texture
|
|
// allocation would show up.
|
|
let (w, h) = g.output_size(32, 32);
|
|
pass.render(&img, &shader, w, h)
|
|
.expect("the full chain must compile");
|
|
}
|
|
|
|
#[test]
|
|
fn exposure_brightens_the_image() {
|
|
// Proves the uniforms actually reach the shader, not merely that it
|
|
// compiles.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = grey_image(&ctx, 2000);
|
|
|
|
let neutral = EditGraph::default_chain().compose();
|
|
let before = {
|
|
let t = pass.render(&img, &neutral, 16, 16).expect("render");
|
|
read_centre(&ctx, t)
|
|
};
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
|
|
let brighter = g.compose();
|
|
let after = {
|
|
let t = pass.render(&img, &brighter, 16, 16).expect("render");
|
|
read_centre(&ctx, t)
|
|
};
|
|
|
|
assert!(
|
|
after[0] > before[0],
|
|
"+2 stops should brighten: {before:?} -> {after:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn negative_exposure_darkens_the_image() {
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = grey_image(&ctx, 8000);
|
|
|
|
let neutral = EditGraph::default_chain().compose();
|
|
let before = {
|
|
let t = pass.render(&img, &neutral, 16, 16).expect("render");
|
|
read_centre(&ctx, t)
|
|
};
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, -2.0);
|
|
let darker = g.compose();
|
|
let after = {
|
|
let t = pass.render(&img, &darker, 16, 16).expect("render");
|
|
read_centre(&ctx, t)
|
|
};
|
|
|
|
assert!(after[0] < before[0], "-2 stops should darken");
|
|
}
|
|
|
|
#[test]
|
|
fn full_negative_saturation_produces_grey() {
|
|
// A neutral grey source cannot show this, so use a coloured one:
|
|
// a strongly red-weighted image must come out with equal channels.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
|
|
let size = 16u32;
|
|
let mut data = vec![0u16; (size * size) as usize];
|
|
for y in 0..size {
|
|
for x in 0..size {
|
|
// RGGB: make red photosites bright, others dim.
|
|
let c = CfaPattern::Rggb.colour_at(x, y);
|
|
data[(y * size + x) as usize] = if c == 0 { 12000 } else { 3000 };
|
|
}
|
|
}
|
|
let raw = RawImage {
|
|
width: size,
|
|
height: size,
|
|
data,
|
|
cfa_pattern: CfaPattern::Rggb,
|
|
black_level: [0; 4],
|
|
white_level: 16383,
|
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
|
crop: CropRect {
|
|
x: 0,
|
|
y: 0,
|
|
width: size,
|
|
height: size,
|
|
},
|
|
};
|
|
let img = Demosaicer::new(&ctx)
|
|
.expect("demosaicer")
|
|
.run(&raw)
|
|
.expect("demosaic");
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(saturation::ID, saturation::SATURATION, -100.0);
|
|
let shader = g.compose();
|
|
let px = {
|
|
let t = pass.render(&img, &shader, 16, 16).expect("render");
|
|
read_centre(&ctx, t)
|
|
};
|
|
|
|
let spread = px[0].abs_diff(px[1]).max(px[1].abs_diff(px[2]));
|
|
assert!(
|
|
spread <= 2,
|
|
"-100 saturation must produce grey, got {px:?} (spread {spread})"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn each_colour_band_gets_its_own_pipeline() {
|
|
// The bug this closes, end to end and through one cache: the mixer
|
|
// emits code only for the bands that are set, but the cache key was
|
|
// the set of *active operations*, which is "colour_mixer" whichever
|
|
// band that is. A red adjustment and a blue one hashed alike, so the
|
|
// second render reused the first's compiled pipeline and uploaded its
|
|
// uniform into the first band's slot — whichever band compiled first
|
|
// kept acting and every other slider did nothing.
|
|
//
|
|
// Ordered red first deliberately: red is the first band declared, and
|
|
// is the one users reported as the only one that worked.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = blue_image(&ctx);
|
|
|
|
// `None` renders the chain untouched, which is the baseline the two
|
|
// band settings are measured against.
|
|
fn band(
|
|
ctx: &GpuContext,
|
|
pass: &mut AdjustPass,
|
|
img: &DemosaicedImage,
|
|
set: Option<(&'static str, f32)>,
|
|
) -> [u8; 4] {
|
|
let mut g = EditGraph::default_chain();
|
|
if let Some((id, v)) = set {
|
|
g.set_param(colour_mixer::ID, dr_pipeline::ParamId(id), v);
|
|
}
|
|
let shader = g.compose();
|
|
let t = pass.render(img, &shader, 16, 16).expect("render");
|
|
read_centre(ctx, t)
|
|
}
|
|
|
|
let neutral = band(&ctx, &mut pass, &img, None);
|
|
// Red first, so its pipeline is the one in the cache when blue asks.
|
|
let reds_turn = band(&ctx, &mut pass, &img, Some(("red_sat", 100.0)));
|
|
let blues_turn = band(&ctx, &mut pass, &img, Some(("blue_sat", -100.0)));
|
|
|
|
let spread = |p: [u8; 4]| p[2].abs_diff(p[0]);
|
|
assert_eq!(
|
|
spread(reds_turn),
|
|
spread(neutral),
|
|
"a blue pixel is outside the red band, so red must leave it alone"
|
|
);
|
|
assert!(
|
|
spread(blues_turn) + 8 < spread(neutral),
|
|
"blue at -100 must desaturate a blue pixel: {neutral:?} -> {blues_turn:?}"
|
|
);
|
|
}
|
|
|
|
/// A demosaiced image whose pixels sit at the centre of the blue band.
|
|
///
|
|
/// Red and green equal, blue well above them, which `rgb_to_hcl` reads as
|
|
/// exactly 240 degrees — full weight to blue, none to any other band.
|
|
fn blue_image(ctx: &GpuContext) -> DemosaicedImage {
|
|
let size = 16u32;
|
|
let mut data = vec![0u16; (size * size) as usize];
|
|
for y in 0..size {
|
|
for x in 0..size {
|
|
let c = CfaPattern::Rggb.colour_at(x, y);
|
|
data[(y * size + x) as usize] = if c == 2 { 12000 } else { 3000 };
|
|
}
|
|
}
|
|
let raw = RawImage {
|
|
width: size,
|
|
height: size,
|
|
data,
|
|
cfa_pattern: CfaPattern::Rggb,
|
|
black_level: [0; 4],
|
|
white_level: 16383,
|
|
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
|
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
|
crop: CropRect {
|
|
x: 0,
|
|
y: 0,
|
|
width: size,
|
|
height: size,
|
|
},
|
|
};
|
|
Demosaicer::new(ctx)
|
|
.expect("demosaicer")
|
|
.run(&raw)
|
|
.expect("demosaic")
|
|
}
|
|
|
|
#[test]
|
|
fn moving_a_slider_does_not_recompile() {
|
|
// The property the pipeline cache exists for. Recompiling per frame
|
|
// would make slider interaction unusable regardless of shader cost.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = grey_image(&ctx, 4000);
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
for i in 1..=10 {
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, i as f32 * 0.2);
|
|
let shader = g.compose();
|
|
pass.render(&img, &shader, 16, 16).expect("render");
|
|
}
|
|
|
|
assert_eq!(
|
|
pass.cached_pipelines(),
|
|
1,
|
|
"ten slider positions must share one compiled pipeline"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_different_operation_set_compiles_its_own_pipeline() {
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = grey_image(&ctx, 4000);
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
|
pass.render(&img, &g.compose(), 16, 16).expect("render");
|
|
assert_eq!(pass.cached_pipelines(), 1);
|
|
|
|
g.set_param(saturation::ID, saturation::SATURATION, 40.0);
|
|
pass.render(&img, &g.compose(), 16, 16).expect("render");
|
|
assert_eq!(pass.cached_pipelines(), 2);
|
|
|
|
// Returning to the earlier state must reuse, not compile a third.
|
|
g.set_param(saturation::ID, saturation::SATURATION, 0.0);
|
|
pass.render(&img, &g.compose(), 16, 16).expect("render");
|
|
assert_eq!(pass.cached_pipelines(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn output_is_opaque_everywhere() {
|
|
// A zero alpha would composite as an invisible image, which reads as
|
|
// "nothing rendered" rather than as a bug in this pass.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = grey_image(&ctx, 4000);
|
|
let shader = EditGraph::default_chain().compose();
|
|
let t = pass.render(&img, &shader, 16, 16).expect("render");
|
|
assert_eq!(read_centre(&ctx, t)[3], 255);
|
|
}
|
|
|
|
/// TRACES: FR-DSP-1 | AC-8
|
|
/// A disc on a non-square frame, rendered through a quarter turn.
|
|
///
|
|
/// Geometry, asserted on real pixels rather than on the generated WGSL —
|
|
/// reading the shader and reasoning about which space `p` lives in is
|
|
/// exactly how a plausible formula gets written twice.
|
|
#[test]
|
|
fn a_quarter_turn_keeps_a_circle_circular() {
|
|
let Some(ctx) = ctx() else { return };
|
|
|
|
// 3:2, so an aspect mistake is a 2.25x distortion rather than a
|
|
// subtlety. The disc is centred and comfortably inside the frame.
|
|
let (w, h) = (180u32, 120u32);
|
|
let rgba = disc_rgba(w, h, 40.0);
|
|
|
|
let src = DemosaicedImage::from_rgba8(&ctx, &rgba, w, h).expect("upload");
|
|
let mut graph = dr_pipeline::EditGraph::default_chain();
|
|
graph.rotate_quarters(1);
|
|
|
|
let (ow, oh) = graph.output_size(w, h);
|
|
assert_eq!((ow, oh), (h, w), "a quarter turn swaps the output axes");
|
|
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
pass.render(&src, &graph.compose(), ow, oh).expect("render");
|
|
let (pixels, rw, rh) = pass.export_pixels().expect("read back");
|
|
|
|
// Measure the disc's extent along each axis, at its centre.
|
|
let lit = |x: u32, y: u32| pixels[((y * rw + x) * 4) as usize] > 128;
|
|
let across = (0..rw).filter(|&x| lit(x, rh / 2)).count();
|
|
let down = (0..rh).filter(|&y| lit(rw / 2, y)).count();
|
|
|
|
assert!(across > 0 && down > 0, "the disc vanished: {across}x{down}");
|
|
let ratio = across as f32 / down as f32;
|
|
assert!(
|
|
(ratio - 1.0).abs() < 0.08,
|
|
"a turned circle came back {across} across by {down} down \
|
|
(ratio {ratio:.3}); anything but 1 is the frame being sheared"
|
|
);
|
|
}
|
|
|
|
/// The same disc, straightened by a free angle rather than turned.
|
|
///
|
|
/// A rotation is rigid: a circle stays a circle at any angle. If the
|
|
/// straighten happens in a space whose axes carry different scales, the
|
|
/// circle comes back as an ellipse — and on a photograph that reads as the
|
|
/// frame being sheared.
|
|
#[test]
|
|
fn straightening_keeps_a_circle_circular() {
|
|
let Some(ctx) = ctx() else { return };
|
|
|
|
let (w, h) = (180u32, 120u32);
|
|
let rgba = disc_rgba(w, h, 34.0);
|
|
|
|
let src = DemosaicedImage::from_rgba8(&ctx, &rgba, w, h).expect("upload");
|
|
let mut graph = dr_pipeline::EditGraph::default_chain();
|
|
// A deliberate angle, not a nudge: a shear scales with the angle and
|
|
// a degree would hide inside the tolerance.
|
|
graph.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, 20.0);
|
|
|
|
let (ow, oh) = graph.output_size(w, h);
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
pass.render(&src, &graph.compose(), ow, oh).expect("render");
|
|
let (pixels, rw, rh) = pass.export_pixels().expect("read back");
|
|
|
|
let lit = |x: u32, y: u32| pixels[((y * rw + x) * 4) as usize] > 128;
|
|
let across = (0..rw).filter(|&x| lit(x, rh / 2)).count();
|
|
let down = (0..rh).filter(|&y| lit(rw / 2, y)).count();
|
|
|
|
assert!(across > 0 && down > 0, "the disc vanished: {across}x{down}");
|
|
let ratio = across as f32 / down as f32;
|
|
assert!(
|
|
(ratio - 1.0).abs() < 0.08,
|
|
"a straightened circle came back {across} across by {down} down \
|
|
(ratio {ratio:.3}); a rotation is rigid, so anything but 1 is shear"
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3 | FR-DSP-1
|
|
/// The same disc again, straightened *and* turned.
|
|
///
|
|
/// The case neither test above reaches, and the one a portrait photograph
|
|
/// hits every time. A quarter turn — the user's or the file's EXIF tag —
|
|
/// swaps the frame's axes, so the space the straightening happens in is no
|
|
/// longer the source's: measuring a 2:3 frame with a 3:2 aspect stretches
|
|
/// one axis against the other by 2.25, and the rotation that follows comes
|
|
/// out as a shear. Each transform alone looks right, which is exactly why
|
|
/// it survived: only the pair is wrong.
|
|
#[test]
|
|
fn straightening_a_turned_frame_keeps_a_circle_circular() {
|
|
let Some(ctx) = ctx() else { return };
|
|
|
|
let (w, h) = (180u32, 120u32);
|
|
let rgba = disc_rgba(w, h, 34.0);
|
|
let src = DemosaicedImage::from_rgba8(&ctx, &rgba, w, h).expect("upload");
|
|
|
|
// Every route to a swapped frame: the button, the file's tag, and the
|
|
// two composed. All three reach the shader as one permutation, and a
|
|
// fix that only covers one of them is not a fix.
|
|
for (name, turns, tag) in [
|
|
("a user quarter turn", 1, 1u16),
|
|
("an EXIF-portrait file", 0, 6),
|
|
("both, composed", 2, 6),
|
|
] {
|
|
let mut graph = dr_pipeline::EditGraph::default_chain();
|
|
graph.set_orientation(dr_types::Orientation::from_exif(tag));
|
|
graph.rotate_quarters(turns);
|
|
graph.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, 20.0);
|
|
|
|
let (ow, oh) = graph.output_size(w, h);
|
|
assert_eq!((ow, oh), (h, w), "{name}: the frame should be portrait");
|
|
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
pass.render(&src, &graph.compose(), ow, oh).expect("render");
|
|
let (pixels, rw, rh) = pass.export_pixels().expect("read back");
|
|
|
|
let lit = |x: u32, y: u32| pixels[((y * rw + x) * 4) as usize] > 128;
|
|
let across = (0..rw).filter(|&x| lit(x, rh / 2)).count();
|
|
let down = (0..rh).filter(|&y| lit(rw / 2, y)).count();
|
|
|
|
assert!(across > 0 && down > 0, "{name}: the disc vanished");
|
|
let ratio = across as f32 / down as f32;
|
|
assert!(
|
|
(ratio - 1.0).abs() < 0.08,
|
|
"{name}: a straightened circle came back {across} across by \
|
|
{down} down (ratio {ratio:.3}); the turn and the angle are \
|
|
disagreeing about which frame they act in"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_output_is_importable_by_a_compositor() {
|
|
// Every condition Slint checks before it will adopt a texture
|
|
// (`slint::wgpu_29`: `TextureImportError`). They are asserted here,
|
|
// in the crate that owns the descriptor, because failing them does not
|
|
// fail a build or a shader — it fails at runtime, on the frame the
|
|
// image is handed over, and only where there is a screen to hand it
|
|
// to. Nothing else in the test suite would notice.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = grey_image(&ctx, 4000);
|
|
let shader = EditGraph::default_chain().compose();
|
|
let t = pass.render(&img, &shader, 16, 16).expect("render");
|
|
|
|
assert!(
|
|
matches!(
|
|
t.format(),
|
|
wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb
|
|
),
|
|
"import accepts only the two 8-bit RGBA formats, not {:?}",
|
|
t.format()
|
|
);
|
|
assert!(
|
|
t.usage().contains(wgpu::TextureUsages::TEXTURE_BINDING),
|
|
"the compositor has to sample it"
|
|
);
|
|
assert!(
|
|
t.usage().contains(wgpu::TextureUsages::RENDER_ATTACHMENT),
|
|
"Slint requires this even though the adjust pass never uses it"
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-DSP-1 | AC-8
|
|
#[test]
|
|
fn consecutive_frames_are_different_textures() {
|
|
// Not a detail: the compositor is handed this texture rather than a
|
|
// copy of its pixels, and Slint repaints only when the image property
|
|
// *changes*. Two images over one texture compare equal, so writing the
|
|
// same texture every frame would leave a slider moving the pixels on
|
|
// the GPU and nothing at all on screen — the frame would be correct
|
|
// and invisible, which is the worst kind of wrong.
|
|
//
|
|
// No display is needed to catch it, because the equality Slint tests
|
|
// is the equality asserted here.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = grey_image(&ctx, 4000);
|
|
let shader = EditGraph::default_chain().compose();
|
|
|
|
let first = pass.render(&img, &shader, 16, 16).expect("render").clone();
|
|
let second = pass.render(&img, &shader, 16, 16).expect("render").clone();
|
|
assert_ne!(first, second, "the compositor cannot tell these two apart");
|
|
|
|
// And back again, so the alternation is a rotation between two rather
|
|
// than an allocation per frame — which at 4K would be 33 MB a frame.
|
|
let third = pass.render(&img, &shader, 16, 16).expect("render").clone();
|
|
assert_eq!(first, third, "a third texture was allocated");
|
|
}
|
|
|
|
#[test]
|
|
fn the_output_resizes_with_the_viewport() {
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = grey_image(&ctx, 4000);
|
|
let shader = EditGraph::default_chain().compose();
|
|
|
|
let t = pass.render(&img, &shader, 64, 48).expect("render");
|
|
assert_eq!((t.width(), t.height()), (64, 48));
|
|
|
|
let t = pass.render(&img, &shader, 32, 96).expect("render");
|
|
assert_eq!((t.width(), t.height()), (32, 96));
|
|
}
|
|
|
|
/// A flat RGBA8 image on the JPEG path — already gamma-encoded, as a
|
|
/// decoded JPEG is.
|
|
fn jpeg_image(ctx: &GpuContext, rgb: [u8; 3]) -> DemosaicedImage {
|
|
let size = 16u32;
|
|
let mut data = Vec::with_capacity((size * size) as usize * 4);
|
|
for _ in 0..size * size {
|
|
data.extend_from_slice(&[rgb[0], rgb[1], rgb[2], 255]);
|
|
}
|
|
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
|
|
}
|
|
|
|
#[test]
|
|
fn a_jpeg_survives_a_neutral_graph_unchanged() {
|
|
// The property the whole JPEG path rests on: decoding the transfer
|
|
// function on the way in and re-encoding on the way out must be exact
|
|
// inverses. If they are not, merely *opening* a JPEG in develop mode
|
|
// shifts its tones — the file would be altered by being looked at,
|
|
// which is far worse than the panel being disabled.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let shader = EditGraph::default_chain().compose();
|
|
|
|
// Several levels: a transfer-function error is smallest in the
|
|
// mid-tones and largest near the ends, so one sample could miss it.
|
|
for level in [16u8, 64, 128, 200, 240] {
|
|
let img = jpeg_image(&ctx, [level, level, level]);
|
|
let t = pass.render(&img, &shader, 16, 16).expect("render");
|
|
let got = read_centre(&ctx, t);
|
|
for (i, c) in got[..3].iter().enumerate() {
|
|
let delta = (i32::from(*c) - i32::from(level)).abs();
|
|
assert!(
|
|
delta <= 2,
|
|
"channel {i} at level {level} came back {c} (delta {delta}) \
|
|
— the transfer functions are not inverses"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_jpeg_keeps_its_colour_through_a_neutral_graph() {
|
|
// Identity colour matrix and neutral white balance, specifically: a
|
|
// camera matrix applied to an image already in sRGB primaries would
|
|
// skew colour, and this is what catches it. A grey patch cannot —
|
|
// every matrix maps neutral to neutral.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let shader = EditGraph::default_chain().compose();
|
|
|
|
let img = jpeg_image(&ctx, [200, 90, 40]);
|
|
let t = pass.render(&img, &shader, 16, 16).expect("render");
|
|
let got = read_centre(&ctx, t);
|
|
|
|
for (i, expected) in [200u8, 90, 40].iter().enumerate() {
|
|
let delta = (i32::from(got[i]) - i32::from(*expected)).abs();
|
|
assert!(
|
|
delta <= 2,
|
|
"channel {i} expected ~{expected}, got {} — colour is being \
|
|
transformed on a source that needs no transform",
|
|
got[i]
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn exposure_brightens_a_jpeg() {
|
|
// Proves the operations reach the JPEG path at all, and that they act
|
|
// on linearised values: an exposure stop is a multiply, which is only
|
|
// meaningful once the gamma encoding is undone.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = jpeg_image(&ctx, [110, 110, 110]);
|
|
|
|
let neutral = EditGraph::default_chain().compose();
|
|
let before = {
|
|
let t = pass.render(&img, &neutral, 16, 16).expect("render");
|
|
read_centre(&ctx, t)
|
|
};
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
|
let brighter = g.compose();
|
|
let after = {
|
|
let t = pass.render(&img, &brighter, 16, 16).expect("render");
|
|
read_centre(&ctx, t)
|
|
};
|
|
|
|
assert!(
|
|
after[0] > before[0],
|
|
"+1 stop should brighten a JPEG: {before:?} -> {after:?}"
|
|
);
|
|
|
|
// One stop on a linear value is a doubling, which after re-encoding
|
|
// lands near 1.5x the encoded value rather than 2x. Checking the
|
|
// magnitude is what distinguishes "linearised correctly" from
|
|
// "doubled the gamma-encoded value", which would blow straight to
|
|
// white — the exact bug a brightness-only assertion would miss.
|
|
assert!(
|
|
after[0] < 255,
|
|
"a stop from mid-grey must not clip: {} — the encoding was \
|
|
probably not undone before the multiply",
|
|
after[0]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn every_output_colour_space_renders_what_the_colorimetry_predicts() {
|
|
// TRACES: FR-EXP-2
|
|
// The shader carries constants generated from `dr_types::colour`; this
|
|
// recomputes the same conversion on the CPU and demands the GPU agree.
|
|
// A transposed matrix, a transfer function applied before the
|
|
// primaries, or a clip in the wrong place all compile perfectly and
|
|
// simply produce the wrong colour — none of which a "did it compile"
|
|
// test would notice.
|
|
//
|
|
// A saturated patch, deliberately: every one of these spaces maps a
|
|
// neutral to itself, so a grey would agree with all four.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let source = [200u8, 90, 40];
|
|
let img = jpeg_image(&ctx, source);
|
|
|
|
// The JPEG path linearises with the sRGB curve, so this is the value
|
|
// reaching the output stage.
|
|
let linear: Vec<f32> = source
|
|
.iter()
|
|
.map(|&v| dr_types::Transfer::Srgb.decode(f32::from(v) / 255.0))
|
|
.collect();
|
|
|
|
for space in dr_types::ColourSpace::ALL {
|
|
let shader = EditGraph::default_chain().compose_for(space);
|
|
let t = pass.render(&img, &shader, 16, 16).expect("render");
|
|
let got = read_centre(&ctx, t);
|
|
|
|
let m = space.from_linear_srgb();
|
|
for channel in 0..3 {
|
|
let converted = m[channel * 3] * linear[0]
|
|
+ m[channel * 3 + 1] * linear[1]
|
|
+ m[channel * 3 + 2] * linear[2];
|
|
let want = space.transfer().encode(converted.clamp(0.0, 1.0)) * 255.0;
|
|
let delta = (f32::from(got[channel]) - want).abs();
|
|
// Two levels: the pipeline stores its intermediate in f16 and
|
|
// the source itself came from an 8-bit texel, so exactness is
|
|
// not on offer. A wrong matrix is out by tens.
|
|
assert!(
|
|
delta <= 2.0,
|
|
"{space:?} channel {channel}: rendered {} against a predicted {want:.1} \
|
|
(whole pixel {got:?})",
|
|
got[channel]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_wide_gamut_render_differs_from_an_srgb_one() {
|
|
// The companion to the test above, and the one that would fail if the
|
|
// output space were accepted and then ignored: predicted values that
|
|
// happened to match sRGB's would prove nothing. A saturated red is
|
|
// several tens of levels apart in P3.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let img = jpeg_image(&ctx, [230, 30, 20]);
|
|
|
|
let srgb = {
|
|
let shader = EditGraph::default_chain().compose_for(dr_types::ColourSpace::Srgb);
|
|
let t = pass.render(&img, &shader, 16, 16).expect("render");
|
|
read_centre(&ctx, t)
|
|
};
|
|
let p3 = {
|
|
let shader = EditGraph::default_chain().compose_for(dr_types::ColourSpace::DisplayP3);
|
|
let t = pass.render(&img, &shader, 16, 16).expect("render");
|
|
read_centre(&ctx, t)
|
|
};
|
|
|
|
// Less red and more green: the same colour expressed against wider
|
|
// primaries needs smaller numbers to reach it.
|
|
assert!(
|
|
p3[0] < srgb[0] && p3[1] > srgb[1],
|
|
"sRGB rendered {srgb:?} and Display P3 {p3:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_jpeg_and_sensor_data_agree_on_the_same_scene_value() {
|
|
// The two producers must be interchangeable. A mid-grey that is
|
|
// linearly 0.216 (sRGB 128) arriving as sensor data and as a JPEG
|
|
// must render the same, or an edit would mean different things
|
|
// depending on which decoder opened the file.
|
|
let Some(ctx) = ctx() else { return };
|
|
let mut pass = AdjustPass::new(&ctx);
|
|
let shader = EditGraph::default_chain().compose();
|
|
|
|
// sRGB 128 linearises to ~0.2159; against a 16383 white level that is
|
|
// sample ~3537.
|
|
let sensor = grey_image(&ctx, 3537);
|
|
let jpeg = jpeg_image(&ctx, [128, 128, 128]);
|
|
|
|
let from_sensor = {
|
|
let t = pass.render(&sensor, &shader, 16, 16).expect("render");
|
|
read_centre(&ctx, t)
|
|
};
|
|
let from_jpeg = {
|
|
let t = pass.render(&jpeg, &shader, 16, 16).expect("render");
|
|
read_centre(&ctx, t)
|
|
};
|
|
|
|
let delta = (i32::from(from_sensor[0]) - i32::from(from_jpeg[0])).abs();
|
|
assert!(
|
|
delta <= 3,
|
|
"the same scene value rendered {from_sensor:?} from sensor data \
|
|
and {from_jpeg:?} from a JPEG"
|
|
);
|
|
}
|
|
}
|