Files
DarkRoom/core/dr-gpu/src/raw_histogram.rs
T
dtourolle affdaecaee Stop describing a base curve the pipeline no longer has
D19 retired the per-body base curve, moved the matrix ahead of the
edits and the film into the view transform's place, but a dozen doc
comments still listed the curve among what a pixel passes through, or
said the film skipped it. The detail stage's module doc still drew the
matrix after the edits and the last detail pass encoding, which the
view pass took over. The film crate's README gave the base curves as
its reason for being data, and the ops README's list of hand-written
nodes had neither the view transform nor three of the five kernels.

FR-MRG-2 gave the base curve as why the merge cuts below the profile;
the view transform is why now. The decision table still said colour
defaults were a per-body curve, and FR-DEV-3j said only the default
view transform skips a JPEG, where the node skips one whatever its
sliders say. frame-budget.md records the view pass as unmeasured.
2026-09-27 19:42:41 -04:00

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 tone curve, no view
//! transform, 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);
}
}