Merge branch 'histogram'
# Conflicts: # ui/dr-ui/src/lib.rs
This commit is contained in:
@@ -19,6 +19,7 @@ use std::collections::HashMap;
|
|||||||
use dr_pipeline::ComposedShader;
|
use dr_pipeline::ComposedShader;
|
||||||
use wgpu::util::DeviceExt;
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
|
use crate::readback::await_mapping;
|
||||||
use crate::{DemosaicedImage, GpuContext, GpuError};
|
use crate::{DemosaicedImage, GpuContext, GpuError};
|
||||||
|
|
||||||
/// Leading floats the composer reserves before any operation's own uniforms:
|
/// Leading floats the composer reserves before any operation's own uniforms:
|
||||||
@@ -31,18 +32,6 @@ use crate::{DemosaicedImage, GpuContext, GpuError};
|
|||||||
/// reads them.
|
/// reads them.
|
||||||
const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS;
|
const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS;
|
||||||
|
|
||||||
/// How many non-blocking polls a readback gets before it is called failed.
|
|
||||||
///
|
|
||||||
/// A bound rather than a spin forever: if the device is lost the map callback
|
|
||||||
/// never arrives, and an unbounded loop would hang the interface rather than
|
|
||||||
/// surfacing the error. Set far above any plausible completion — the copy this
|
|
||||||
/// waits on is milliseconds — so it is reached only when something is wrong.
|
|
||||||
///
|
|
||||||
/// Ungated along with `export_pixels`: an export reads pixels back in a
|
|
||||||
/// shipping build, and the bound that stops a lost device hanging the app
|
|
||||||
/// applies at least as much there as it does to the display bridge.
|
|
||||||
const READBACK_POLL_LIMIT: u32 = 100_000;
|
|
||||||
|
|
||||||
/// Runs composed operation chains against demosaiced images.
|
/// Runs composed operation chains against demosaiced images.
|
||||||
pub struct AdjustPass {
|
pub struct AdjustPass {
|
||||||
ctx: GpuContext,
|
ctx: GpuContext,
|
||||||
@@ -399,38 +388,10 @@ impl AdjustPass {
|
|||||||
let _ = tx.send(r);
|
let _ = tx.send(r);
|
||||||
});
|
});
|
||||||
|
|
||||||
// **Polled without blocking, then checked.**
|
// Polled rather than parked, and bounded rather than spun forever —
|
||||||
//
|
// see `readback::await_mapping`, which the histogram's own transfer
|
||||||
// `Maintain::Wait` parks the calling thread until the GPU has finished,
|
// shares for exactly the same reasons.
|
||||||
// and this is called from the UI thread — so that park was a frozen
|
await_mapping(&self.ctx, &rx)?;
|
||||||
// interface for the duration of the copy (~7 ms at 4K, per the note
|
|
||||||
// above). `Poll` drives the same callbacks without sleeping, so the
|
|
||||||
// loop below stays interruptible and the mapping still completes.
|
|
||||||
//
|
|
||||||
// The bounded spin matters: a lost device would otherwise never
|
|
||||||
// deliver the callback and this would hang the app instead of
|
|
||||||
// reporting an error.
|
|
||||||
let mut mapped = None;
|
|
||||||
for _ in 0..READBACK_POLL_LIMIT {
|
|
||||||
// A poll error is a lost device, which is exactly the case the
|
|
||||||
// bounded spin exists to escape — returning here reports it
|
|
||||||
// immediately rather than spinning out the full limit first.
|
|
||||||
self.ctx
|
|
||||||
.device
|
|
||||||
.poll(wgpu::PollType::Poll)
|
|
||||||
.map_err(|e| GpuError::Readback(e.to_string()))?;
|
|
||||||
match rx.try_recv() {
|
|
||||||
Ok(r) => {
|
|
||||||
mapped = Some(r);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(std::sync::mpsc::TryRecvError::Empty) => continue,
|
|
||||||
Err(e) => return Err(GpuError::Readback(e.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mapped
|
|
||||||
.ok_or_else(|| GpuError::Readback("readback did not complete".into()))?
|
|
||||||
.map_err(|e| GpuError::Readback(e.to_string()))?;
|
|
||||||
|
|
||||||
let data = slice.get_mapped_range();
|
let data = slice.get_mapped_range();
|
||||||
let mut out = Vec::with_capacity((unpadded * h) as usize);
|
let mut out = Vec::with_capacity((unpadded * h) as usize);
|
||||||
|
|||||||
@@ -0,0 +1,591 @@
|
|||||||
|
//! TRACES: FR-DSP-7
|
||||||
|
//! Counting the display frame, on the device that drew it.
|
||||||
|
//!
|
||||||
|
//! # Why a compute reduction and not a CPU pass over the readback
|
||||||
|
//!
|
||||||
|
//! There is, today, a whole frame already sitting in CPU memory every time the
|
||||||
|
//! canvas updates — `AdjustPass::read_output`, the temporary bridge that spike
|
||||||
|
//! S1 removes. Walking it to build a histogram would have been perhaps thirty
|
||||||
|
//! lines and no shader at all, and it was the obvious thing to reach for.
|
||||||
|
//!
|
||||||
|
//! It was rejected for two reasons, in this order.
|
||||||
|
//!
|
||||||
|
//! FR-DSP-7 states the mechanism, not just the feature: "these derive from a
|
||||||
|
//! GPU-side reduction into a small buffer. Per-frame CPU readback of image data
|
||||||
|
//! is prohibited." A histogram built on the bridge would be correct today and
|
||||||
|
//! *deleted* by S1 — it would be new code whose only foundation is the one
|
||||||
|
//! thing the architecture is committed to removing, and the histogram would
|
||||||
|
//! then be the reason the bridge could not go.
|
||||||
|
//!
|
||||||
|
//! And the cost does not scale the way the shortcut implies. Counting 2 MP on
|
||||||
|
//! one CPU thread is several milliseconds of the settle frame; the reduction
|
||||||
|
//! below is a fraction of one, and what crosses the bus is 4104 bytes
|
||||||
|
//! regardless of the image. The simpler-looking option is simpler only while
|
||||||
|
//! the frame happens to be lying there.
|
||||||
|
//!
|
||||||
|
//! # What it costs and when it runs
|
||||||
|
//!
|
||||||
|
//! One dispatch plus a 4 KB buffer copy and a mapping — a device sync point.
|
||||||
|
//! FR-DSP-7 requires that this not extend the FR-DSP-3 frame budget, so the
|
||||||
|
//! interface runs it on the *settled* frame only, never on the draft frames a
|
||||||
|
//! drag produces. The histogram of an image being dragged past is not read
|
||||||
|
//! anyway; the one that arrives when the slider stops is.
|
||||||
|
|
||||||
|
use wgpu::util::DeviceExt;
|
||||||
|
|
||||||
|
use crate::readback::await_mapping;
|
||||||
|
use crate::{GpuContext, GpuError};
|
||||||
|
|
||||||
|
/// Levels per channel. 256, so a bin *is* an output code value and no
|
||||||
|
/// re-bucketing stands between the count and what the display shows.
|
||||||
|
pub const BINS: usize = 256;
|
||||||
|
|
||||||
|
/// Four channel histograms plus the two clip counters, as the shader lays them
|
||||||
|
/// out. Kept next to the shader's own constants because the two must agree.
|
||||||
|
const CHANNELS: usize = 4;
|
||||||
|
const CLIPPED_HIGH: usize = CHANNELS * BINS;
|
||||||
|
const CLIPPED_LOW: usize = CHANNELS * BINS + 1;
|
||||||
|
const SLOTS: usize = CHANNELS * BINS + 2;
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-7
|
||||||
|
/// A counted frame: how many pixels sit at each output level.
|
||||||
|
///
|
||||||
|
/// Counts, not proportions. Turning these into something drawable — folding
|
||||||
|
/// 256 bins into the columns a 280px panel can show, choosing a peak to scale
|
||||||
|
/// against — is presentation, and belongs to whoever is drawing (ARCH §4.3a).
|
||||||
|
/// What this crate owes is the numbers.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Histogram {
|
||||||
|
red: [u32; BINS],
|
||||||
|
green: [u32; BINS],
|
||||||
|
blue: [u32; BINS],
|
||||||
|
luma: [u32; BINS],
|
||||||
|
clipped_highlights: u32,
|
||||||
|
clipped_shadows: u32,
|
||||||
|
pixels: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Histogram {
|
||||||
|
/// Counts per output level, darkest first.
|
||||||
|
pub fn red(&self) -> &[u32; BINS] {
|
||||||
|
&self.red
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn green(&self) -> &[u32; BINS] {
|
||||||
|
&self.green
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn blue(&self) -> &[u32; BINS] {
|
||||||
|
&self.blue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rec.709 luma of the encoded values — the axis a photographer reads
|
||||||
|
/// exposure off. See the shader for why it is weighted in fixed point.
|
||||||
|
pub fn luma(&self) -> &[u32; BINS] {
|
||||||
|
&self.luma
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pixels with **any** channel at 255, and with any channel at 0.
|
||||||
|
///
|
||||||
|
/// Any rather than all, because a single blown channel is detail that is
|
||||||
|
/// already gone: a red that has hit the ceiling has no gradation left in it
|
||||||
|
/// however much green and blue still hold.
|
||||||
|
pub fn clipped_highlights(&self) -> u32 {
|
||||||
|
self.clipped_highlights
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clipped_shadows(&self) -> u32 {
|
||||||
|
self.clipped_shadows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pixels counted. The denominator for the two figures above.
|
||||||
|
pub fn pixels(&self) -> u32 {
|
||||||
|
self.pixels
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild from the flat slot array the shader writes.
|
||||||
|
///
|
||||||
|
/// `pixels` is summed from the red channel rather than taken from the image
|
||||||
|
/// dimensions: every pixel lands in exactly one red bin, so the sum *is*
|
||||||
|
/// the count, and deriving it that way makes a dropped or double-counted
|
||||||
|
/// texel show up as a wrong denominator instead of hiding.
|
||||||
|
fn from_slots(slots: &[u32]) -> Result<Self, GpuError> {
|
||||||
|
if slots.len() < SLOTS {
|
||||||
|
return Err(GpuError::Readback(format!(
|
||||||
|
"histogram readback was {} slots, expected {SLOTS}",
|
||||||
|
slots.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let channel = |i: usize| -> [u32; BINS] {
|
||||||
|
let mut out = [0u32; BINS];
|
||||||
|
out.copy_from_slice(&slots[i * BINS..(i + 1) * BINS]);
|
||||||
|
out
|
||||||
|
};
|
||||||
|
let red = channel(0);
|
||||||
|
Ok(Self {
|
||||||
|
pixels: red.iter().sum(),
|
||||||
|
red,
|
||||||
|
green: channel(1),
|
||||||
|
blue: channel(2),
|
||||||
|
luma: channel(3),
|
||||||
|
clipped_highlights: slots[CLIPPED_HIGH],
|
||||||
|
clipped_shadows: slots[CLIPPED_LOW],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dispatch's view of the frame. Padded to 16 bytes for std140.
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
|
struct Dims {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
pad_0: u32,
|
||||||
|
pad_1: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-7
|
||||||
|
/// Counts a rendered frame into [`Histogram`].
|
||||||
|
///
|
||||||
|
/// Holds its buffers for the life of the session. They are a fixed 4104 bytes
|
||||||
|
/// whatever the image size — the one property that makes this affordable — so
|
||||||
|
/// there is nothing to reallocate when the viewport changes, unlike the display
|
||||||
|
/// target beside it.
|
||||||
|
pub struct HistogramPass {
|
||||||
|
ctx: GpuContext,
|
||||||
|
pipeline: wgpu::ComputePipeline,
|
||||||
|
bind_group_layout: wgpu::BindGroupLayout,
|
||||||
|
/// Where the shader accumulates. Cleared before each dispatch.
|
||||||
|
bins: wgpu::Buffer,
|
||||||
|
/// Mappable destination; a storage buffer cannot also be `MAP_READ`.
|
||||||
|
staging: wgpu::Buffer,
|
||||||
|
dims: wgpu::Buffer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HistogramPass {
|
||||||
|
pub fn new(ctx: &GpuContext) -> Result<Self, GpuError> {
|
||||||
|
// A validation error here is a bug in the shader beside this file, not
|
||||||
|
// anything a user did — surfaced as a `Result` rather than left to
|
||||||
|
// wgpu's default handler, which panics.
|
||||||
|
let scope = ctx.device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||||
|
|
||||||
|
let module = ctx
|
||||||
|
.device
|
||||||
|
.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("histogram"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/histogram.wgsl").into()),
|
||||||
|
});
|
||||||
|
|
||||||
|
let bind_group_layout =
|
||||||
|
ctx.device
|
||||||
|
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("histogram-bgl"),
|
||||||
|
entries: &[
|
||||||
|
// The rendered frame, sampled with `textureLoad` — the
|
||||||
|
// same texture the compositor shows, so what is counted
|
||||||
|
// is what is on screen.
|
||||||
|
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::Storage { read_only: false },
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 2,
|
||||||
|
visibility: wgpu::ShaderStages::COMPUTE,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Uniform,
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let layout = ctx
|
||||||
|
.device
|
||||||
|
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
label: Some("histogram-layout"),
|
||||||
|
bind_group_layouts: &[Some(&bind_group_layout)],
|
||||||
|
immediate_size: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let pipeline = ctx
|
||||||
|
.device
|
||||||
|
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||||
|
label: Some("histogram-pipeline"),
|
||||||
|
layout: Some(&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(err.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = (SLOTS * std::mem::size_of::<u32>()) as u64;
|
||||||
|
let bins = ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("histogram-bins"),
|
||||||
|
size: bytes,
|
||||||
|
usage: wgpu::BufferUsages::STORAGE
|
||||||
|
| wgpu::BufferUsages::COPY_SRC
|
||||||
|
| wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
let staging = ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("histogram-staging"),
|
||||||
|
size: bytes,
|
||||||
|
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
let dims = ctx
|
||||||
|
.device
|
||||||
|
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("histogram-dims"),
|
||||||
|
contents: bytemuck::bytes_of(&Dims {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
pad_0: 0,
|
||||||
|
pad_1: 0,
|
||||||
|
}),
|
||||||
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
ctx: ctx.clone(),
|
||||||
|
pipeline,
|
||||||
|
bind_group_layout,
|
||||||
|
bins,
|
||||||
|
staging,
|
||||||
|
dims,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-7
|
||||||
|
/// Count one rendered frame.
|
||||||
|
///
|
||||||
|
/// `texture` must carry `TEXTURE_BINDING`, which `AdjustPass`'s output
|
||||||
|
/// does because the compositor samples it.
|
||||||
|
pub fn compute(&self, texture: &wgpu::Texture) -> Result<Histogram, GpuError> {
|
||||||
|
let (width, height) = (texture.width(), texture.height());
|
||||||
|
self.ctx.queue.write_buffer(
|
||||||
|
&self.dims,
|
||||||
|
0,
|
||||||
|
bytemuck::bytes_of(&Dims {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
pad_0: 0,
|
||||||
|
pad_1: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let view = texture.create_view(&Default::default());
|
||||||
|
let bind_group = self
|
||||||
|
.ctx
|
||||||
|
.device
|
||||||
|
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("histogram-bg"),
|
||||||
|
layout: &self.bind_group_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: self.bins.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: self.dims.as_entire_binding(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut enc = self
|
||||||
|
.ctx
|
||||||
|
.device
|
||||||
|
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("histogram-encoder"),
|
||||||
|
});
|
||||||
|
// The accumulator is reused between frames, so it carries the previous
|
||||||
|
// frame's counts until this line. Forgetting it does not fail — it
|
||||||
|
// quietly integrates every frame since the image opened, which looks
|
||||||
|
// like a histogram that will not respond to the exposure slider.
|
||||||
|
enc.clear_buffer(&self.bins, 0, None);
|
||||||
|
{
|
||||||
|
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||||
|
label: Some("histogram-pass"),
|
||||||
|
timestamp_writes: None,
|
||||||
|
});
|
||||||
|
pass.set_pipeline(&self.pipeline);
|
||||||
|
pass.set_bind_group(0, &bind_group, &[]);
|
||||||
|
pass.dispatch_workgroups(width.div_ceil(16), height.div_ceil(16), 1);
|
||||||
|
}
|
||||||
|
enc.copy_buffer_to_buffer(&self.bins, 0, &self.staging, 0, self.staging.size());
|
||||||
|
self.ctx.queue.submit(Some(enc.finish()));
|
||||||
|
|
||||||
|
let slice = self.staging.slice(..);
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||||||
|
let _ = tx.send(r);
|
||||||
|
});
|
||||||
|
await_mapping(&self.ctx, &rx)?;
|
||||||
|
|
||||||
|
let data = slice.get_mapped_range();
|
||||||
|
let slots: Vec<u32> = bytemuck::cast_slice::<u8, u32>(&data).to_vec();
|
||||||
|
drop(data);
|
||||||
|
self.staging.unmap();
|
||||||
|
|
||||||
|
Histogram::from_slots(&slots)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn ctx() -> Option<GpuContext> {
|
||||||
|
match pollster::block_on(GpuContext::new_headless()) {
|
||||||
|
Ok(c) => Some(c),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("skipping: no GPU adapter ({e})");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload `rgba` as a texture the pass can read, the way `AdjustPass`
|
||||||
|
/// hands its output over.
|
||||||
|
fn texture(ctx: &GpuContext, rgba: &[u8], width: u32, height: u32) -> wgpu::Texture {
|
||||||
|
let tex = ctx.device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("histogram-test-source"),
|
||||||
|
size: wgpu::Extent3d {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||||
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
ctx.queue.write_texture(
|
||||||
|
wgpu::TexelCopyTextureInfo {
|
||||||
|
texture: &tex,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: wgpu::Origin3d::ZERO,
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
},
|
||||||
|
rgba,
|
||||||
|
wgpu::TexelCopyBufferLayout {
|
||||||
|
offset: 0,
|
||||||
|
bytes_per_row: Some(width * 4),
|
||||||
|
rows_per_image: Some(height),
|
||||||
|
},
|
||||||
|
wgpu::Extent3d {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ctx.queue.submit(std::iter::empty());
|
||||||
|
tex
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reference the shader is checked against: the same bucketing, written
|
||||||
|
/// the obvious way on the CPU.
|
||||||
|
///
|
||||||
|
/// Deliberately a *second* implementation rather than shared code. The bugs
|
||||||
|
/// this is here to catch — a workgroup tile that is never merged, an edge
|
||||||
|
/// tile counted twice, a luma weighting that carries past 255 — are all
|
||||||
|
/// bugs a shared implementation would commit identically on both sides and
|
||||||
|
/// so could not detect.
|
||||||
|
fn expected(rgba: &[u8]) -> Histogram {
|
||||||
|
let mut slots = vec![0u32; SLOTS];
|
||||||
|
for px in rgba.chunks_exact(4) {
|
||||||
|
let (r, g, b) = (px[0] as usize, px[1] as usize, px[2] as usize);
|
||||||
|
let y = (54 * r + 183 * g + 19 * b) >> 8;
|
||||||
|
slots[r] += 1;
|
||||||
|
slots[BINS + g] += 1;
|
||||||
|
slots[2 * BINS + b] += 1;
|
||||||
|
slots[3 * BINS + y] += 1;
|
||||||
|
if px[0] == 255 || px[1] == 255 || px[2] == 255 {
|
||||||
|
slots[CLIPPED_HIGH] += 1;
|
||||||
|
}
|
||||||
|
if px[0] == 0 || px[1] == 0 || px[2] == 0 {
|
||||||
|
slots[CLIPPED_LOW] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Histogram::from_slots(&slots).expect("slot count")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_flat_frame_puts_every_pixel_in_one_bin() {
|
||||||
|
// The arithmetic at its most checkable: 64x64 pixels of one value must
|
||||||
|
// produce exactly 4096 in exactly one bin and nothing anywhere else.
|
||||||
|
// A tile that failed to merge, or merged twice, changes this number —
|
||||||
|
// and a histogram that is merely "roughly right" is a histogram nobody
|
||||||
|
// can set a black point from.
|
||||||
|
let Some(ctx) = ctx() else { return };
|
||||||
|
let pass = HistogramPass::new(&ctx).expect("pass");
|
||||||
|
|
||||||
|
let rgba: Vec<u8> = std::iter::repeat_n([90u8, 140, 200, 255], 64 * 64)
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
let tex = texture(&ctx, &rgba, 64, 64);
|
||||||
|
let hist = pass.compute(&tex).expect("compute");
|
||||||
|
|
||||||
|
assert_eq!(hist.pixels(), 4096);
|
||||||
|
assert_eq!(hist.red()[90], 4096);
|
||||||
|
assert_eq!(hist.green()[140], 4096);
|
||||||
|
assert_eq!(hist.blue()[200], 4096);
|
||||||
|
assert_eq!(
|
||||||
|
hist.red().iter().filter(|c| **c > 0).count(),
|
||||||
|
1,
|
||||||
|
"one value can only occupy one bin"
|
||||||
|
);
|
||||||
|
// (54*90 + 183*140 + 19*200) >> 8 = 34280 >> 8 = 133.
|
||||||
|
assert_eq!(hist.luma()[133], 4096);
|
||||||
|
assert_eq!(hist.clipped_highlights(), 0);
|
||||||
|
assert_eq!(hist.clipped_shadows(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_level_is_reachable_and_lands_where_it_belongs() {
|
||||||
|
// A ramp covering all 256 codes, four pixels each. This is the test
|
||||||
|
// that would catch an off-by-one in the quantisation — a `floor` where
|
||||||
|
// a rounding was needed shifts the whole ramp down one bin and leaves
|
||||||
|
// 255 empty, which on a real photograph looks like nothing at all.
|
||||||
|
let Some(ctx) = ctx() else { return };
|
||||||
|
let pass = HistogramPass::new(&ctx).expect("pass");
|
||||||
|
|
||||||
|
let (w, h) = (256u32, 4u32);
|
||||||
|
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
|
||||||
|
for _ in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let v = x as u8;
|
||||||
|
rgba.extend_from_slice(&[v, v, v, 255]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tex = texture(&ctx, &rgba, w, h);
|
||||||
|
let hist = pass.compute(&tex).expect("compute");
|
||||||
|
|
||||||
|
assert_eq!(hist.pixels(), w * h);
|
||||||
|
for level in 0..BINS {
|
||||||
|
assert_eq!(
|
||||||
|
hist.red()[level],
|
||||||
|
h,
|
||||||
|
"level {level} should hold exactly {h} pixels"
|
||||||
|
);
|
||||||
|
// Neutral, so luma must land on the same bin as the channels do.
|
||||||
|
assert_eq!(hist.luma()[level], h, "luma drifted at level {level}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_shader_agrees_with_a_cpu_count_of_the_same_frame() {
|
||||||
|
// The cross-check, on a frame with no structure for a wrong dispatch to
|
||||||
|
// hide behind: a size that is not a multiple of the 16x16 workgroup, so
|
||||||
|
// the edge tiles run off the image, and pseudo-random content so every
|
||||||
|
// bin is occupied unevenly. Exact equality — the reduction is integer
|
||||||
|
// throughout precisely so this can be an `assert_eq`, not a tolerance.
|
||||||
|
let Some(ctx) = ctx() else { return };
|
||||||
|
let pass = HistogramPass::new(&ctx).expect("pass");
|
||||||
|
|
||||||
|
let (w, h) = (101u32, 37u32);
|
||||||
|
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
|
||||||
|
let mut state = 0x2545_F491_4F6C_DD1Du64;
|
||||||
|
for _ in 0..w * h {
|
||||||
|
for _ in 0..3 {
|
||||||
|
// xorshift64*, so the frame is identical on every machine and a
|
||||||
|
// failure can be reproduced rather than merely observed.
|
||||||
|
state ^= state >> 12;
|
||||||
|
state ^= state << 25;
|
||||||
|
state ^= state >> 27;
|
||||||
|
rgba.push((state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 56) as u8);
|
||||||
|
}
|
||||||
|
rgba.push(255);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tex = texture(&ctx, &rgba, w, h);
|
||||||
|
let hist = pass.compute(&tex).expect("compute");
|
||||||
|
|
||||||
|
assert_eq!(hist.pixels(), w * h, "an edge tile was dropped or doubled");
|
||||||
|
assert_eq!(hist, expected(&rgba));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clipping_is_counted_per_pixel_and_not_per_channel() {
|
||||||
|
// The distinction the indicator rests on. A pixel with two channels at
|
||||||
|
// the ceiling is *one* clipped pixel; counting channels would report
|
||||||
|
// 200% of a frame clipped, and a percentage that can exceed 100 is a
|
||||||
|
// readout nobody will trust again.
|
||||||
|
let Some(ctx) = ctx() else { return };
|
||||||
|
let pass = HistogramPass::new(&ctx).expect("pass");
|
||||||
|
|
||||||
|
let rgba: Vec<u8> = [
|
||||||
|
// Two channels blown, one pixel clipped.
|
||||||
|
[255u8, 255, 10, 255],
|
||||||
|
// One channel blown — still clipped, which is the point of "any".
|
||||||
|
[255, 10, 10, 255],
|
||||||
|
// Clean.
|
||||||
|
[10, 10, 10, 255],
|
||||||
|
// Black in one channel only: a clipped shadow.
|
||||||
|
[0, 10, 10, 255],
|
||||||
|
]
|
||||||
|
.concat();
|
||||||
|
let tex = texture(&ctx, &rgba, 4, 1);
|
||||||
|
let hist = pass.compute(&tex).expect("compute");
|
||||||
|
|
||||||
|
assert_eq!(hist.pixels(), 4);
|
||||||
|
assert_eq!(hist.clipped_highlights(), 2);
|
||||||
|
assert_eq!(hist.clipped_shadows(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_second_frame_replaces_the_first_rather_than_adding_to_it() {
|
||||||
|
// The accumulator is reused, so a missing clear integrates every frame
|
||||||
|
// since the session opened. The symptom is subtle and awful: the
|
||||||
|
// histogram keeps its shape and simply stops responding to the sliders,
|
||||||
|
// because each frame's contribution shrinks against the running total.
|
||||||
|
let Some(ctx) = ctx() else { return };
|
||||||
|
let pass = HistogramPass::new(&ctx).expect("pass");
|
||||||
|
|
||||||
|
let dark: Vec<u8> = std::iter::repeat_n([40u8, 40, 40, 255], 16 * 16)
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
let bright: Vec<u8> = std::iter::repeat_n([210u8, 210, 210, 255], 16 * 16)
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let first = pass.compute(&texture(&ctx, &dark, 16, 16)).expect("first");
|
||||||
|
assert_eq!(first.red()[40], 256);
|
||||||
|
|
||||||
|
let second = pass
|
||||||
|
.compute(&texture(&ctx, &bright, 16, 16))
|
||||||
|
.expect("second");
|
||||||
|
assert_eq!(second.pixels(), 256, "the previous frame was still counted");
|
||||||
|
assert_eq!(second.red()[40], 0);
|
||||||
|
assert_eq!(second.red()[210], 256);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,10 +15,15 @@ mod adjust;
|
|||||||
mod demosaic;
|
mod demosaic;
|
||||||
mod error;
|
mod error;
|
||||||
pub mod hierarchy;
|
pub mod hierarchy;
|
||||||
|
mod histogram;
|
||||||
|
mod readback;
|
||||||
mod segment;
|
mod segment;
|
||||||
pub use adjust::AdjustPass;
|
pub use adjust::AdjustPass;
|
||||||
pub use demosaic::{DemosaicedImage, Demosaicer};
|
pub use demosaic::{DemosaicedImage, Demosaicer};
|
||||||
pub use error::GpuError;
|
pub use error::GpuError;
|
||||||
|
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
|
||||||
|
// at all at a crate root shared with demosaic and segmentation.
|
||||||
|
pub use histogram::{Histogram, HistogramPass, BINS as HISTOGRAM_BINS};
|
||||||
pub use segment::{SegmentOptions, SegmentPass, Segmentation};
|
pub use segment::{SegmentOptions, SegmentPass, Segmentation};
|
||||||
|
|
||||||
/// Owns the wgpu device and queue.
|
/// Owns the wgpu device and queue.
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
//! Waiting for a buffer mapping without parking the interface.
|
||||||
|
//!
|
||||||
|
//! Shared by every transfer off the device — the display bridge, an export, and
|
||||||
|
//! the histogram's 4 KB of bin counts. It was written once inside `AdjustPass`
|
||||||
|
//! and the reasoning below is the whole of why it is shaped this way; copying
|
||||||
|
//! thirty lines of that reasoning into a second caller would have left two
|
||||||
|
//! copies to keep true of each other.
|
||||||
|
|
||||||
|
use crate::{GpuContext, GpuError};
|
||||||
|
|
||||||
|
/// How many non-blocking polls a readback gets before it is called failed.
|
||||||
|
///
|
||||||
|
/// A bound rather than a spin forever: if the device is lost the map callback
|
||||||
|
/// never arrives, and an unbounded loop would hang the interface rather than
|
||||||
|
/// surfacing the error. Set far above any plausible completion — the copies
|
||||||
|
/// this waits on are milliseconds — so it is reached only when something is
|
||||||
|
/// wrong.
|
||||||
|
const POLL_LIMIT: u32 = 100_000;
|
||||||
|
|
||||||
|
/// Drive the device until a `map_async` callback lands.
|
||||||
|
///
|
||||||
|
/// **Polled without blocking, then checked.**
|
||||||
|
///
|
||||||
|
/// `PollType::Wait` parks the calling thread until the GPU has finished, and
|
||||||
|
/// these transfers are called from the UI thread — so that park was a frozen
|
||||||
|
/// interface for the duration of the copy (~7 ms at 4K for a whole frame).
|
||||||
|
/// `Poll` drives the same callbacks without sleeping, so the loop below stays
|
||||||
|
/// interruptible and the mapping still completes.
|
||||||
|
///
|
||||||
|
/// The bounded spin matters: a lost device would otherwise never deliver the
|
||||||
|
/// callback and this would hang the app instead of reporting an error.
|
||||||
|
pub(crate) fn await_mapping(
|
||||||
|
ctx: &GpuContext,
|
||||||
|
rx: &std::sync::mpsc::Receiver<Result<(), wgpu::BufferAsyncError>>,
|
||||||
|
) -> Result<(), GpuError> {
|
||||||
|
let mut mapped = None;
|
||||||
|
for _ in 0..POLL_LIMIT {
|
||||||
|
// A poll error is a lost device, which is exactly the case the bounded
|
||||||
|
// spin exists to escape — returning here reports it immediately rather
|
||||||
|
// than spinning out the full limit first.
|
||||||
|
ctx.device
|
||||||
|
.poll(wgpu::PollType::Poll)
|
||||||
|
.map_err(|e| GpuError::Readback(e.to_string()))?;
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(r) => {
|
||||||
|
mapped = Some(r);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(std::sync::mpsc::TryRecvError::Empty) => continue,
|
||||||
|
Err(e) => return Err(GpuError::Readback(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mapped
|
||||||
|
.ok_or_else(|| GpuError::Readback("readback did not complete".into()))?
|
||||||
|
.map_err(|e| GpuError::Readback(e.to_string()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// TRACES: FR-DSP-7
|
||||||
|
// A reduction of the display frame into bin counts, run where the pixels are.
|
||||||
|
//
|
||||||
|
// FR-DSP-7 does not merely permit this shape, it names it: "these derive from a
|
||||||
|
// GPU-side reduction into a small buffer", because the alternative — dragging
|
||||||
|
// the frame back across the bus to count it — is the per-frame round-trip
|
||||||
|
// ARCH §6.1 forbids and the bottleneck darktable documents. What leaves the
|
||||||
|
// device here is 4104 bytes whatever the image size.
|
||||||
|
//
|
||||||
|
// **Counted per workgroup first, then merged.** A photograph is not noise: a
|
||||||
|
// clear sky puts tens of thousands of adjacent pixels in one bin, and having
|
||||||
|
// every invocation contend for that single global atomic serialises the whole
|
||||||
|
// dispatch. Each workgroup therefore tallies its own 256 pixels into workgroup
|
||||||
|
// memory — where the atomic is cheap and the contention is between 256 threads
|
||||||
|
// rather than two million — and contributes one add per non-empty bin at the
|
||||||
|
// end. Four kilobytes of workgroup storage against a 16 KB floor.
|
||||||
|
|
||||||
|
const BINS: u32 = 256u;
|
||||||
|
// Two counters past the four channels: how many pixels clip at each end. They
|
||||||
|
// live in the same buffer because they are gathered from the same texel read
|
||||||
|
// and would otherwise need a second reduction to answer a question the first
|
||||||
|
// one already had the data for.
|
||||||
|
const CLIPPED_HIGH: u32 = 4u * BINS;
|
||||||
|
const CLIPPED_LOW: u32 = 4u * BINS + 1u;
|
||||||
|
const SLOTS: u32 = 4u * BINS + 2u;
|
||||||
|
// 16x16. Stated as a constant because the clear and merge loops below stride by
|
||||||
|
// it, and a workgroup size that disagreed would leave slots uncleared.
|
||||||
|
const THREADS: u32 = 256u;
|
||||||
|
|
||||||
|
struct Dims {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
// std140 rounds a uniform block up to 16 bytes; named rather than left
|
||||||
|
// implicit so the Rust side's padding is visibly the same shape.
|
||||||
|
pad_0: u32,
|
||||||
|
pad_1: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||||
|
@group(0) @binding(1) var<storage, read_write> bins: array<atomic<u32>>;
|
||||||
|
@group(0) @binding(2) var<uniform> dims: Dims;
|
||||||
|
|
||||||
|
var<workgroup> tile: array<atomic<u32>, SLOTS>;
|
||||||
|
|
||||||
|
/// The 8-bit level a sampled texel came from.
|
||||||
|
///
|
||||||
|
/// The source is `Rgba8Unorm`, so the sampler hands back exactly n/255 and this
|
||||||
|
/// recovers n. `floor(x + 0.5)` rather than `round`, which ties to even in WGSL
|
||||||
|
/// and away from zero in Rust — a difference invisible except on an exact tie,
|
||||||
|
/// which is precisely the kind of disagreement that makes a cross-check against
|
||||||
|
/// a CPU reference fail once in a thousand runs and look like flakiness.
|
||||||
|
fn level(v: f32) -> u32 {
|
||||||
|
return u32(clamp(floor(v * 255.0 + 0.5), 0.0, 255.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@compute @workgroup_size(16, 16, 1)
|
||||||
|
fn main(
|
||||||
|
@builtin(global_invocation_id) gid: vec3<u32>,
|
||||||
|
@builtin(local_invocation_index) lid: u32,
|
||||||
|
) {
|
||||||
|
for (var i = lid; i < SLOTS; i = i + THREADS) {
|
||||||
|
atomicStore(&tile[i], 0u);
|
||||||
|
}
|
||||||
|
workgroupBarrier();
|
||||||
|
|
||||||
|
// Guarded rather than dispatched exactly: the workgroup is 16x16 and an
|
||||||
|
// image is not, so the last row and column of workgroups run off the edge.
|
||||||
|
if (gid.x < dims.width && gid.y < dims.height) {
|
||||||
|
let texel = textureLoad(source, vec2<i32>(i32(gid.x), i32(gid.y)), 0);
|
||||||
|
let r = level(texel.r);
|
||||||
|
let g = level(texel.g);
|
||||||
|
let b = level(texel.b);
|
||||||
|
|
||||||
|
// Rec.709 luma in 8.8 fixed point. 54 + 183 + 19 is exactly 256, so the
|
||||||
|
// weights sum to unity and the shift can never carry past 255.
|
||||||
|
//
|
||||||
|
// Integer rather than float on purpose (ARCH §6.13): a float weighting
|
||||||
|
// is reproducible only to within the vendor's rounding, and the whole
|
||||||
|
// value of the CPU cross-check in the tests is that it is exact.
|
||||||
|
//
|
||||||
|
// Weighted on the *encoded* values, not on linear light. That is what
|
||||||
|
// every histogram a photographer has read is: the axis is the output
|
||||||
|
// level, so a mid-grey has to sit in the middle of it.
|
||||||
|
let y = (54u * r + 183u * g + 19u * b) >> 8u;
|
||||||
|
|
||||||
|
atomicAdd(&tile[r], 1u);
|
||||||
|
atomicAdd(&tile[BINS + g], 1u);
|
||||||
|
atomicAdd(&tile[2u * BINS + b], 1u);
|
||||||
|
atomicAdd(&tile[3u * BINS + y], 1u);
|
||||||
|
|
||||||
|
// Any channel, not all three: a blown red channel is detail that is
|
||||||
|
// gone, whatever green and blue still hold. Counting only neutral white
|
||||||
|
// would stay silent on exactly the saturated highlight — a sunset, a
|
||||||
|
// red jersey — that clips first and recovers worst.
|
||||||
|
if (r == 255u || g == 255u || b == 255u) {
|
||||||
|
atomicAdd(&tile[CLIPPED_HIGH], 1u);
|
||||||
|
}
|
||||||
|
if (r == 0u || g == 0u || b == 0u) {
|
||||||
|
atomicAdd(&tile[CLIPPED_LOW], 1u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
workgroupBarrier();
|
||||||
|
|
||||||
|
for (var i = lid; i < SLOTS; i = i + THREADS) {
|
||||||
|
let count = atomicLoad(&tile[i]);
|
||||||
|
// Most bins of most workgroups are empty — a 16x16 tile can touch 256
|
||||||
|
// of 1026 slots at the very most, and usually far fewer.
|
||||||
|
if (count != 0u) {
|
||||||
|
atomicAdd(&bins[i], count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
-1
@@ -10,7 +10,7 @@
|
|||||||
//! new operation appears in the panel with no change here (FR-DEV-3c).
|
//! new operation appears in the panel with no change here (FR-DEV-3c).
|
||||||
|
|
||||||
use dr_decode::RawImage;
|
use dr_decode::RawImage;
|
||||||
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
|
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, Histogram, HistogramPass};
|
||||||
use dr_pipeline::ops::curve;
|
use dr_pipeline::ops::curve;
|
||||||
use dr_pipeline::{
|
use dr_pipeline::{
|
||||||
CropRect, Edit, EditGraph, History, OpCapability, OpId, ParamId, ParamKind, Presentation,
|
CropRect, Edit, EditGraph, History, OpCapability, OpId, ParamId, ParamKind, Presentation,
|
||||||
@@ -34,6 +34,12 @@ pub struct DevelopSession {
|
|||||||
history: History,
|
history: History,
|
||||||
demosaiced: DemosaicedImage,
|
demosaiced: DemosaicedImage,
|
||||||
adjust: AdjustPass,
|
adjust: AdjustPass,
|
||||||
|
/// TRACES: FR-DSP-7
|
||||||
|
/// Optional, because a session that cannot count its frames is still a
|
||||||
|
/// session that can develop them. If the reduction fails to build — an
|
||||||
|
/// old driver, a device without the storage-buffer atomics it needs — the
|
||||||
|
/// photographer loses the histogram and keeps the photograph.
|
||||||
|
histogram: Option<HistogramPass>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DevelopSession {
|
impl DevelopSession {
|
||||||
@@ -88,6 +94,9 @@ impl DevelopSession {
|
|||||||
history,
|
history,
|
||||||
demosaiced,
|
demosaiced,
|
||||||
adjust: AdjustPass::new(ctx),
|
adjust: AdjustPass::new(ctx),
|
||||||
|
histogram: HistogramPass::new(ctx)
|
||||||
|
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
|
||||||
|
.ok(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,6 +560,34 @@ impl DevelopSession {
|
|||||||
Ok(slint::Image::from_rgba8(buffer))
|
Ok(slint::Image::from_rgba8(buffer))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-7
|
||||||
|
/// Count the frame that is currently on the canvas.
|
||||||
|
///
|
||||||
|
/// **Reads the frame [`Self::render`] last produced rather than rendering
|
||||||
|
/// its own.** The histogram has to describe what the photographer is
|
||||||
|
/// looking at, and rendering a second time to count it would both cost a
|
||||||
|
/// second pass and open the possibility of the two disagreeing.
|
||||||
|
///
|
||||||
|
/// That the frame is the *displayed* one has two consequences worth being
|
||||||
|
/// explicit about. It is in the output colour space, which is what
|
||||||
|
/// FR-DSP-7 asks for — the levels counted are the levels the display will
|
||||||
|
/// show, so a clipped bin means a highlight that is actually gone rather
|
||||||
|
/// than one the transform might still recover. And when the view is zoomed
|
||||||
|
/// or cropped it describes the visible region, not the whole file: a
|
||||||
|
/// photographer inspecting a highlight at 4× is asking about *that*
|
||||||
|
/// highlight, and a histogram of the parts of the frame off screen would
|
||||||
|
/// be answering a question nobody asked.
|
||||||
|
///
|
||||||
|
/// `None` where nothing has been rendered yet, or where the device could
|
||||||
|
/// not build the reduction.
|
||||||
|
pub fn histogram(&self) -> Option<Histogram> {
|
||||||
|
let pass = self.histogram.as_ref()?;
|
||||||
|
let frame = self.adjust.output()?;
|
||||||
|
pass.compute(frame)
|
||||||
|
.inspect_err(|e| log::warn!("histogram failed: {e}"))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// Render the *whole* frame for the crop overlay to be drawn over.
|
/// Render the *whole* frame for the crop overlay to be drawn over.
|
||||||
///
|
///
|
||||||
/// Crop mode cannot use [`Self::render`]: that applies the crop, so the
|
/// Crop mode cannot use [`Self::render`]: that applies the crop, so the
|
||||||
@@ -1944,6 +1981,87 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A frame black on the left half and white on the right, at `size`
|
||||||
|
/// square. Both ends of the histogram are occupied and both clipping
|
||||||
|
/// counters are non-zero, and cropping to one half leaves exactly one of
|
||||||
|
/// them so.
|
||||||
|
fn split_frame(size: u32) -> Vec<u8> {
|
||||||
|
let mut rgba = Vec::with_capacity((size * size * 4) as usize);
|
||||||
|
for _ in 0..size {
|
||||||
|
for x in 0..size {
|
||||||
|
let v = if x < size / 2 { 0u8 } else { 255 };
|
||||||
|
rgba.extend_from_slice(&[v, v, v, 255]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rgba
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-7
|
||||||
|
#[test]
|
||||||
|
fn the_histogram_counts_the_frame_that_is_actually_on_the_canvas() {
|
||||||
|
// The wiring, end to end and against exact numbers: a 64x64 frame that
|
||||||
|
// is half black and half white must come back as 2048 pixels at level
|
||||||
|
// 0, 2048 at 255, and both clipping counters at 2048.
|
||||||
|
//
|
||||||
|
// Asserted at the session rather than at the pass because the mistake
|
||||||
|
// this catches is not arithmetic — `dr_gpu` has its own tests for that
|
||||||
|
// — it is counting the *wrong texture*. Reading a stale target, or the
|
||||||
|
// demosaiced source instead of the adjusted output, produces a
|
||||||
|
// perfectly well-formed histogram of an image the photographer is not
|
||||||
|
// looking at, which is the one failure mode that cannot be seen.
|
||||||
|
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||||||
|
log::warn!("no GPU adapter; skipping");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let rgba = split_frame(64);
|
||||||
|
let mut session =
|
||||||
|
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||||||
|
.expect("session");
|
||||||
|
session.render(64, 64).expect("render");
|
||||||
|
|
||||||
|
let hist = session.histogram().expect("a rendered session must count");
|
||||||
|
assert_eq!(hist.pixels(), 64 * 64);
|
||||||
|
assert_eq!(hist.red()[0], 2048, "the black half");
|
||||||
|
assert_eq!(hist.red()[255], 2048, "the white half");
|
||||||
|
assert_eq!(hist.clipped_shadows(), 2048);
|
||||||
|
assert_eq!(hist.clipped_highlights(), 2048);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-7
|
||||||
|
#[test]
|
||||||
|
fn the_histogram_follows_the_edit_rather_than_the_file() {
|
||||||
|
// The property that makes it *live*. A histogram computed once from the
|
||||||
|
// source would pass the test above and be useless — the whole reason
|
||||||
|
// FR-DSP-7 exists is to show what an adjustment is doing, so cropping
|
||||||
|
// away the white half must leave a histogram with no white in it and
|
||||||
|
// no highlight clipping to report.
|
||||||
|
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||||||
|
log::warn!("no GPU adapter; skipping");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let rgba = split_frame(64);
|
||||||
|
let mut session =
|
||||||
|
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||||||
|
.expect("session");
|
||||||
|
|
||||||
|
session.set_crop(CropRect {
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
width: 0.5,
|
||||||
|
height: 1.0,
|
||||||
|
});
|
||||||
|
session.render(64, 64).expect("render");
|
||||||
|
|
||||||
|
let hist = session.histogram().expect("histogram");
|
||||||
|
assert_eq!(hist.pixels(), 32 * 64, "the crop halved the frame");
|
||||||
|
assert_eq!(hist.red()[0], 32 * 64);
|
||||||
|
assert_eq!(hist.red()[255], 0, "the white half was cropped away");
|
||||||
|
assert_eq!(hist.clipped_highlights(), 0);
|
||||||
|
assert_eq!(hist.clipped_shadows(), 32 * 64);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn routing_indices_map_back_to_the_right_parameter() {
|
fn routing_indices_map_back_to_the_right_parameter() {
|
||||||
// A wrong index would silently move the wrong slider's value, which
|
// A wrong index would silently move the wrong slider's value, which
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
//! TRACES: FR-DSP-7
|
||||||
|
//! Turning bin counts into something a 280-pixel column can be read from.
|
||||||
|
//!
|
||||||
|
//! `dr_gpu` counts; this decides what the counting looks like. The split is
|
||||||
|
//! ARCH §4.3a's: how many columns a panel can show, which peak to scale
|
||||||
|
//! against and when a handful of specular pixels is worth an alarm are all
|
||||||
|
//! questions about *this* interface, and none of them belong beside the shader.
|
||||||
|
//!
|
||||||
|
//! Free-standing functions over plain numbers, deliberately. Every judgement
|
||||||
|
//! here is one that shows up as a wrong picture rather than as a crash — a
|
||||||
|
//! histogram flattened by a black-point spike, a clipping figure that reads
|
||||||
|
//! 0.0% when a quarter of the sky is gone — and none of them can be checked by
|
||||||
|
//! looking at a running application, because a plausible wrong shape and the
|
||||||
|
//! right shape look equally plausible. So they are all reachable without a GPU,
|
||||||
|
//! a window or a photograph.
|
||||||
|
|
||||||
|
use dr_gpu::{Histogram, HISTOGRAM_BINS};
|
||||||
|
|
||||||
|
use crate::HistogramView;
|
||||||
|
|
||||||
|
/// Columns the plot draws.
|
||||||
|
///
|
||||||
|
/// **A divisor of [`HISTOGRAM_BINS`], and that is not a detail.** 96 columns
|
||||||
|
/// over 256 bins folds two levels into some columns and three into others, so a
|
||||||
|
/// perfectly even ramp is drawn as a comb — structure the photograph does not
|
||||||
|
/// have. Four levels per column, always.
|
||||||
|
///
|
||||||
|
/// 64 rather than 128 because the plot is about 256px wide in the develop
|
||||||
|
/// column: four pixels per column is a bar that can be seen, where two is a
|
||||||
|
/// hairline. It also halves the repeater, and this panel is instantiated for
|
||||||
|
/// the life of the window rather than built when it is looked at.
|
||||||
|
pub(crate) const COLUMNS: usize = 64;
|
||||||
|
|
||||||
|
/// Fraction of the frame that must clip before the indicator lights.
|
||||||
|
///
|
||||||
|
/// Not zero. Almost every photograph has a few pixels at an extreme — a
|
||||||
|
/// specular glint on chrome, a sensor hot pixel, the dark corner of a vignette
|
||||||
|
/// — and an indicator that fires on all of them is one a photographer stops
|
||||||
|
/// reading within a day. A thousandth of the frame is roughly where clipping
|
||||||
|
/// stops being an artefact and starts being a decision.
|
||||||
|
const CLIP_VISIBLE: f32 = 0.001;
|
||||||
|
|
||||||
|
/// The whole panel's state, for one counted frame.
|
||||||
|
pub(crate) fn view(hist: &Histogram) -> HistogramView {
|
||||||
|
HistogramView {
|
||||||
|
// Zero pixels means nothing has been rendered yet, not an empty
|
||||||
|
// photograph — the panel draws its frame and no data rather than a flat
|
||||||
|
// line, which would be a claim about an image that does not exist.
|
||||||
|
available: hist.pixels() > 0,
|
||||||
|
luma: model(&scaled(hist.luma())),
|
||||||
|
red: model(&scaled(hist.red())),
|
||||||
|
green: model(&scaled(hist.green())),
|
||||||
|
blue: model(&scaled(hist.blue())),
|
||||||
|
highlights_clipped: fraction(hist.clipped_highlights(), hist.pixels()) >= CLIP_VISIBLE,
|
||||||
|
shadows_clipped: fraction(hist.clipped_shadows(), hist.pixels()) >= CLIP_VISIBLE,
|
||||||
|
highlights_label: percentage(hist.clipped_highlights(), hist.pixels()).into(),
|
||||||
|
shadows_label: percentage(hist.clipped_shadows(), hist.pixels()).into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty panel — no image open, or a frame that could not be counted.
|
||||||
|
pub(crate) fn empty() -> HistogramView {
|
||||||
|
HistogramView {
|
||||||
|
available: false,
|
||||||
|
luma: model(&[0.0; COLUMNS]),
|
||||||
|
red: model(&[0.0; COLUMNS]),
|
||||||
|
green: model(&[0.0; COLUMNS]),
|
||||||
|
blue: model(&[0.0; COLUMNS]),
|
||||||
|
highlights_clipped: false,
|
||||||
|
shadows_clipped: false,
|
||||||
|
// Not "0%". Nothing has been counted, and claiming no clipping about a
|
||||||
|
// frame that does not exist is the one thing an instrument must not do.
|
||||||
|
highlights_label: UNKNOWN.into(),
|
||||||
|
shadows_label: UNKNOWN.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a figure reads before there is a frame behind it.
|
||||||
|
const UNKNOWN: &str = "—";
|
||||||
|
|
||||||
|
fn model(heights: &[f32; COLUMNS]) -> slint::ModelRc<f32> {
|
||||||
|
slint::ModelRc::new(slint::VecModel::from(heights.to_vec()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fold 256 levels into [`COLUMNS`] columns and scale to 0..1.
|
||||||
|
pub(crate) fn scaled(bins: &[u32; HISTOGRAM_BINS]) -> [f32; COLUMNS] {
|
||||||
|
let folded = fold(bins);
|
||||||
|
let peak = peak(&folded);
|
||||||
|
if peak == 0 {
|
||||||
|
return [0.0; COLUMNS];
|
||||||
|
}
|
||||||
|
let mut out = [0.0f32; COLUMNS];
|
||||||
|
for (o, count) in out.iter_mut().zip(folded.iter()) {
|
||||||
|
// Clamped because `peak` deliberately ignores the end columns, so the
|
||||||
|
// spike at a clipped white is taller than the scale it is drawn on.
|
||||||
|
*o = (*count as f32 / peak as f32).min(1.0);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sum adjacent levels into the columns the plot has room for.
|
||||||
|
fn fold(bins: &[u32; HISTOGRAM_BINS]) -> [u32; COLUMNS] {
|
||||||
|
const PER_COLUMN: usize = HISTOGRAM_BINS / COLUMNS;
|
||||||
|
let mut out = [0u32; COLUMNS];
|
||||||
|
for (level, count) in bins.iter().enumerate() {
|
||||||
|
// Saturating: a 24 MP frame of one colour is 24 million in one bin,
|
||||||
|
// which fits, but two folded bins of a hypothetical larger frame need
|
||||||
|
// not — and a histogram that wrapped to near-zero at the exact moment
|
||||||
|
// the image went flat would be worse than useless.
|
||||||
|
out[level / PER_COLUMN] = out[level / PER_COLUMN].saturating_add(*count);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The count the plot's full height stands for.
|
||||||
|
///
|
||||||
|
/// **The end columns are excluded, and this is the single most consequential
|
||||||
|
/// decision in the file.** A photograph shot against a black backdrop puts a
|
||||||
|
/// third of its pixels in level 0; scaled against that, every tone the
|
||||||
|
/// photographer is actually working with is drawn two pixels tall and the
|
||||||
|
/// histogram says nothing. The same happens at the top with a blown sky. Both
|
||||||
|
/// spikes are exactly what the clipping indicators report separately and in
|
||||||
|
/// figures, so nothing is hidden by leaving them off the scale — the plot stops
|
||||||
|
/// being dominated by the one fact it was already stating twice.
|
||||||
|
///
|
||||||
|
/// Falls back to the true maximum when the ends are all there is, so a frame
|
||||||
|
/// that really is entirely black still draws something rather than nothing.
|
||||||
|
fn peak(folded: &[u32; COLUMNS]) -> u32 {
|
||||||
|
let interior = folded[1..COLUMNS - 1].iter().copied().max().unwrap_or(0);
|
||||||
|
if interior > 0 {
|
||||||
|
interior
|
||||||
|
} else {
|
||||||
|
folded.iter().copied().max().unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fraction(clipped: u32, pixels: u32) -> f32 {
|
||||||
|
if pixels == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
clipped as f32 / pixels as f32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much of the frame is gone, as a figure rather than a colour.
|
||||||
|
///
|
||||||
|
/// NFR-A11Y-3 asks that no status be carried by hue alone, and this is the
|
||||||
|
/// text half of that: the marker beside it says *whether*, and this says how
|
||||||
|
/// much — which is the more useful half anyway, since the choice between
|
||||||
|
/// pulling a stop back and leaving a specular highlight alone is a choice about
|
||||||
|
/// magnitude.
|
||||||
|
///
|
||||||
|
/// Distinguishes "none" from "not none but under a tenth of a percent": those
|
||||||
|
/// are different answers, and rounding the second to `0.0%` would tell a
|
||||||
|
/// photographer their highlights were safe when the indicator beside it is lit.
|
||||||
|
fn percentage(clipped: u32, pixels: u32) -> String {
|
||||||
|
if pixels == 0 || clipped == 0 {
|
||||||
|
return "0%".into();
|
||||||
|
}
|
||||||
|
let pct = 100.0 * fraction(clipped, pixels);
|
||||||
|
if pct < 0.1 {
|
||||||
|
"<0.1%".into()
|
||||||
|
} else {
|
||||||
|
format!("{pct:.1}%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A histogram with `count` pixels at each named level and nothing else.
|
||||||
|
///
|
||||||
|
/// Built through the GPU pass's own constructor path would need a device;
|
||||||
|
/// these are assertions about arithmetic, so they take the counts directly.
|
||||||
|
fn bins(levels: &[(usize, u32)]) -> [u32; HISTOGRAM_BINS] {
|
||||||
|
let mut out = [0u32; HISTOGRAM_BINS];
|
||||||
|
for (level, count) in levels {
|
||||||
|
out[*level] = *count;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_column_covers_the_same_number_of_levels() {
|
||||||
|
// The comb bug. With a column count that does not divide 256, some
|
||||||
|
// columns gather three levels and some two, so a perfectly even ramp is
|
||||||
|
// drawn with every third bar 50% taller — which reads as structure in
|
||||||
|
// the image that is not there. Asserted on the fold rather than on the
|
||||||
|
// constant, because the constant being wrong is only a problem through
|
||||||
|
// what the fold then does with it.
|
||||||
|
assert_eq!(HISTOGRAM_BINS % COLUMNS, 0);
|
||||||
|
|
||||||
|
let flat = [7u32; HISTOGRAM_BINS];
|
||||||
|
let folded = fold(&flat);
|
||||||
|
let first = folded[0];
|
||||||
|
assert!(first > 0);
|
||||||
|
assert!(
|
||||||
|
folded.iter().all(|c| *c == first),
|
||||||
|
"an even distribution must fold to even columns, got {folded:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_level_lands_in_the_column_that_covers_it() {
|
||||||
|
// Routing, level by level. An off-by-one here draws the whole
|
||||||
|
// photograph one column left of where it belongs, which is invisible on
|
||||||
|
// any image and wrong on all of them.
|
||||||
|
let per = HISTOGRAM_BINS / COLUMNS;
|
||||||
|
for level in [0usize, 1, per, per + 1, HISTOGRAM_BINS - 1] {
|
||||||
|
let folded = fold(&bins(&[(level, 5)]));
|
||||||
|
let expected = level / per;
|
||||||
|
assert_eq!(
|
||||||
|
folded[expected], 5,
|
||||||
|
"level {level} missed column {expected}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
folded.iter().sum::<u32>(),
|
||||||
|
5,
|
||||||
|
"level {level} was counted in more than one column"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_clipped_spike_does_not_flatten_everything_else() {
|
||||||
|
// The reason `peak` ignores the ends. A frame that is nine-tenths pure
|
||||||
|
// black — a studio shot on a black backdrop, or any night scene — must
|
||||||
|
// still show the tones the photographer is working on. Scaled against
|
||||||
|
// the black spike they would be a hundredth of the plot's height, which
|
||||||
|
// is a flat line.
|
||||||
|
let mut levels = vec![(0usize, 900_000u32)];
|
||||||
|
levels.push((128, 1000));
|
||||||
|
levels.push((129, 500));
|
||||||
|
let heights = scaled(&bins(&levels));
|
||||||
|
|
||||||
|
let mid = heights[128 / (HISTOGRAM_BINS / COLUMNS)];
|
||||||
|
assert!(
|
||||||
|
mid > 0.9,
|
||||||
|
"the tallest interior column should reach the top, got {mid}"
|
||||||
|
);
|
||||||
|
// And the spike is still drawn, at the ceiling rather than off it.
|
||||||
|
assert_eq!(heights[0], 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_entirely_black_frame_still_draws_its_spike() {
|
||||||
|
// The fallback in `peak`. With every pixel at level 0 the interior is
|
||||||
|
// empty, and dividing by that peak would either panic or produce a
|
||||||
|
// plot with nothing in it — which says "no data" about a frame that has
|
||||||
|
// a great deal of data, all of it bad news.
|
||||||
|
let heights = scaled(&bins(&[(0, 4096)]));
|
||||||
|
assert_eq!(heights[0], 1.0);
|
||||||
|
assert!(heights[1..].iter().all(|h| *h == 0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_frame_scales_to_nothing_rather_than_dividing_by_zero() {
|
||||||
|
let heights = scaled(&[0u32; HISTOGRAM_BINS]);
|
||||||
|
assert!(heights.iter().all(|h| *h == 0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heights_never_leave_the_plot() {
|
||||||
|
// Everything downstream multiplies these by the plot's height, so a
|
||||||
|
// value above 1 paints outside the box and one below 0 paints upward
|
||||||
|
// out of the panel. Checked across the shapes most likely to break it:
|
||||||
|
// all the weight at one end, and all of it in one interior column.
|
||||||
|
for levels in [
|
||||||
|
vec![(0usize, 100u32)],
|
||||||
|
vec![(255, 100)],
|
||||||
|
vec![(0, 100), (255, 100)],
|
||||||
|
vec![(64, 100)],
|
||||||
|
vec![(0, 1_000_000), (64, 1)],
|
||||||
|
] {
|
||||||
|
for h in scaled(&bins(&levels)) {
|
||||||
|
assert!((0.0..=1.0).contains(&h), "height {h} left the plot");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn folding_cannot_overflow_on_a_large_frame() {
|
||||||
|
// `u32` counts summed two at a time. A saturating add rather than a
|
||||||
|
// wrapping one, because the wrap would land near zero — a histogram
|
||||||
|
// that emptied itself at the exact moment the image became flat.
|
||||||
|
let folded = fold(&[u32::MAX; HISTOGRAM_BINS]);
|
||||||
|
assert_eq!(folded[0], u32::MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_clipping_figure_distinguishes_none_from_nearly_none() {
|
||||||
|
// `0.0%` and `<0.1%` are different answers, and the second is the one
|
||||||
|
// that appears beside a lit marker. Rounding it to the first would have
|
||||||
|
// the panel contradict itself: an indicator saying "clipped" over a
|
||||||
|
// figure saying "none".
|
||||||
|
assert_eq!(percentage(0, 1000), "0%");
|
||||||
|
assert_eq!(percentage(1, 1_000_000), "<0.1%");
|
||||||
|
assert_eq!(percentage(12, 1000), "1.2%");
|
||||||
|
assert_eq!(percentage(1000, 1000), "100.0%");
|
||||||
|
// No frame yet: not a claim that nothing is clipped so much as nothing
|
||||||
|
// to claim, and "0%" is the honest reading of zero pixels counted.
|
||||||
|
assert_eq!(percentage(0, 0), "0%");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_uncounted_frame_says_it_does_not_know_rather_than_says_zero() {
|
||||||
|
// The panel is emptied when an image is closed and again when one fails
|
||||||
|
// to render, and it stays empty until the first settled frame. Reading
|
||||||
|
// "Highlights 0%" through that gap is a positive claim about a
|
||||||
|
// photograph nobody has counted — the failure mode of an instrument
|
||||||
|
// that is worse than no instrument.
|
||||||
|
let blank = empty();
|
||||||
|
assert!(!blank.available);
|
||||||
|
assert_eq!(blank.highlights_label, UNKNOWN);
|
||||||
|
assert_eq!(blank.shadows_label, UNKNOWN);
|
||||||
|
assert!(!blank.highlights_clipped && !blank.shadows_clipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_indicator_ignores_a_handful_of_specular_pixels() {
|
||||||
|
// The threshold, from both sides. Below it the marker stays dark while
|
||||||
|
// the figure still reports what it found — an indicator that fired on
|
||||||
|
// every glint would be one nobody reads, and one that hid the number
|
||||||
|
// would be one nobody could check.
|
||||||
|
let just_under = (CLIP_VISIBLE * 1_000_000.0) as u32 - 1;
|
||||||
|
assert!(fraction(just_under, 1_000_000) < CLIP_VISIBLE);
|
||||||
|
assert!(fraction(just_under + 1, 1_000_000) >= CLIP_VISIBLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ mod collections_ui;
|
|||||||
mod derived_sync;
|
mod derived_sync;
|
||||||
mod develop;
|
mod develop;
|
||||||
mod export;
|
mod export;
|
||||||
|
mod histogram;
|
||||||
mod labels;
|
mod labels;
|
||||||
mod library;
|
mod library;
|
||||||
mod library_ui;
|
mod library_ui;
|
||||||
@@ -261,6 +262,11 @@ fn reset_view_state(window: &AppWindow) {
|
|||||||
// previous image's history.
|
// previous image's history.
|
||||||
window.set_can_undo(false);
|
window.set_can_undo(false);
|
||||||
window.set_can_redo(false);
|
window.set_can_redo(false);
|
||||||
|
// TRACES: FR-DSP-7
|
||||||
|
// Emptied rather than left standing: the previous photograph's histogram
|
||||||
|
// beside the next one's filename is a confident, precise lie, and the gap
|
||||||
|
// before the new frame settles is exactly long enough to read it.
|
||||||
|
window.set_histogram(histogram::empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Push the framing back to the geometry panel.
|
/// Push the framing back to the geometry panel.
|
||||||
@@ -1016,10 +1022,32 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
// duration of every gesture.
|
// duration of every gesture.
|
||||||
let (vw, vh) = *viewport.borrow();
|
let (vw, vh) = *viewport.borrow();
|
||||||
window.set_magnified(s.magnifies_source(vw, vh));
|
window.set_magnified(s.magnifies_source(vw, vh));
|
||||||
|
|
||||||
|
// TRACES: FR-DSP-7
|
||||||
|
// **Counted on the settled frame and no other.**
|
||||||
|
//
|
||||||
|
// FR-DSP-7 requires the histogram not extend the FR-DSP-3
|
||||||
|
// frame budget, and `draft` is already exactly the flag
|
||||||
|
// that says a gesture is still moving — so this reduction
|
||||||
|
// and its 4 KB transfer happen once when the slider stops
|
||||||
|
// rather than on every one of the forty frames a drag
|
||||||
|
// emits. Nothing is lost by it: a histogram of a
|
||||||
|
// half-resolution frame flickering past under a finger is
|
||||||
|
// not a reading anyone takes.
|
||||||
|
if !draft {
|
||||||
|
window.set_histogram(
|
||||||
|
s.histogram()
|
||||||
|
.as_ref()
|
||||||
|
.map_or_else(histogram::empty, histogram::view),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("render failed: {e}");
|
log::warn!("render failed: {e}");
|
||||||
window.set_load_error(e.into());
|
window.set_load_error(e.into());
|
||||||
|
// No frame, so nothing to describe. The stale plot would
|
||||||
|
// otherwise sit beside the error message looking current.
|
||||||
|
window.set_histogram(histogram::empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -117,6 +117,31 @@ colors:
|
|||||||
state, and it is worth the one exception to say that instantly. Muted
|
state, and it is worth the one exception to say that instantly. Muted
|
||||||
rather than saturated so it does not shift perception of a nearby image.
|
rather than saturated so it does not shift perception of a nearby image.
|
||||||
|
|
||||||
|
_plot:
|
||||||
|
section: plot series
|
||||||
|
note: |
|
||||||
|
The histogram's four traces (FR-DSP-7). Hue here is the same exception the
|
||||||
|
swatch takes and not a second one: a per-channel histogram has to say
|
||||||
|
*which channel*, and no achromatic treatment can distinguish red from blue
|
||||||
|
— so the colour is data, exactly as the image beside it is.
|
||||||
|
|
||||||
|
Held well back from full strength, and darker than `ink`, for the reason
|
||||||
|
the theme preamble gives: three saturated traces sitting a few centimetres
|
||||||
|
from the photograph would compete with it and shift how its colours read.
|
||||||
|
These are legible against `ground` at a glance and no louder than that.
|
||||||
|
|
||||||
|
plot-red: "#C4626A"
|
||||||
|
plot-green: "#6BA867"
|
||||||
|
plot-blue: "#5F8CCB"
|
||||||
|
|
||||||
|
plot-luma:
|
||||||
|
value: "#4E5257"
|
||||||
|
doc: |
|
||||||
|
The luminance trace, drawn filled and behind the three channels. Neutral
|
||||||
|
because luminance is not a channel — it is the axis exposure is read off,
|
||||||
|
and a hue would imply it were one series among four rather than the one
|
||||||
|
the other three are decomposing.
|
||||||
|
|
||||||
lengths:
|
lengths:
|
||||||
_gaps: { break: true }
|
_gaps: { break: true }
|
||||||
gap-sm: 6
|
gap-sm: 6
|
||||||
|
|||||||
+22
-1
@@ -4,9 +4,10 @@ import { LaunchScreen } from "launch.slint";
|
|||||||
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
|
import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint";
|
||||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||||||
import { CollectionsPanel, CollectionRow } from "collections.slint";
|
import { CollectionsPanel, CollectionRow } from "collections.slint";
|
||||||
|
import { HistogramPanel, HistogramView } from "histogram.slint";
|
||||||
import { SettingsPage } from "settings.slint";
|
import { SettingsPage } from "settings.slint";
|
||||||
|
|
||||||
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow }
|
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView }
|
||||||
|
|
||||||
// Status strip — surfaces the GPU backend and adapter, which matters during
|
// Status strip — surfaces the GPU backend and adapter, which matters during
|
||||||
// v0.1 because assumption A1 is exactly "does this compositing path work on
|
// v0.1 because assumption A1 is exactly "does this compositing path work on
|
||||||
@@ -223,6 +224,11 @@ export component AppWindow inherits Window {
|
|||||||
in property <int> total: 0;
|
in property <int> total: 0;
|
||||||
in property <string> load-error: "";
|
in property <string> load-error: "";
|
||||||
|
|
||||||
|
/// TRACES: FR-DSP-7
|
||||||
|
/// The counted frame. Set only when the render has settled — a histogram
|
||||||
|
/// of a draft frame is a histogram of an image nobody is reading.
|
||||||
|
in property <HistogramView> histogram;
|
||||||
|
|
||||||
// --- zoom, pan and crop (FR-DEV-4) ---
|
// --- zoom, pan and crop (FR-DEV-4) ---
|
||||||
//
|
//
|
||||||
// Zoom is a *viewing* state, not an edit: it changes the resolution the
|
// Zoom is a *viewing* state, not an edit: it changes the resolution the
|
||||||
@@ -1500,6 +1506,21 @@ in property <bool> panel-visible: true;
|
|||||||
background: Theme.rule;
|
background: Theme.rule;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Directly under the capture metadata and above every
|
||||||
|
// control, because it is the thing the controls are
|
||||||
|
// judged against: exposure, blacks and whites are all
|
||||||
|
// set by watching this move (FR-DSP-7). An instrument
|
||||||
|
// below the sliders it reports on would have the
|
||||||
|
// photographer looking away from it to use it.
|
||||||
|
HistogramPanel {
|
||||||
|
data: root.histogram;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
height: 1px;
|
||||||
|
background: Theme.rule;
|
||||||
|
}
|
||||||
|
|
||||||
// Framing above the colour work, matching how the edit is
|
// Framing above the colour work, matching how the edit is
|
||||||
// made rather than how it is applied: the frame is decided
|
// made rather than how it is applied: the frame is decided
|
||||||
// by eye first and the pipeline runs it last (see
|
// by eye first and the pipeline runs it last (see
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
// TRACES: FR-DSP-7
|
||||||
|
// The live histogram, and what it says about clipping.
|
||||||
|
//
|
||||||
|
// **Why this is hand-built when the rest of the column is generated.** The
|
||||||
|
// develop panel is built from operation capabilities, and a histogram is not an
|
||||||
|
// operation: it has no parameters, changes nothing about the photograph, and
|
||||||
|
// answers a question rather than asking one. It is an *instrument* — the same
|
||||||
|
// kind of thing as the zoom readout — so it is written, and ARCH §4.3a is
|
||||||
|
// untroubled by it: nothing here reads a parameter out of a descriptor.
|
||||||
|
//
|
||||||
|
// **Everything numeric was decided in Rust.** The heights arriving here are
|
||||||
|
// already 0..1 against a chosen scale, and the clipping figures are already
|
||||||
|
// strings. That is not tidiness — it is the only way the arithmetic is
|
||||||
|
// testable. A peak chosen in Slint could only be checked by looking at it, and
|
||||||
|
// a histogram that is the wrong shape looks exactly as plausible as one that is
|
||||||
|
// right (see `src/histogram.rs`).
|
||||||
|
|
||||||
|
import { Theme } from "theme.slint";
|
||||||
|
import { PanelHeading, Caption, Panel } from "widgets.slint";
|
||||||
|
|
||||||
|
// One counted frame, ready to draw.
|
||||||
|
//
|
||||||
|
// Heights are 0..1 with y up, darkest level first — the same convention the
|
||||||
|
// tone curve's samples use, so the two plots in this column cannot end up
|
||||||
|
// disagreeing about which way is up.
|
||||||
|
export struct HistogramView {
|
||||||
|
// Whether a frame has been counted at all. Distinct from an all-zero
|
||||||
|
// histogram, which would be a claim about an image rather than the absence
|
||||||
|
// of one.
|
||||||
|
available: bool,
|
||||||
|
|
||||||
|
luma: [float],
|
||||||
|
red: [float],
|
||||||
|
green: [float],
|
||||||
|
blue: [float],
|
||||||
|
|
||||||
|
// Past the threshold worth reporting — see `CLIP_VISIBLE` in Rust.
|
||||||
|
highlights-clipped: bool,
|
||||||
|
shadows-clipped: bool,
|
||||||
|
|
||||||
|
// How much, as text. NFR-A11Y-3: the marker beside these says *whether*
|
||||||
|
// by hue and position, and these say *how much* in a form that survives
|
||||||
|
// being unable to tell the two apart.
|
||||||
|
highlights-label: string,
|
||||||
|
shadows-label: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
// One series, as a run of columns.
|
||||||
|
//
|
||||||
|
// Two shapes from one component because the two differ only in where the column
|
||||||
|
// starts: the luminance trace is filled from the baseline, the three channels
|
||||||
|
// are a line. Splitting them into two components would duplicate the column
|
||||||
|
// arithmetic, which is the part that has to stay identical or the four series
|
||||||
|
// would no longer be plotted against the same axis.
|
||||||
|
//
|
||||||
|
// One thin Rectangle per column: Slint has no polyline primitive, and this is
|
||||||
|
// the same construction `CurveEditor` uses a few files away.
|
||||||
|
component Trace inherits Rectangle {
|
||||||
|
/// 0..1, y up.
|
||||||
|
in property <[float]> heights;
|
||||||
|
in property <color> ink;
|
||||||
|
/// Filled from the baseline rather than drawn as a line.
|
||||||
|
in property <bool> filled: false;
|
||||||
|
in property <float> shade: 1.0;
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
for h[i] in root.heights: Rectangle {
|
||||||
|
// The next column's height, so a line segment can span the gap between
|
||||||
|
// them. Without this a steep edge draws as a dotted stair rather than a
|
||||||
|
// rising line — the same fix `CurveEditor` makes for the same reason.
|
||||||
|
property <float> next: i + 1 < root.heights.length
|
||||||
|
? root.heights[i + 1] : h;
|
||||||
|
|
||||||
|
x: parent.width * i / max(root.heights.length, 1);
|
||||||
|
width: parent.width / max(root.heights.length, 1) + 1px;
|
||||||
|
|
||||||
|
y: root.filled
|
||||||
|
? parent.height * (1.0 - h)
|
||||||
|
: parent.height * (1.0 - max(h, self.next));
|
||||||
|
height: root.filled
|
||||||
|
? parent.height * h
|
||||||
|
// A floor, so a flat stretch of the trace is still a line and not a
|
||||||
|
// gap in one.
|
||||||
|
: max(parent.height * abs(self.next - h), 1.5px);
|
||||||
|
|
||||||
|
background: root.ink;
|
||||||
|
opacity: root.shade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A clipping readout: a lit marker and a figure.
|
||||||
|
//
|
||||||
|
// **Two affordances for one fact, and NFR-A11Y-3 is why.** No status in this
|
||||||
|
// application is carried by hue alone, and clipping is the status most worth
|
||||||
|
// getting right — it is the one the histogram exists to warn about. So the
|
||||||
|
// marker appears and disappears (a shape, not a tint) and the figure states the
|
||||||
|
// magnitude in words. Either alone is enough to read it.
|
||||||
|
component ClipReadout inherits HorizontalLayout {
|
||||||
|
in property <string> title;
|
||||||
|
in property <string> figure;
|
||||||
|
in property <bool> lit;
|
||||||
|
|
||||||
|
spacing: Theme.gap-sm;
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
border-radius: 1px;
|
||||||
|
// `warn-ink`, the palette's one sanctioned hue: a blown highlight is a
|
||||||
|
// caution about the photograph, which is exactly the kind of thing that
|
||||||
|
// token exists to say instantly.
|
||||||
|
background: Theme.warn-ink;
|
||||||
|
visible: root.lit;
|
||||||
|
}
|
||||||
|
|
||||||
|
Caption { text: root.title; }
|
||||||
|
Caption { text: root.figure; warn: root.lit; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-DSP-7
|
||||||
|
export component HistogramPanel inherits Rectangle {
|
||||||
|
in property <HistogramView> data;
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
height: panel.preferred-height;
|
||||||
|
|
||||||
|
panel := Panel {
|
||||||
|
// Flat, like every panel in this column: the rule between it and its
|
||||||
|
// neighbour belongs to the column that stacks them.
|
||||||
|
flat: true;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
PanelHeading { text: "HISTOGRAM"; }
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
// Tall enough to read a shape off and no taller. The column is the
|
||||||
|
// photographer's instrument panel, and every pixel this takes is a
|
||||||
|
// slider pushed below the fold.
|
||||||
|
height: 84px;
|
||||||
|
background: Theme.ground;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.rule;
|
||||||
|
// The traces are positioned by fraction of the plot and a value at
|
||||||
|
// the ceiling lands exactly on the border; clipping keeps the top
|
||||||
|
// row of pixels inside the box rather than over its edge.
|
||||||
|
clip: true;
|
||||||
|
|
||||||
|
// Quarter gridlines, so a tone can be placed on the axis without
|
||||||
|
// counting. Same construction and same weight as the tone curve's,
|
||||||
|
// because the two plots sit in one column and any difference
|
||||||
|
// between them would read as meaning something.
|
||||||
|
for i in [1, 2, 3]: Rectangle {
|
||||||
|
x: parent.width * i / 4;
|
||||||
|
width: 1px;
|
||||||
|
background: Theme.rule;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Luminance first, so it sits behind: it is the envelope the three
|
||||||
|
// channels decompose, and a filled area drawn over a line hides it.
|
||||||
|
Trace {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
heights: root.data.luma;
|
||||||
|
ink: Theme.plot-luma;
|
||||||
|
filled: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The channels over it, held back so three overlapping traces stay
|
||||||
|
// legible where they cross — which on a neutral subject is
|
||||||
|
// everywhere.
|
||||||
|
Trace {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
heights: root.data.red;
|
||||||
|
ink: Theme.plot-red;
|
||||||
|
shade: 0.85;
|
||||||
|
}
|
||||||
|
Trace {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
heights: root.data.green;
|
||||||
|
ink: Theme.plot-green;
|
||||||
|
shade: 0.85;
|
||||||
|
}
|
||||||
|
Trace {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
heights: root.data.blue;
|
||||||
|
ink: Theme.plot-blue;
|
||||||
|
shade: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clipping markers, drawn on the plot's own ends.
|
||||||
|
//
|
||||||
|
// Here as well as in the figures below because this is where the
|
||||||
|
// eye already is: a bar standing at the edge the tones are piling
|
||||||
|
// against says which end has gone in the same glance that reads the
|
||||||
|
// shape, and the figures underneath say how much.
|
||||||
|
Rectangle {
|
||||||
|
x: 0;
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
background: Theme.warn-ink;
|
||||||
|
visible: root.data.shadows-clipped;
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
x: parent.width - self.width;
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
background: Theme.warn-ink;
|
||||||
|
visible: root.data.highlights-clipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing rendered yet. Said rather than shown as an empty plot,
|
||||||
|
// which would be a claim that the photograph has no tones in it.
|
||||||
|
Caption {
|
||||||
|
text: "no frame yet";
|
||||||
|
width: 100%;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
visible: !root.data.available;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always drawn, never hidden. With no frame counted the figures read
|
||||||
|
// "—", which is a different statement from "0%" and the true one: the
|
||||||
|
// panel does not yet know. Hiding the row instead would move the
|
||||||
|
// controls below it up and back down as each image opens.
|
||||||
|
HorizontalLayout {
|
||||||
|
ClipReadout {
|
||||||
|
title: "Shadows";
|
||||||
|
figure: root.data.shadows-label;
|
||||||
|
lit: root.data.shadows-clipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { horizontal-stretch: 1; }
|
||||||
|
|
||||||
|
ClipReadout {
|
||||||
|
title: "Highlights";
|
||||||
|
figure: root.data.highlights-label;
|
||||||
|
lit: root.data.highlights-clipped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user