Files
dtourolle a8043e6827 Hand Android's develop frame to the compositor as a texture again
With Skia drawing pre-rotated on wgpu's Vulkan swapchain, Android no
longer needs to draw with Skia over OpenGL, which was the only reason
the develop view read its frame back through memory (TD-1).

So `unstable-wgpu-29` moves back to the common slint dependency. The
android-activity backend then builds `SkiaRenderer::default_wgpu_29`,
and `shared_gpu` loses its Android arm. The one wgpu device is handed to
Slint through `BackendSelector::require_wgpu_29` on both platforms.
`slint::android::init_with_event_listener` runs before `dr_ui::run`, so
the selector reaches the Android adapter before its window exists.
`renderer-femtovg-wgpu` stays desktop-only, since Android has no FemtoVG.

The two `#[cfg(target_os = "android")]` readbacks in `develop::render`
(the frame through `export_pixels` and the focus overlay through
`read_overlay`) are gone. `read_overlay` stays for the tests that check
what the overlay marks.

Built for arm64 and release-signed. Not yet run on the tablet.
2026-09-25 04:20:19 -04:00

1001 lines
40 KiB
Rust

//! TRACES: FR-CULL-3 | NFR-P14
//! Focus peaking — saying what is sharp, so nobody has to zoom in to find out.
//!
//! # What this is for
//!
//! FR-CULL-3 lists three raw-truth overlays and gives the reason for this one
//! plainly: peaking "removes the largest single source of culling latency from
//! the critical path". Checking focus by zooming to 1:1 costs a render, a pan
//! to the subject's eye, and a zoom back, per frame, on a folder of three
//! thousand. D11 keeps 1:1 available for certainty; this is what makes reaching
//! for it the exception.
//!
//! It is *raw*-truth for the same reason the histogram beside it is: what this
//! measures is the frame the develop pipeline rendered from sensor data
//! through the demosaic, not the camera's embedded JPEG. A JPEG has already
//! been sharpened by the body, at a strength and radius nobody outside the
//! manufacturer knows, and peaking on one measures that sharpening at least as
//! much as it measures the lens.
//!
//! # Why a layer, and not a tint in the picture
//!
//! The first shape tried was the obvious one — read the display frame, write
//! the same frame with marked pixels replaced, hand *that* to the compositor.
//! It is wrong, and `app.slint` had already written down why, on the region
//! overlay it composites over the canvas: a diagnostic "must not reach the
//! histogram, an export, or the texture the develop pass hands the
//! compositor".
//!
//! All three would have happened. `HistogramPass` counts whatever texture the
//! session last rendered, so a red mark on every in-focus edge would have
//! arrived in the histogram as a red spike and in the clipping figure as blown
//! highlights. So this pass writes its **own** texture, transparent everywhere
//! except where something is in focus, and the interface lays it over the
//! canvas. The photograph is untouched by construction rather than by care.
//!
//! # Why it stays on the GPU
//!
//! Per-pixel work over the whole frame, every settled frame, is exactly what
//! ARCH §6.1 exists about: the measurement on this project puts a 4K readback
//! at 7.4 ms against a 0.28 ms compute pass, 96% of it transfer. The marks are
//! produced where the pixels already are and handed to the compositor as a
//! texture, and nothing in this file can read a pixel back on the display path
//! — see [`FocusPeakPass::read_overlay`] for the one transfer that exists and
//! who is allowed to call it.
//!
//! # Two textures, alternating
//!
//! For the reason [`crate::AdjustPass`] keeps two: Slint decides whether to
//! repaint by comparing the image property against its previous value, and two
//! images wrapping the same `wgpu::Texture` compare equal. A pass that always
//! wrote one texture would compute a new overlay every frame and never once be
//! asked to show it.
//!
//! # What it costs
//!
//! One dispatch, nine texture loads per pixel, no readback and no
//! reallocation at a steady viewport size. NFR-P14 allows 100 ms after the
//! preview on desktop and 150 ms on Android; this is two orders of magnitude
//! inside that, and the assertion in `overlay_is_ready_well_inside_the_budget`
//! is what keeps the claim honest rather than remembered.
use wgpu::util::DeviceExt as _;
use crate::readback::await_mapping;
use crate::{GpuContext, GpuError};
/// How much local contrast counts as focus.
///
/// Three steps rather than a slider, because the number underneath is not one
/// a photographer can reason about and the choice being made is coarse: *this
/// frame is noisy, mark less* or *this subject is low-contrast, mark more*.
/// Every camera that offers peaking offers it this way.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
pub enum PeakSensitivity {
/// The high-ISO setting. Marks only edges nothing but focus explains.
Low,
#[default]
Medium,
/// For a low-contrast subject — fur, fabric, distant foliage — at the
/// price of marking noise as well.
High,
}
impl PeakSensitivity {
/// The luma difference, 0..1, at which a pixel is called in focus.
///
/// **Where these three numbers come from.** A hard one-pixel step of
/// height *D* produces a response of `0.375 D` (see the shader), so a
/// threshold *t* marks any sharp edge whose contrast exceeds `t / 0.375`.
/// At `Medium` that is 0.107 in luma — about 27 code values — which is a
/// perfectly ordinary edge and well above what a photograph's own texture
/// produces by accident.
///
/// **And what bounds them from below is noise, not taste.** Sensor noise
/// surviving into an 8-bit render is a few code values; call it a standard
/// deviation of 0.008. The response subtracts a mean of eight independent
/// neighbours from one sample, so its standard deviation is
/// `0.008 * sqrt(1 + 1/8)` = 0.0085. `Medium` sits 4.7 of those out, which
/// a normal tail puts at roughly one pixel in a million; `High` sits 2.4
/// out, which is about one pixel in a hundred — visible as a dusting on a
/// noisy frame, which is the trade the setting is named for. `Low` is 9.4
/// out and will not mark noise at all.
///
/// Noise reduction, if the edit has any, has already run by the time this
/// pass sees the frame, so those figures are the pessimistic end.
pub fn threshold(self) -> f32 {
match self {
PeakSensitivity::Low => 0.080,
PeakSensitivity::Medium => 0.040,
PeakSensitivity::High => 0.020,
}
}
}
/// What colour the marks are drawn in.
///
/// A choice rather than a constant, and the reason is NFR-A11Y-3's: status
/// must not rest on hue alone. An overlay's information *is* positional — it
/// says where, not what — so the requirement is not violated by there being a
/// colour; it would be violated by there being only one, because a red mark on
/// a red jersey conveys nothing, and a photographer with a red-green
/// deficiency looking at foliage is in the same position permanently.
///
/// Four fully saturated choices, no mixtures. A desaturated mark has to
/// compete with the photograph for the same colours, which is the one thing a
/// mark must not do.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
pub enum PeakColour {
/// The convention, and what most cameras do.
#[default]
Red,
/// For a red or warm subject, and the most visible of the four on a dark
/// frame.
Yellow,
/// For a warm photograph as a whole, and the choice that survives a
/// red-green deficiency.
Cyan,
/// For a cool or green photograph — the hue photographs contain least.
Magenta,
}
impl PeakColour {
/// The mark, as the encoded triple the overlay is written in.
pub fn rgb(self) -> [f32; 3] {
match self {
PeakColour::Red => [1.0, 0.0, 0.0],
PeakColour::Yellow => [1.0, 1.0, 0.0],
PeakColour::Cyan => [0.0, 1.0, 1.0],
PeakColour::Magenta => [1.0, 0.0, 1.0],
}
}
}
/// TRACES: FR-CULL-3
/// Everything the photographer chose about the overlay.
///
/// One value rather than two arguments, because the two travel together
/// everywhere — into the session, into the sidecar-free view state, back out
/// to the panel — and a pair that is always passed together is a type.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
pub struct FocusPeaking {
pub sensitivity: PeakSensitivity,
pub colour: PeakColour,
}
/// The dispatch's view of the frame. Padded to std140's 16 bytes before the
/// marker, exactly as the shader's `Params` declares it.
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
width: u32,
height: u32,
threshold: f32,
pad_0: u32,
marker: [f32; 4],
}
/// One overlay texture.
struct Layer {
texture: wgpu::Texture,
view: wgpu::TextureView,
width: u32,
height: u32,
}
/// TRACES: FR-CULL-3 | NFR-P14
/// Produces the focus-peaking overlay for a rendered frame.
pub struct FocusPeakPass {
ctx: GpuContext,
pipeline: wgpu::ComputePipeline,
bind_group_layout: wgpu::BindGroupLayout,
params: wgpu::Buffer,
/// Alternating overlay textures — see the module documentation for why
/// there are two rather than one.
layers: [Option<Layer>; 2],
current: usize,
/// Textures allocated since this pass was created. Exists to be asserted
/// on: an overlay reallocated per frame instead of per resize costs a
/// great deal of bandwidth and looks identical in the picture, which is
/// the shape of regression only a counter can see.
allocations: usize,
dispatches: usize,
}
impl FocusPeakPass {
/// The overlay's format.
///
/// The same `Rgba8Unorm` [`crate::AdjustPass`] writes, and for the same
/// non-negotiable reason: it is one of the two formats Slint's texture
/// import accepts. The alpha channel is what carries the overlay, so the
/// eight bits it has are seven more than this needs.
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
pub fn new(ctx: &GpuContext) -> Result<Self, GpuError> {
// A validation error here is a bug in the shader beside this file
// rather than anything a user did, so it is caught in an error scope
// and returned — wgpu's default handler panics.
let scope = ctx.device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("focus-peak"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/focus_peak.wgsl").into()),
});
let bind_group_layout =
ctx.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("focus-peak-bgl"),
entries: &[
// The rendered frame, read with `textureLoad` — the
// same texture the compositor is showing, so what is
// measured is what is on screen.
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly,
format: Self::FORMAT,
view_dimension: wgpu::TextureViewDimension::D2,
},
count: None,
},
],
});
let layout = ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("focus-peak-layout"),
bind_group_layouts: &[Some(&bind_group_layout)],
immediate_size: 0,
});
let pipeline = ctx
.device
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("focus-peak-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 params = ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("focus-peak-params"),
contents: bytemuck::bytes_of(&Params {
width: 0,
height: 0,
threshold: PeakSensitivity::default().threshold(),
pad_0: 0,
marker: [0.0; 4],
}),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
Ok(Self {
ctx: ctx.clone(),
pipeline,
bind_group_layout,
params,
layers: [None, None],
current: 0,
allocations: 0,
dispatches: 0,
})
}
/// TRACES: FR-CULL-3 | NFR-P14
/// Mark the in-focus regions of `frame`, returning the overlay to lay
/// over it.
///
/// `frame` must carry `TEXTURE_BINDING`, which [`crate::AdjustPass`]'s
/// output does because the compositor samples it.
///
/// **Give this a settled frame, not a draft one.** The measure is the
/// energy in the top octave of what it is handed, so it is a statement
/// about a particular sampling grid: at half resolution — which is what a
/// draft frame is rendered at — a defocused edge spanning four pixels
/// spans two, which is the signature of a sharp one. Peaking a draft frame
/// would mark the out-of-focus background of every photograph, briefly,
/// during every drag. The interface runs this where it runs the histogram,
/// on the settled frame, and for the same class of reason.
pub fn render(
&mut self,
frame: &wgpu::Texture,
settings: FocusPeaking,
) -> Result<&wgpu::Texture, GpuError> {
// No zero-size guard: wgpu will not create a texture with a zero
// extent, so a frame that exists has at least one pixel in it.
let (width, height) = (frame.width(), frame.height());
self.ensure_layer(width, height);
let rgb = settings.colour.rgb();
self.ctx.queue.write_buffer(
&self.params,
0,
bytemuck::bytes_of(&Params {
width,
height,
threshold: settings.sensitivity.threshold(),
pad_0: 0,
marker: [rgb[0], rgb[1], rgb[2], 1.0],
}),
);
let layer = self.layers[self.current]
.as_ref()
.expect("ensure_layer just built it");
let source = frame.create_view(&Default::default());
let bind_group = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("focus-peak-bg"),
layout: &self.bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&source),
},
wgpu::BindGroupEntry {
binding: 1,
resource: self.params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&layer.view),
},
],
});
let mut enc = self
.ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("focus-peak-encoder"),
});
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("focus-peak-pass"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
}
// Submitted on its own queue entry after the render that produced
// `frame`. Submission order is the whole of the synchronisation, as it
// is between the fused pass and the detail chain: one queue, and this
// reads what that wrote.
self.ctx.queue.submit(Some(enc.finish()));
self.dispatches += 1;
Ok(&self.layers[self.current]
.as_ref()
.expect("just written")
.texture)
}
/// The overlay the last [`Self::render`] wrote, if there has been one.
pub fn overlay(&self) -> Option<&wgpu::Texture> {
self.layers[self.current].as_ref().map(|l| &l.texture)
}
/// Forget the overlay, so nothing stale is composited.
///
/// Called when the photograph changes and when peaking is switched off.
/// The alternative — leaving the last overlay resident and merely not
/// drawing it — is one interface bug away from laying one photograph's
/// focus marks over another's, which is the single worst thing an
/// instrument like this can do.
pub fn clear(&mut self) {
self.layers = [None, None];
self.current = 0;
}
/// Overlay textures allocated since this pass was created.
///
/// For tests. A steady viewport must not move this number, and the only
/// evidence of that is a counter — a per-frame reallocation renders
/// identically to a cached one.
pub fn allocations(&self) -> usize {
self.allocations
}
/// Dispatches encoded since this pass was created.
///
/// The other half of [`Self::allocations`]: together they say that eight
/// frames cost eight dispatches and two textures, which is the shape a
/// steady viewport is supposed to have.
pub fn dispatches(&self) -> usize {
self.dispatches
}
/// Make sure the current slot holds a texture of this size.
///
/// Rotates first, so consecutive frames land in different textures — see
/// the module documentation. A size change drops both, because neither
/// fits any more and a stale one of the wrong size would be composited
/// stretched over the new frame.
fn ensure_layer(&mut self, width: u32, height: u32) {
self.current ^= 1;
let fits = self.layers[self.current]
.as_ref()
.is_some_and(|l| l.width == width && l.height == height);
if fits {
return;
}
let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("focus-peak-overlay"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: Self::FORMAT,
// STORAGE_BINDING to be written by the dispatch and
// TEXTURE_BINDING to be sampled by the compositor.
// RENDER_ATTACHMENT is not used by anything here and is required
// anyway: Slint rejects an imported texture without it. COPY_SRC
// is for `read_overlay` and the tests that call it.
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
self.layers[self.current] = Some(Layer {
texture,
view,
width,
height,
});
self.allocations += 1;
}
/// TRACES: AC-8
/// Copy the overlay to the CPU, as RGBA8 rows with no padding.
///
/// **The tests below are the only caller, and never the display path.** An
/// overlay is a claim about which pixels are sharp, and there is no way to
/// check that claim without looking at the pixels. On screen, on desktop
/// and Android alike, the overlay reaches the compositor as a texture and
/// ARCH §6.1 holds. (Android read it back here until TD-1 was paid off.)
pub fn read_overlay(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
let Some(layer) = self.layers[self.current].as_ref() else {
return Err(GpuError::Readback("no overlay has been rendered".into()));
};
let (w, h) = (layer.width, layer.height);
let unpadded = w * 4;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let padded = unpadded.div_ceil(align) * align;
let buf = self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("focus-peak-readback"),
size: (padded * h) as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut enc = self.ctx.device.create_command_encoder(&Default::default());
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &layer.texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded),
rows_per_image: Some(h),
},
},
wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
);
self.ctx.queue.submit(Some(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
await_mapping(&self.ctx, &rx)?;
let data = slice.get_mapped_range();
let mut out = Vec::with_capacity((unpadded * h) as usize);
for row in 0..h {
let start = (row * padded) as usize;
out.extend_from_slice(&data[start..start + unpadded as usize]);
}
drop(data);
buf.unmap();
Ok((out, w, h))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx() -> Option<GpuContext> {
match pollster::block_on(GpuContext::new_headless()) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("skipping: no GPU adapter ({e})");
None
}
}
}
/// Upload a greyscale frame the pass can read, the way `AdjustPass` hands
/// its output over.
fn frame(ctx: &GpuContext, grey: &[f32], width: u32, height: u32) -> wgpu::Texture {
let rgba: Vec<u8> = grey
.iter()
.flat_map(|v| {
let c = (v.clamp(0.0, 1.0) * 255.0).round() as u8;
[c, c, c, 255]
})
.collect();
let tex = ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("focus-peak-test-frame"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
ctx.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&rgba,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(width * 4),
rows_per_image: Some(height),
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
ctx.queue.submit(std::iter::empty());
tex
}
/// A vertical step from `dark` to `bright`, blurred with a Gaussian of
/// this `sigma` in pixels. `sigma == 0.0` is the hard edge.
///
/// The profile is evaluated analytically rather than by convolving a
/// sampled image, so "the same edge, out of focus" is exactly that and not
/// a second thing the test would also have to trust.
fn step(width: u32, height: u32, dark: f32, bright: f32, sigma: f32) -> Vec<f32> {
let edge = width as f32 / 2.0 - 0.5;
(0..height)
.flat_map(|_| {
(0..width).map(move |x| {
let d = x as f32 - edge;
let t = if sigma <= 0.0 {
if d < 0.0 {
0.0
} else {
1.0
}
} else {
// The error function, which is what a Gaussian blur of
// a step is, via a tanh approximation good to ~1e-4 —
// ample, since the assertions below are about orders
// of magnitude rather than about the fourth digit.
0.5 * (1.0 + (1.202_7 * (d / sigma)).tanh())
};
dark + t * (bright - dark)
})
})
.collect()
}
/// How many pixels of an overlay carry a mark, and what colour they are.
fn marks(pixels: &[u8]) -> (usize, Vec<[u8; 3]>) {
let mut count = 0;
let mut colours: Vec<[u8; 3]> = Vec::new();
for px in pixels.chunks_exact(4) {
if px[3] != 0 {
count += 1;
let c = [px[0], px[1], px[2]];
if !colours.contains(&c) {
colours.push(c);
}
}
}
(count, colours)
}
fn peak(
ctx: &GpuContext,
pass: &mut FocusPeakPass,
grey: &[f32],
w: u32,
h: u32,
settings: FocusPeaking,
) -> Vec<u8> {
let tex = frame(ctx, grey, w, h);
pass.render(&tex, settings).expect("peak");
pass.read_overlay().expect("readback").0
}
#[test]
fn the_three_sensitivities_are_ordered_and_none_of_them_is_zero() {
// Arithmetic, so no device. A threshold of zero marks every pixel of
// every photograph — including a flat sky, where the response is
// whatever the last bit of the encoder rounded to — and an overlay
// that covers the frame says nothing at all.
let (low, med, high) = (
PeakSensitivity::Low.threshold(),
PeakSensitivity::Medium.threshold(),
PeakSensitivity::High.threshold(),
);
assert!(high > 0.0, "a zero threshold marks everything");
assert!(
low > med && med > high,
"more sensitive must mean a lower bar: {low} {med} {high}"
);
assert_eq!(PeakSensitivity::default(), PeakSensitivity::Medium);
}
#[test]
fn every_colour_is_fully_saturated_and_distinct() {
// The overlay competes with the photograph for attention, and a
// desaturated mark loses. Each choice must also be a different colour
// from the others — two entries that render the same would be a menu
// that lies.
let mut seen: Vec<[f32; 3]> = Vec::new();
for c in [
PeakColour::Red,
PeakColour::Yellow,
PeakColour::Cyan,
PeakColour::Magenta,
] {
let rgb = c.rgb();
assert!(
rgb.iter().all(|v| *v == 0.0 || *v == 1.0),
"{c:?} is not a saturated primary: {rgb:?}"
);
assert!(rgb.iter().any(|v| *v > 0.0), "{c:?} is black");
assert!(!seen.contains(&rgb), "{c:?} duplicates another choice");
seen.push(rgb);
}
}
#[test]
fn a_flat_frame_is_marked_nowhere() {
// The floor. An overlay that marks an empty sky is one a photographer
// stops believing within a frame, and there is nothing subtle about
// the failure — it would be the whole picture.
let Some(ctx) = ctx() else { return };
let mut pass = FocusPeakPass::new(&ctx).expect("pass");
let flat = vec![0.45f32; 64 * 64];
let px = peak(&ctx, &mut pass, &flat, 64, 64, FocusPeaking::default());
assert_eq!(marks(&px).0, 0, "flat grey produced marks");
}
#[test]
fn a_sharp_edge_is_marked_and_the_same_edge_defocused_is_not() {
// **The claim the whole overlay rests on**, and the one a gradient
// detector would fail: these two frames have identical contrast and
// differ only in how many pixels the transition is spread over. If
// both are marked, the overlay is an edge detector wearing focus
// peaking's name and it will light up every out-of-focus background
// ever photographed.
let Some(ctx) = ctx() else { return };
let mut pass = FocusPeakPass::new(&ctx).expect("pass");
let (w, h) = (64u32, 16u32);
let sharp = step(w, h, 0.25, 0.75, 0.0);
let soft = step(w, h, 0.25, 0.75, 2.0);
let marked_sharp = marks(&peak(
&ctx,
&mut pass,
&sharp,
w,
h,
FocusPeaking::default(),
))
.0;
let marked_soft = marks(&peak(&ctx, &mut pass, &soft, w, h, FocusPeaking::default())).0;
// Two columns of the sharp edge respond — the pixel each side of the
// transition — so a full-height edge marks 2 per row.
assert_eq!(
marked_sharp,
(2 * h) as usize,
"a hard edge should mark the column each side of it, on every row"
);
assert_eq!(
marked_soft, 0,
"the same edge at sigma 2 is out of focus and must not be marked"
);
}
#[test]
fn sensitivity_decides_how_faint_an_edge_still_counts() {
// The setting doing what its name says, from both sides. A hard edge
// of 0.08 contrast responds at `0.375 * 0.08` = 0.031 — above `High`'s
// bar of 0.020 and below `Medium`'s of 0.040 — so the same photograph
// must be marked at one setting and clean at the other. Without this,
// three menu entries that all behaved identically would pass every
// other test in this file.
let Some(ctx) = ctx() else { return };
let mut pass = FocusPeakPass::new(&ctx).expect("pass");
let (w, h) = (64u32, 8u32);
let faint = step(w, h, 0.46, 0.54, 0.0);
let at = |pass: &mut FocusPeakPass, sensitivity| {
marks(&peak(
&ctx,
pass,
&faint,
w,
h,
FocusPeaking {
sensitivity,
..Default::default()
},
))
.0
};
assert!(
at(&mut pass, PeakSensitivity::High) > 0,
"a faint but sharp edge is exactly what High is for"
);
assert_eq!(
at(&mut pass, PeakSensitivity::Medium),
0,
"Medium should hold its bar above an 0.08 edge"
);
assert_eq!(at(&mut pass, PeakSensitivity::Low), 0);
}
#[test]
fn the_mark_is_the_colour_that_was_asked_for_and_the_rest_is_transparent() {
// Both halves matter. The colour, because a menu that quietly draws
// red whatever is chosen is worse than not offering the choice; and
// the transparency, because the overlay is composited over the
// photograph and an alpha of even 1/255 across the frame is a veil
// over every judgement made through it.
let Some(ctx) = ctx() else { return };
let mut pass = FocusPeakPass::new(&ctx).expect("pass");
let (w, h) = (32u32, 8u32);
let sharp = step(w, h, 0.2, 0.8, 0.0);
for colour in [
PeakColour::Red,
PeakColour::Yellow,
PeakColour::Cyan,
PeakColour::Magenta,
] {
let px = peak(
&ctx,
&mut pass,
&sharp,
w,
h,
FocusPeaking {
colour,
..Default::default()
},
);
let (count, colours) = marks(&px);
assert!(count > 0, "{colour:?} marked nothing");
let expected: [u8; 3] = colour.rgb().map(|v| (v * 255.0).round() as u8);
assert_eq!(colours, vec![expected], "{colour:?} drew the wrong ink");
// Everything unmarked is fully transparent *and* black, which is
// what makes the layer correct whether the compositor treats it as
// premultiplied or not.
for texel in px.chunks_exact(4) {
if texel[3] == 0 {
assert!(
texel[..3].iter().all(|c| *c == 0),
"an unmarked texel carried colour under a zero alpha"
);
} else {
assert_eq!(texel[3], 255, "a mark was drawn part-way transparent");
}
}
}
}
#[test]
fn the_edge_of_the_frame_is_not_marked_by_its_own_edge() {
// The clamp in `neighbour`. Wrapping instead would fold the right-hand
// column of the picture into the left-hand one's neighbourhood, and a
// photograph whose two sides differ — which is most of them — would be
// marked down both borders regardless of focus.
let Some(ctx) = ctx() else { return };
let mut pass = FocusPeakPass::new(&ctx).expect("pass");
let (w, h) = (33u32, 17u32);
// Dark on the left, bright on the right, with the transition a long
// way from either border.
let split = step(w, h, 0.1, 0.9, 0.0);
let px = peak(&ctx, &mut pass, &split, w, h, FocusPeaking::default());
for y in 0..h {
for x in [0u32, w - 1] {
let a = px[(((y * w + x) * 4) + 3) as usize];
assert_eq!(a, 0, "the frame border at ({x}, {y}) was marked");
}
}
// A size that is not a multiple of the 8x8 workgroup, so the edge
// groups run off the image: every texel must still have been written.
assert_eq!(px.len(), (w * h * 4) as usize);
}
#[test]
fn consecutive_overlays_are_different_textures() {
// Slint compares the image property by value to decide whether to
// repaint, so two frames wrapping one texture compare equal and the
// second is never drawn. `AdjustPass` keeps two targets for this
// reason; the overlay beside it has to do the same or it freezes on
// whatever it first showed.
let Some(ctx) = ctx() else { return };
let mut pass = FocusPeakPass::new(&ctx).expect("pass");
let grey = vec![0.5f32; 16 * 16];
let tex = frame(&ctx, &grey, 16, 16);
let first =
std::ptr::from_ref(pass.render(&tex, FocusPeaking::default()).expect("first")) as usize;
let second = std::ptr::from_ref(pass.render(&tex, FocusPeaking::default()).expect("second"))
as usize;
assert_ne!(
first, second,
"two consecutive overlays landed in the same texture"
);
}
#[test]
fn a_steady_viewport_allocates_nothing_after_the_first_two_frames() {
// Reallocating a viewport-sized texture per frame costs a great deal
// of bandwidth and renders identically, which is why this is a counter
// and not an assertion about the picture. Two allocations, not one:
// the pair alternates, so the second frame builds the other slot.
let Some(ctx) = ctx() else { return };
let mut pass = FocusPeakPass::new(&ctx).expect("pass");
let grey = vec![0.5f32; 64 * 64];
let tex = frame(&ctx, &grey, 64, 64);
for _ in 0..8 {
pass.render(&tex, FocusPeaking::default()).expect("render");
}
assert_eq!(pass.dispatches(), 8);
assert_eq!(
pass.allocations(),
2,
"the overlay was reallocated per frame"
);
// A resize is the one thing that must reallocate: a stale overlay of
// the wrong size would be composited stretched over the new frame.
let small = vec![0.5f32; 32 * 32];
let smaller = frame(&ctx, &small, 32, 32);
pass.render(&smaller, FocusPeaking::default())
.expect("render");
assert_eq!(pass.allocations(), 3);
}
#[test]
fn a_cleared_pass_has_no_overlay_to_composite() {
// Switching peaking off, or opening a different photograph, must leave
// nothing behind. One interface bug away from laying one frame's focus
// marks over another's, which is the worst thing an instrument can do:
// be confidently about the wrong picture.
let Some(ctx) = ctx() else { return };
let mut pass = FocusPeakPass::new(&ctx).expect("pass");
let grey = vec![0.5f32; 16 * 16];
let tex = frame(&ctx, &grey, 16, 16);
pass.render(&tex, FocusPeaking::default()).expect("render");
assert!(pass.overlay().is_some());
pass.clear();
assert!(pass.overlay().is_none());
assert!(
pass.read_overlay().is_err(),
"a cleared pass must not hand out a stale overlay"
);
}
#[test]
fn the_overlay_is_ready_well_inside_its_budget() {
// NFR-P14: ready within 100 ms of the preview on desktop, 150 ms on
// Android. The bound asserted here is 50 ms at 4K, which is half the
// desktop budget and a third of Android's, and is still around two
// orders of magnitude above what the dispatch actually costs — chosen
// so that an unrelated machine under load does not fail the suite,
// while a change that made this a multi-pass or readback-bound
// operation could not possibly stay under it.
//
// The readback is deliberately outside the timed region. It is a
// property of this test rather than of the overlay, and at 4K it is
// the 7 ms transfer ARCH §6.1 exists to keep off the frame path.
let Some(ctx) = ctx() else { return };
let mut pass = FocusPeakPass::new(&ctx).expect("pass");
let (w, h) = (3840u32, 2160u32);
let tex = frame(&ctx, &step(w, h, 0.2, 0.8, 0.0), w, h);
// One frame to allocate the layer and warm the pipeline; a resize is
// not what the budget is about.
pass.render(&tex, FocusPeaking::default()).expect("warm");
ctx.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("idle");
let start = std::time::Instant::now();
pass.render(&tex, FocusPeaking::default()).expect("render");
ctx.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("idle");
let elapsed = start.elapsed();
assert!(
elapsed.as_millis() < 50,
"the overlay took {elapsed:?} at {w}x{h}; NFR-P14 allows 100 ms"
);
}
}