//! 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 { 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 { // 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::()) 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 { 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 = bytemuck::cast_slice::(&data).to_vec(); drop(data); self.staging.unmap(); RawHistogram::from_slots(&slots) } } #[cfg(test)] mod tests { use super::*; fn ctx() -> Option { 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 = 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::(), 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); } }