The author could not run cargo, so this is the formatter's first pass over the new module and its presentation half. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
862 lines
36 KiB
Rust
862 lines
36 KiB
Rust
//! TRACES: FR-CULL-3
|
|
//! Counting the sensor's own numbers, on an axis measured in stops of
|
|
//! headroom.
|
|
//!
|
|
//! # Why there are two histograms, and what each one answers
|
|
//!
|
|
//! [`crate::HistogramPass`] beside this file counts the frame the display is
|
|
//! about to show. It is tagged FR-DSP-7, its own documentation says a clipped
|
|
//! bin means "a highlight that is actually gone rather than one the transform
|
|
//! might still recover", and that is exactly right for the question it is
|
|
//! there to answer: *what will this image look like when I send it out.*
|
|
//!
|
|
//! FR-CULL-3 asks the opposite question, and says why: "a JPEG's clipping
|
|
//! warnings systematically lie about what is recoverable in the raw". A
|
|
//! photographer culling three thousand frames is deciding whether a highlight
|
|
//! can be brought back, not whether the current rendering happens to have
|
|
//! kept it. A readout that measures the render cannot answer that however it
|
|
//! is presented, so this is a second instrument rather than a setting on the
|
|
//! first — and the panel offers both, because both are true and they are true
|
|
//! about different things.
|
|
//!
|
|
//! # What is reduced over, and why it is not the CFA samples
|
|
//!
|
|
//! ARCH §5.5 originally specified a reduction over the *pre-demosaic* texture.
|
|
//! This reduces over the demosaiced scene-linear texture instead —
|
|
//! [`crate::DemosaicedImage`]'s `Rgba16Float`, the one the whole develop chain
|
|
//! then works from — and the amendment to §5.5 records the decision rather
|
|
//! than leaving the specification and the code silently disagreeing.
|
|
//!
|
|
//! That texture is the right one on the merits. It is camera-native: no white
|
|
//! balance has been applied, no camera matrix, no base curve, no tone curve,
|
|
//! no output transform. It is normalised by the sensor's own black and white
|
|
//! levels, so 1.0 is saturation by construction and the distribution below it
|
|
//! *is* the headroom question, with no calibration to carry and no origin to
|
|
//! choose.
|
|
//!
|
|
//! And retaining the CFA samples would have cost real memory for the
|
|
//! difference. `Demosaicer::run` uploads the packed `u32` sample buffer and
|
|
//! drops it the moment the dispatch is encoded; keeping it resident to reduce
|
|
//! over later is 48 MB at 24 MP and 120 MB at 60 MP, per photograph opened,
|
|
//! whether or not anyone ever looks at the histogram. ARCH §6.2 exists because
|
|
//! memory is scarce on the platform this has to run on. A more complete
|
|
//! instrument that is paid for on every image by everyone who never opens it
|
|
//! is not a better instrument.
|
|
//!
|
|
//! # Three things this cannot tell you
|
|
//!
|
|
//! Each of these is a different failure, and none of them is hidden by
|
|
//! presenting the result well.
|
|
//!
|
|
//! **It counts pixels, not photosites.** Every value here passed through the
|
|
//! demosaic, so a saturated photosite pulls its interpolated neighbours up
|
|
//! with it: per-channel clipping is smeared across roughly a demosaic kernel.
|
|
//! The count of clipped pixels is therefore an overestimate of the count of
|
|
//! clipped photosites, by an amount that depends on how isolated the clipping
|
|
//! is — a large blown sky is barely affected, a field of specular glints on
|
|
//! water is affected a great deal.
|
|
//!
|
|
//! **It cannot see above white.** `demosaic.wgsl` clamps each photosite at 1.0
|
|
//! for a good reason of its own — a Canon 6D reads to 16383 against a declared
|
|
//! white level of 15070, and carrying that overshoot forward turns blown
|
|
//! highlights pink through the white balance — but the consequence here is
|
|
//! that "at saturation" and "a stop past saturation" arrive in the same bin.
|
|
//! The top column says *how much* is gone and never *how far* gone, which
|
|
//! matters because some of what a raw converter can recover lives exactly
|
|
//! there.
|
|
//!
|
|
//! **It is measured after the CFA pattern is gone.** Which channel of the
|
|
//! mosaic saturated first at a given site is a fact about the sensor readout,
|
|
//! and by this point each pixel carries three channels, two of which were
|
|
//! reconstructed from its neighbours. The series below say which *colour*
|
|
//! clipped in the reconstructed image; they cannot say which photosite went
|
|
//! first.
|
|
//!
|
|
//! # What it costs, and when it runs
|
|
//!
|
|
//! One dispatch over the source texture plus a 4 KB buffer copy and a mapping.
|
|
//! Unlike the display histogram this is a property of the **file**, not of the
|
|
//! edit: nothing downstream of the demosaic can change it, so it is computed
|
|
//! once per photograph and cached rather than recomputed on every settled
|
|
//! frame. That is also why it describes the whole frame rather than the
|
|
//! visible region — a crop changes what is on screen and changes nothing about
|
|
//! what the sensor recorded.
|
|
|
|
use wgpu::util::DeviceExt;
|
|
|
|
use crate::readback::await_mapping;
|
|
use crate::{GpuContext, GpuError};
|
|
|
|
/// Bins on the stops axis.
|
|
///
|
|
/// 256, the same count [`crate::HistogramPass`] uses, so the presentation code
|
|
/// that folds bins into drawable columns is shared between the two rather than
|
|
/// written twice against two different divisors.
|
|
pub const BINS: usize = 256;
|
|
|
|
/// Bins per stop. Must equal `BINS_PER_STOP` in `raw_histogram.wgsl`.
|
|
///
|
|
/// 16 over 256 bins puts the floor of the axis at just under 16 stops, which
|
|
/// covers every sensor anyone will point at this and a couple more besides —
|
|
/// the best-measured full-frame bodies reach about 15 stops at base ISO, and
|
|
/// nothing below the noise floor is a reading anyway. A finer division would
|
|
/// buy resolution in a part of the plot that is already narrower than one
|
|
/// drawn column.
|
|
pub const BINS_PER_STOP: usize = 16;
|
|
|
|
/// Stops the axis spans, as a whole number.
|
|
///
|
|
/// Note that bin 0 sits at `STOPS - 1/BINS_PER_STOP` below saturation rather
|
|
/// than at `STOPS`, and holds everything darker as well — see
|
|
/// [`RawHistogram::stops`].
|
|
///
|
|
/// The last two stops of this range are, strictly, further than the source can
|
|
/// carry: [`crate::DemosaicedImage::FORMAT`] is `Rgba16Float`, whose smallest
|
|
/// normal value is 2^-14, so below fourteen stops a value survives only as an
|
|
/// f16 subnormal and many drivers flush those to zero. It costs nothing to
|
|
/// leave the axis at a round sixteen — no sensor records usable signal
|
|
/// anywhere near there, and the alternative is a plot whose floor is an
|
|
/// implementation detail of a texture format.
|
|
pub const STOPS: usize = BINS / BINS_PER_STOP;
|
|
|
|
/// Four series 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 SATURATED: usize = CHANNELS * BINS;
|
|
const AT_BLACK: usize = CHANNELS * BINS + 1;
|
|
const SLOTS: usize = CHANNELS * BINS + 2;
|
|
|
|
/// TRACES: FR-CULL-3
|
|
/// A counted sensor frame: how many pixels sit at each distance below
|
|
/// saturation.
|
|
///
|
|
/// Counts, not proportions, and bins rather than stops — the same division of
|
|
/// labour [`crate::Histogram`] draws (ARCH §4.3a). Folding 256 bins into the
|
|
/// columns a panel can show, deciding how much clipping is worth an alarm and
|
|
/// turning a bin index into a figure a photographer reads are all questions
|
|
/// about an interface. What this crate owes is the numbers.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct RawHistogram {
|
|
red: [u32; BINS],
|
|
green: [u32; BINS],
|
|
blue: [u32; BINS],
|
|
brightest: [u32; BINS],
|
|
saturated: u32,
|
|
at_black: u32,
|
|
pixels: u32,
|
|
}
|
|
|
|
impl RawHistogram {
|
|
/// Counts per bin for the camera's red channel, darkest first.
|
|
///
|
|
/// **Camera-native and unbalanced.** These are not the red of a rendered
|
|
/// image: the as-shot white balance has not been applied, so on a neutral
|
|
/// subject under daylight red typically sits around a stop below green and
|
|
/// blue rather further. That is not a defect to be corrected before
|
|
/// drawing — it is the point. Red reaching saturation while green has a
|
|
/// stop left is a fact about the exposure, and balancing it away would
|
|
/// hide the very thing the instrument is for.
|
|
pub fn red(&self) -> &[u32; BINS] {
|
|
&self.red
|
|
}
|
|
|
|
pub fn green(&self) -> &[u32; BINS] {
|
|
&self.green
|
|
}
|
|
|
|
pub fn blue(&self) -> &[u32; BINS] {
|
|
&self.blue
|
|
}
|
|
|
|
/// The brightest of the three channels at each pixel.
|
|
///
|
|
/// The envelope the three series sit under, and the one that answers the
|
|
/// headroom question directly: a pixel is safe exactly as long as its
|
|
/// *highest* channel is, because that is the one that saturates first.
|
|
///
|
|
/// Where the display histogram draws Rec.709 luma, this draws a maximum,
|
|
/// and the substitution is not cosmetic. Luma is a weighted sum of
|
|
/// display-referred primaries; these values are camera-native and
|
|
/// unbalanced, so any weighting of them is a number about nothing.
|
|
pub fn brightest(&self) -> &[u32; BINS] {
|
|
&self.brightest
|
|
}
|
|
|
|
/// Pixels with **any** channel at sensor saturation.
|
|
///
|
|
/// Any rather than all, for the reason the display histogram gives about
|
|
/// its own counter: a channel at the ceiling has no gradation left in it
|
|
/// however much the other two still hold, and counting only neutral white
|
|
/// would stay silent on exactly the saturated subject that clips first.
|
|
///
|
|
/// Read this against [`Self::pixels`], and read the module documentation
|
|
/// before reading it against a count of photosites — the demosaic has
|
|
/// already spread each clipped site over its neighbours.
|
|
pub fn saturated(&self) -> u32 {
|
|
self.saturated
|
|
}
|
|
|
|
/// Pixels with any channel at or below the sensor's black level.
|
|
///
|
|
/// The other end, and a genuinely different statement from the display
|
|
/// histogram's shadow clipping: this is a photosite that recorded nothing
|
|
/// but read noise, where that one is a tone the output transform put at
|
|
/// zero and which a lifted black point would bring back.
|
|
pub fn at_black(&self) -> u32 {
|
|
self.at_black
|
|
}
|
|
|
|
/// Pixels counted. The denominator for the two figures above.
|
|
pub fn pixels(&self) -> u32 {
|
|
self.pixels
|
|
}
|
|
|
|
/// How far below sensor saturation a bin sits, in stops.
|
|
///
|
|
/// Bin 255 is 0 stops — the top 1/16 of a stop, where a saturated pixel
|
|
/// lands. Bin 239 is exactly 1 stop below. Bin 0 is 15.9375 stops below
|
|
/// **and everything darker than that**, because it is the end of the axis
|
|
/// rather than a bin of the same width as its neighbours; a photograph
|
|
/// with genuinely 20 stops in it puts its bottom four into that column.
|
|
///
|
|
/// An index past the end is clamped rather than refused: this exists to
|
|
/// label a plot, and a panel that panicked because a loop ran one past its
|
|
/// last column would be a worse failure than a label at the floor of the
|
|
/// axis.
|
|
pub fn stops(bin: usize) -> f32 {
|
|
(BINS - 1 - bin.min(BINS - 1)) as f32 / BINS_PER_STOP as f32
|
|
}
|
|
|
|
/// Rebuild from the flat slot array the shader writes.
|
|
///
|
|
/// `pixels` is summed from the red series rather than taken from the image
|
|
/// dimensions, for the reason [`crate::Histogram`] gives: 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!(
|
|
"raw histogram readback was {} slots, expected {SLOTS}",
|
|
slots.len()
|
|
)));
|
|
}
|
|
let series = |i: usize| -> [u32; BINS] {
|
|
let mut out = [0u32; BINS];
|
|
out.copy_from_slice(&slots[i * BINS..(i + 1) * BINS]);
|
|
out
|
|
};
|
|
let red = series(0);
|
|
Ok(Self {
|
|
pixels: red.iter().sum(),
|
|
red,
|
|
green: series(1),
|
|
blue: series(2),
|
|
brightest: series(3),
|
|
saturated: slots[SATURATED],
|
|
at_black: slots[AT_BLACK],
|
|
})
|
|
}
|
|
}
|
|
|
|
/// The dispatch's view of the source. 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-CULL-3
|
|
/// Counts a scene-linear sensor frame into [`RawHistogram`].
|
|
///
|
|
/// Holds its buffers for the life of the session. They are a fixed 4104 bytes
|
|
/// whatever the sensor size, which is the property that makes the whole shape
|
|
/// affordable — there is nothing to reallocate when a different photograph is
|
|
/// opened.
|
|
pub struct RawHistogramPass {
|
|
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 RawHistogramPass {
|
|
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("raw-histogram"),
|
|
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/raw_histogram.wgsl").into()),
|
|
});
|
|
|
|
let bind_group_layout =
|
|
ctx.device
|
|
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
|
label: Some("raw-histogram-bgl"),
|
|
entries: &[
|
|
// The demosaiced scene-linear texture, read with
|
|
// `textureLoad` — the same one the adjust pass samples
|
|
// from, so what is counted is what the develop chain
|
|
// starts from.
|
|
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("raw-histogram-layout"),
|
|
bind_group_layouts: &[Some(&bind_group_layout)],
|
|
immediate_size: 0,
|
|
});
|
|
|
|
let pipeline = ctx
|
|
.device
|
|
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
|
label: Some("raw-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("raw-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("raw-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("raw-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-CULL-3
|
|
/// Count one scene-linear frame.
|
|
///
|
|
/// `texture` must be linear, camera-native and normalised so that 1.0 is
|
|
/// the sensor's white level — which is what [`crate::Demosaicer::run`]
|
|
/// produces and what nothing else in this crate does. Handing it the
|
|
/// display frame instead would produce a perfectly well-formed plot of the
|
|
/// wrong quantity, on an axis whose origin means nothing there, so callers
|
|
/// must check [`crate::DemosaicedImage::is_non_linear`] first: a texture
|
|
/// that came from a JPEG carries no sensor scale to measure headroom
|
|
/// against, and the honest answer for one is that there is no reading
|
|
/// rather than a reading nobody should trust.
|
|
///
|
|
/// It must also carry `TEXTURE_BINDING`, which the demosaic output does
|
|
/// because the adjust pass samples it.
|
|
pub fn compute(&self, texture: &wgpu::Texture) -> Result<RawHistogram, 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("raw-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("raw-histogram-encoder"),
|
|
});
|
|
// The accumulator is reused between photographs, so it carries the
|
|
// previous one's counts until this line. Forgetting it does not fail —
|
|
// it quietly integrates every image opened this session, which looks
|
|
// like a histogram that is roughly the right shape and never quite
|
|
// about the picture on screen.
|
|
enc.clear_buffer(&self.bins, 0, None);
|
|
{
|
|
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
|
label: Some("raw-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();
|
|
|
|
RawHistogram::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
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An f32 as IEEE 754 half-precision bits, for building source textures.
|
|
///
|
|
/// Written out rather than pulled in as a dependency, exactly as
|
|
/// `demosaic.rs` does for the same reason: every value these tests upload
|
|
/// is inside `0.0..=2.0`, comfortably within f16's normal range, so the
|
|
/// subnormal and overflow cases a general converter must handle cannot
|
|
/// arise. The mantissa is truncated rather than rounded, which is why the
|
|
/// tests below place their values in the *middle* of a bin instead of on
|
|
/// its edge.
|
|
fn half(v: f32) -> u16 {
|
|
let v = v.clamp(0.0, 2.0);
|
|
if v == 0.0 {
|
|
return 0;
|
|
}
|
|
let bits = v.to_bits();
|
|
let exp = ((bits >> 23) & 0xFF) as i32 - 127 + 15;
|
|
let mantissa = (bits >> 13) & 0x3FF;
|
|
if exp <= 0 {
|
|
return 0;
|
|
}
|
|
((exp as u16) << 10) | mantissa as u16
|
|
}
|
|
|
|
/// Upload `rgb` as the `Rgba16Float` texture the pass expects, the way
|
|
/// `Demosaicer::run` hands its output over.
|
|
fn source(ctx: &GpuContext, rgb: &[[f32; 3]], width: u32, height: u32) -> wgpu::Texture {
|
|
let halves: Vec<u16> = rgb
|
|
.iter()
|
|
.flat_map(|px| [half(px[0]), half(px[1]), half(px[2]), half(1.0)])
|
|
.collect();
|
|
let tex = ctx.device.create_texture(&wgpu::TextureDescriptor {
|
|
label: Some("raw-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::Rgba16Float,
|
|
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,
|
|
},
|
|
bytemuck::cast_slice(&halves),
|
|
wgpu::TexelCopyBufferLayout {
|
|
offset: 0,
|
|
// Four channels of two bytes.
|
|
bytes_per_row: Some(width * 8),
|
|
rows_per_image: Some(height),
|
|
},
|
|
wgpu::Extent3d {
|
|
width,
|
|
height,
|
|
depth_or_array_layers: 1,
|
|
},
|
|
);
|
|
ctx.queue.submit(std::iter::empty());
|
|
tex
|
|
}
|
|
|
|
/// The value at the centre of the bin `stops` below saturation.
|
|
///
|
|
/// Deliberately mid-bin. On a bin edge the assertion would be testing
|
|
/// whether this machine's `log2` and this test's `powf` round a tie the
|
|
/// same way, which is a question about two libraries rather than about the
|
|
/// reduction — and the kind of disagreement that fails once in a thousand
|
|
/// runs and looks like flakiness.
|
|
fn mid_bin(bin_below_top: usize) -> f32 {
|
|
2f32.powf(-((bin_below_top as f32) + 0.5) / BINS_PER_STOP as f32)
|
|
}
|
|
|
|
#[test]
|
|
fn the_axis_runs_from_saturation_downward_in_stops() {
|
|
// Arithmetic, so no device. The single most consequential convention
|
|
// in the file, and the one every reading of the plot depends on: if
|
|
// the axis were inverted the histogram would still look like a
|
|
// histogram, and every headroom judgement made from it would be
|
|
// exactly backwards.
|
|
assert_eq!(
|
|
RawHistogram::stops(BINS - 1),
|
|
0.0,
|
|
"the top bin is saturation"
|
|
);
|
|
assert_eq!(RawHistogram::stops(BINS - 1 - BINS_PER_STOP), 1.0);
|
|
assert_eq!(RawHistogram::stops(BINS - 1 - 8 * BINS_PER_STOP), 8.0);
|
|
// The floor is one bin short of `STOPS`, because bin 0 is the end of
|
|
// the axis rather than the start of another stop.
|
|
assert!(RawHistogram::stops(0) < STOPS as f32);
|
|
assert!(RawHistogram::stops(0) > STOPS as f32 - 0.1);
|
|
// Monotonic: further down the axis is always more stops below.
|
|
for bin in 1..BINS {
|
|
assert!(
|
|
RawHistogram::stops(bin) < RawHistogram::stops(bin - 1),
|
|
"bin {bin} is not below bin {}",
|
|
bin - 1
|
|
);
|
|
}
|
|
// Past the end is clamped, not a panic — see the doc comment.
|
|
assert_eq!(RawHistogram::stops(BINS), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn a_saturated_frame_lands_in_the_top_bin_and_is_counted_as_clipped() {
|
|
// The arithmetic at its most checkable, at the end of the axis that
|
|
// matters most: 64x64 pixels at the white level must produce exactly
|
|
// 4096 in bin 255, nothing anywhere else, and 4096 clipped.
|
|
let Some(ctx) = ctx() else { return };
|
|
let pass = RawHistogramPass::new(&ctx).expect("pass");
|
|
|
|
let rgb = vec![[1.0f32, 1.0, 1.0]; 64 * 64];
|
|
let hist = pass.compute(&source(&ctx, &rgb, 64, 64)).expect("compute");
|
|
|
|
assert_eq!(hist.pixels(), 4096);
|
|
assert_eq!(hist.red()[BINS - 1], 4096);
|
|
assert_eq!(hist.green()[BINS - 1], 4096);
|
|
assert_eq!(hist.blue()[BINS - 1], 4096);
|
|
assert_eq!(hist.brightest()[BINS - 1], 4096);
|
|
assert_eq!(
|
|
hist.red().iter().filter(|c| **c > 0).count(),
|
|
1,
|
|
"one value can only occupy one bin"
|
|
);
|
|
assert_eq!(hist.saturated(), 4096);
|
|
assert_eq!(hist.at_black(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn a_frame_at_the_black_level_lands_in_the_bottom_bin() {
|
|
// Zero has no logarithm, so the bottom of the axis is a special case
|
|
// in the shader rather than a value that falls out of the formula. A
|
|
// frame of it must land in bin 0 and be counted as black-clipped —
|
|
// getting this wrong produces a NaN bin index, which on most hardware
|
|
// is bin 0 anyway and on some is not.
|
|
let Some(ctx) = ctx() else { return };
|
|
let pass = RawHistogramPass::new(&ctx).expect("pass");
|
|
|
|
let rgb = vec![[0.0f32, 0.0, 0.0]; 32 * 32];
|
|
let hist = pass.compute(&source(&ctx, &rgb, 32, 32)).expect("compute");
|
|
|
|
assert_eq!(hist.pixels(), 1024);
|
|
assert_eq!(hist.red()[0], 1024);
|
|
assert_eq!(hist.brightest()[0], 1024);
|
|
assert_eq!(hist.at_black(), 1024);
|
|
assert_eq!(hist.saturated(), 0);
|
|
}
|
|
|
|
/// Bins the ramp below walks, from the top of the axis downward.
|
|
///
|
|
/// Twelve stops rather than the whole sixteen, and the reason is the
|
|
/// source format rather than the reduction. `Rgba16Float`'s smallest
|
|
/// *normal* value is 2^-14, so the bottom two stops of the axis can only
|
|
/// be held as f16 subnormals — which many GPUs flush to zero — and a test
|
|
/// that walked into them would be measuring the driver's denormal policy
|
|
/// rather than this shader. Twelve stops is past the noise floor of every
|
|
/// sensor this will meet, so nothing a photograph actually contains is
|
|
/// left unchecked.
|
|
const RAMP: usize = 12 * BINS_PER_STOP;
|
|
|
|
#[test]
|
|
fn every_bin_the_axis_can_hold_lands_where_it_belongs() {
|
|
// A ramp down the axis, four pixels per bin, each value placed at the
|
|
// centre of the bin it belongs in. This is the test that catches an
|
|
// off-by-one in the binning or a `BINS_PER_STOP` that disagrees across
|
|
// the language boundary — either shifts the whole plot along the axis,
|
|
// which on a real photograph looks like nothing at all and is a
|
|
// headroom figure out by a stop.
|
|
let Some(ctx) = ctx() else { return };
|
|
let pass = RawHistogramPass::new(&ctx).expect("pass");
|
|
|
|
let (w, h) = (RAMP as u32, 4u32);
|
|
let mut rgb = Vec::with_capacity((w * h) as usize);
|
|
for _ in 0..h {
|
|
for x in 0..w {
|
|
// x = 0 is the top bin, so `x` is also the number of bins
|
|
// below saturation.
|
|
let v = mid_bin(x as usize);
|
|
rgb.push([v, v, v]);
|
|
}
|
|
}
|
|
let hist = pass.compute(&source(&ctx, &rgb, w, h)).expect("compute");
|
|
|
|
assert_eq!(hist.pixels(), w * h);
|
|
for below in 0..RAMP {
|
|
let bin = BINS - 1 - below;
|
|
assert_eq!(
|
|
hist.red()[bin],
|
|
h,
|
|
"{below} bins below saturation should hold exactly {h} pixels"
|
|
);
|
|
// Neutral, so the brightest channel must land on the same bin as
|
|
// the three do.
|
|
assert_eq!(
|
|
hist.brightest()[bin],
|
|
h,
|
|
"the envelope drifted at bin {bin}"
|
|
);
|
|
}
|
|
// Nothing below the ramp, so an off-by-one that spilled a pixel past
|
|
// the end of it would show here rather than hiding in a bin the loop
|
|
// above never looks at.
|
|
assert!(
|
|
hist.red()[..BINS - RAMP].iter().all(|c| *c == 0),
|
|
"a pixel landed below the ramp"
|
|
);
|
|
// A neutral ramp touching neither end: nothing is at the white level
|
|
// and nothing is at black, so neither counter may fire.
|
|
assert_eq!(hist.saturated(), 0);
|
|
assert_eq!(hist.at_black(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn a_channel_saturating_alone_is_seen_and_the_others_are_not_dragged_with_it() {
|
|
// **The claim the whole instrument rests on**, and the one a
|
|
// luma-weighted or balanced reduction would fail. A red sunset clips
|
|
// red while green and blue still hold two stops — that pixel is
|
|
// clipped, its red must be at the top of the axis, and its green and
|
|
// blue must be where they actually are. A readout that averaged the
|
|
// three would report a comfortable exposure over a channel that is
|
|
// already gone.
|
|
let Some(ctx) = ctx() else { return };
|
|
let pass = RawHistogramPass::new(&ctx).expect("pass");
|
|
|
|
// Two stops below saturation, mid-bin.
|
|
let two_stops = mid_bin(2 * BINS_PER_STOP);
|
|
let rgb = vec![
|
|
[1.0f32, two_stops, two_stops],
|
|
[1.0, two_stops, two_stops],
|
|
[two_stops, two_stops, two_stops],
|
|
[two_stops, two_stops, two_stops],
|
|
];
|
|
let hist = pass.compute(&source(&ctx, &rgb, 4, 1)).expect("compute");
|
|
|
|
assert_eq!(hist.pixels(), 4);
|
|
assert_eq!(
|
|
hist.saturated(),
|
|
2,
|
|
"two pixels have a channel at the ceiling"
|
|
);
|
|
assert_eq!(hist.at_black(), 0);
|
|
assert_eq!(hist.red()[BINS - 1], 2);
|
|
|
|
let two_below = BINS - 1 - 2 * BINS_PER_STOP;
|
|
assert_eq!(hist.red()[two_below], 2, "the unclipped pixels' red moved");
|
|
assert_eq!(
|
|
hist.green()[two_below],
|
|
4,
|
|
"green was dragged along by red's clipping"
|
|
);
|
|
assert_eq!(hist.blue()[two_below], 4);
|
|
// The envelope follows the highest channel, which for the clipped
|
|
// pixels is red.
|
|
assert_eq!(hist.brightest()[BINS - 1], 2);
|
|
assert_eq!(hist.brightest()[two_below], 2);
|
|
}
|
|
|
|
#[test]
|
|
fn overshoot_above_the_white_level_folds_into_the_top_bin() {
|
|
// The demosaic clamps each *photosite* at 1.0, but the Malvar
|
|
// correction can overshoot upward between two clipped ones, so values
|
|
// above 1.0 do reach this pass. `log2` of them is negative and a
|
|
// signed bin index would wrap to somewhere near the bottom of the
|
|
// axis — a blown highlight drawn as deep shadow, which is the single
|
|
// most misleading thing this plot could do.
|
|
let Some(ctx) = ctx() else { return };
|
|
let pass = RawHistogramPass::new(&ctx).expect("pass");
|
|
|
|
let rgb = vec![[1.4f32, 1.05, 1.0]; 8 * 8];
|
|
let hist = pass.compute(&source(&ctx, &rgb, 8, 8)).expect("compute");
|
|
|
|
assert_eq!(hist.pixels(), 64);
|
|
assert_eq!(hist.red()[BINS - 1], 64);
|
|
assert_eq!(hist.green()[BINS - 1], 64);
|
|
assert_eq!(hist.blue()[BINS - 1], 64);
|
|
assert_eq!(hist.saturated(), 64);
|
|
assert_eq!(hist.red()[0], 0, "an overshoot wrapped to the bottom bin");
|
|
}
|
|
|
|
#[test]
|
|
fn an_edge_tile_is_neither_dropped_nor_counted_twice() {
|
|
// A size that is not a multiple of the 16x16 workgroup, so the last
|
|
// row and column of workgroups run off the image. The denominator is
|
|
// where this shows: `pixels` is summed from the red series, so a tile
|
|
// that failed to merge undercounts it and one merged twice doubles it,
|
|
// and a percentage computed against either is wrong in a way nobody
|
|
// can see on the plot.
|
|
let Some(ctx) = ctx() else { return };
|
|
let pass = RawHistogramPass::new(&ctx).expect("pass");
|
|
|
|
let (w, h) = (101u32, 37u32);
|
|
let mut rgb = Vec::with_capacity((w * h) as usize);
|
|
let mut state = 0x2545_F491_4F6C_DD1Du64;
|
|
for _ in 0..w * h {
|
|
// 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;
|
|
let below = (state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 56) as usize % RAMP;
|
|
let v = mid_bin(below);
|
|
rgb.push([v, v, v]);
|
|
}
|
|
let hist = pass.compute(&source(&ctx, &rgb, w, h)).expect("compute");
|
|
|
|
assert_eq!(hist.pixels(), w * h, "an edge tile was dropped or doubled");
|
|
// Every series counts the same pixels, so every series must sum to the
|
|
// same total — a merge that lost one channel's tile would not move the
|
|
// denominator at all.
|
|
for series in [hist.green(), hist.blue(), hist.brightest()] {
|
|
assert_eq!(series.iter().sum::<u32>(), w * h);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_second_photograph_replaces_the_first_rather_than_adding_to_it() {
|
|
// The accumulator is reused across images, so a missing clear
|
|
// integrates every photograph opened this session. The symptom is
|
|
// subtle and awful on a culling pass in particular: the plot keeps a
|
|
// plausible shape and slowly stops responding to the file on screen,
|
|
// because each new image's contribution shrinks against the running
|
|
// total of the folder behind it.
|
|
let Some(ctx) = ctx() else { return };
|
|
let pass = RawHistogramPass::new(&ctx).expect("pass");
|
|
|
|
let bright = vec![[mid_bin(BINS_PER_STOP); 3]; 16 * 16];
|
|
let dark = vec![[mid_bin(6 * BINS_PER_STOP); 3]; 16 * 16];
|
|
|
|
let one_stop = BINS - 1 - BINS_PER_STOP;
|
|
let six_stops = BINS - 1 - 6 * BINS_PER_STOP;
|
|
|
|
let first = pass.compute(&source(&ctx, &bright, 16, 16)).expect("first");
|
|
assert_eq!(first.red()[one_stop], 256);
|
|
|
|
let second = pass.compute(&source(&ctx, &dark, 16, 16)).expect("second");
|
|
assert_eq!(
|
|
second.pixels(),
|
|
256,
|
|
"the previous photograph was still counted"
|
|
);
|
|
assert_eq!(second.red()[one_stop], 0);
|
|
assert_eq!(second.red()[six_stops], 256);
|
|
}
|
|
}
|