Mark what is in focus, so a frame can be judged without zooming to 100%
FR-CULL-3's focus peaking. One compute dispatch measures local contrast in WGSL and writes an overlay texture; on desktop it reaches Slint through the same zero-copy wgpu import the canvas uses, so nothing per-pixel touches the CPU on the frame path. With peaking off the cost is zero and structurally so: focus_overlay opens with `let settings = self.peaking?;` before the frame is touched, and clearing drops both overlay textures, so no VRAM is held either. NFR-P14 is met by construction rather than by measurement -- one dispatch, no second render, no pipeline compile after session open, and a test asserting allocations stay at 2 over eight frames. The budget test asserts 50ms at 4K rather than a tight bound, deliberately: a tight bound fails on a loaded machine and gets deleted, which is worse than a loose one that still catches the regression that matters. TD-1 is amended rather than joined by a TD-6: on Android the overlay rides the readback that already exists there, roughly doubling that transfer while peaking is on, and TD-1's own "Done when" removes both because both are the same missing capability. Verified: cargo fmt clean; clippy --workspace --all-targets -D warnings green, which also compiles peaking.slint through dr-ui's build.rs; 11 focus GPU tests and 79 baseline dr-gpu tests pass; 511 dr-ui tests pass. Not verified: the cfg(target_os = "android") arm, which the host-target clippy never compiled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ mod adjust;
|
||||
mod demosaic;
|
||||
mod detail;
|
||||
mod error;
|
||||
mod focus;
|
||||
mod histogram;
|
||||
mod mask;
|
||||
mod readback;
|
||||
@@ -33,6 +34,7 @@ pub use adjust::AdjustPass;
|
||||
pub use demosaic::{DemosaicedImage, Demosaicer};
|
||||
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
|
||||
pub use error::GpuError;
|
||||
pub use focus::{FocusPeakPass, FocusPeaking, PeakColour, PeakSensitivity};
|
||||
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
|
||||
// at all at a crate root shared with demosaic and segmentation.
|
||||
pub use histogram::{Histogram, HistogramPass, BINS as HISTOGRAM_BINS};
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// TRACES: FR-CULL-3 | NFR-P14
|
||||
// Marking what is sharp, in a layer laid over the frame rather than into it.
|
||||
//
|
||||
// # Why the top octave, and not a gradient
|
||||
//
|
||||
// The obvious detector is a gradient magnitude — Sobel, or a central
|
||||
// difference — and it is the wrong one, for a reason that decides whether the
|
||||
// overlay is useful at all. A gradient answers "is there an edge here", and a
|
||||
// defocused edge is still an edge: blur a 100-code step with a two-pixel
|
||||
// Gaussian and the peak gradient is still around 20 codes per pixel, larger
|
||||
// than a genuinely sharp edge across a low-contrast texture. Peaking built on
|
||||
// gradients lights up the out-of-focus background of every portrait ever
|
||||
// taken, which is the frame it exists to reject.
|
||||
//
|
||||
// What separates sharp from soft is *scale*, not amplitude. Defocus is a
|
||||
// low-pass: it removes the top octave and leaves everything below it intact.
|
||||
// So the detector is a high-pass — this pixel against the mean of its eight
|
||||
// neighbours, a discrete Laplacian — which by construction responds only to
|
||||
// the frequencies defocus destroys.
|
||||
//
|
||||
// The arithmetic, on a one-dimensional step of height D:
|
||||
//
|
||||
// | profile | abs(centre - mean of 8) |
|
||||
// |--------------------------|-------------------------|
|
||||
// | hard step, 1 px | 0.375 D |
|
||||
// | Gaussian blur, sigma 1 | ~0.10 D |
|
||||
// | Gaussian blur, sigma 2 | ~0.03 D |
|
||||
// | linear ramp, any slope | 0 |
|
||||
//
|
||||
// The ramp row is the property being bought: the smooth luminance falloff
|
||||
// across an out-of-focus highlight scores zero however bright it is.
|
||||
//
|
||||
// # Why luma, and why the histogram's luma
|
||||
//
|
||||
// One channel rather than three, because a colour edge carrying no luminance
|
||||
// difference is both rare and, at the acuity an overlay is read at, invisible.
|
||||
// The weights are `histogram.wgsl`'s 54/183/19 over 256 — the same Rec.709
|
||||
// weighting on the same encoded values — so the two instruments in this
|
||||
// application agree about what "luma" means. Two definitions of brightness in
|
||||
// one panel is the kind of disagreement nobody finds until it has already
|
||||
// misled someone.
|
||||
//
|
||||
// # Why the frame is read where it is encoded, and not in linear light
|
||||
//
|
||||
// This runs on the output of the display transform, on encoded values, and
|
||||
// that is deliberate: a fixed difference in sRGB code values is roughly
|
||||
// equally visible wherever it sits in the range, which is what a transfer
|
||||
// curve is for. Measured in linear light the same detector would need a
|
||||
// threshold that varied with exposure, and a shadow texture the photographer
|
||||
// can plainly see would score a hundredth of the identical texture in the
|
||||
// highlights. The encoding has already done the normalisation, so the
|
||||
// threshold is one number.
|
||||
//
|
||||
// # Why this writes a layer and not the picture
|
||||
//
|
||||
// The frame the compositor is handed is also what the histogram counts and
|
||||
// what an export renders (`app.slint`, on the region overlay: a diagnostic
|
||||
// "must not reach the histogram, an export, or the texture the develop pass
|
||||
// hands the compositor"). So the marks go in their own texture — transparent
|
||||
// everywhere except where something is in focus — and the compositor blends
|
||||
// them. Nothing about the photograph changes, and the peaking overlay cannot
|
||||
// leak into a measurement or a file.
|
||||
//
|
||||
// Alpha is written as exactly 0 or exactly 1, never between. The importing
|
||||
// compositor's convention for whether colour arrives premultiplied is not
|
||||
// something this shader can see, and at those two values the two conventions
|
||||
// agree — which is a cheaper guarantee than being right about which one it is.
|
||||
|
||||
struct Params {
|
||||
width: u32,
|
||||
height: u32,
|
||||
// Luma difference at which a pixel is called in focus. See
|
||||
// `PeakSensitivity::threshold` for where the three values come from.
|
||||
threshold: f32,
|
||||
// std140 rounds the scalar block up to 16 bytes before the vec4; named so
|
||||
// the Rust struct's padding is visibly the same shape.
|
||||
pad_0: u32,
|
||||
// The mark's colour, fully saturated. Its alpha is ignored — see above.
|
||||
marker: vec4<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var frame: texture_2d<f32>;
|
||||
@group(0) @binding(1) var<uniform> params: Params;
|
||||
@group(0) @binding(2) var marks: texture_storage_2d<rgba8unorm, write>;
|
||||
|
||||
/// Rec.709 luma of an encoded triple, weighted exactly as `histogram.wgsl`
|
||||
/// weights it. 54 + 183 + 19 is 256, so the weights sum to unity.
|
||||
fn luma(c: vec3<f32>) -> f32 {
|
||||
return dot(c, vec3<f32>(54.0, 183.0, 19.0) / 256.0);
|
||||
}
|
||||
|
||||
/// A neighbour, with the frame edge held rather than wrapped.
|
||||
///
|
||||
/// Clamping duplicates the edge pixel into the missing half of the
|
||||
/// neighbourhood, which pulls the mean towards the centre and so biases the
|
||||
/// response *down* on the outermost row and column. That is the right
|
||||
/// direction to be wrong in: the failure is a missing mark at the frame edge,
|
||||
/// where nobody is judging focus, rather than a false mark produced by
|
||||
/// folding the opposite side of the picture into the kernel.
|
||||
fn neighbour(x: i32, y: i32) -> f32 {
|
||||
let cx = clamp(x, 0i, i32(params.width) - 1i);
|
||||
let cy = clamp(y, 0i, i32(params.height) - 1i);
|
||||
return luma(textureLoad(frame, vec2<i32>(cx, cy), 0).rgb);
|
||||
}
|
||||
|
||||
// 8x8, matching the detail stage's dispatch. Each texel is loaded by nine
|
||||
// invocations and no workgroup-memory tile is built to avoid that: at viewport
|
||||
// resolution the reads are perfectly coherent and the texture cache serves
|
||||
// eight of the nine. The budget is NFR-P14's 100 ms against a dispatch
|
||||
// measured in tenths of a millisecond, so there is nothing here worth the
|
||||
// complexity of a tiled load.
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (gid.x >= params.width || gid.y >= params.height) {
|
||||
return;
|
||||
}
|
||||
let x = i32(gid.x);
|
||||
let y = i32(gid.y);
|
||||
|
||||
// The eight neighbours, centre excluded. Excluded rather than folded in
|
||||
// because it makes the response readable: `abs(c - mean8)` is the height
|
||||
// of this pixel above its surroundings in the same units as the step it
|
||||
// sits on, so the threshold can be quoted as a luma difference rather than
|
||||
// as eight-ninths of one.
|
||||
var sum = 0.0;
|
||||
for (var dy = -1; dy <= 1; dy = dy + 1) {
|
||||
for (var dx = -1; dx <= 1; dx = dx + 1) {
|
||||
if (dx != 0 || dy != 0) {
|
||||
sum = sum + neighbour(x + dx, y + dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
let centre = luma(textureLoad(frame, vec2<i32>(x, y), 0).rgb);
|
||||
let response = abs(centre - sum / 8.0);
|
||||
|
||||
if (response >= params.threshold) {
|
||||
textureStore(marks, vec2<i32>(x, y), vec4<f32>(params.marker.rgb, 1.0));
|
||||
} else {
|
||||
textureStore(marks, vec2<i32>(x, y), vec4<f32>(0.0, 0.0, 0.0, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,27 @@ readback is at viewport resolution, not sensor resolution. The 7.43 ms at 4K in
|
||||
not the bill. **It has not been measured on the device**, which is the first thing to do if the
|
||||
develop view feels heavy on the tablet; do not assume this is the cause without a number.
|
||||
|
||||
### And a second transfer, while focus peaking is on
|
||||
|
||||
Added 2026-08-29 with FR-CULL-3. The focus-peaking overlay is a compute pass writing its own
|
||||
`Rgba8Unorm` texture, which on desktop reaches the compositor with no copy — but on Android there is
|
||||
no more a path for *that* texture than for the frame it belongs to, and an overlay that stayed on
|
||||
the device while the picture underneath it did not would simply never be seen. So
|
||||
`FocusPeakPass::read_overlay` follows the frame back through memory, and the Android frame path
|
||||
carries **two** full-resolution `copy_texture_to_buffer` transfers instead of one.
|
||||
|
||||
This is recorded under TD-1 rather than as its own entry because it is not an independent choice.
|
||||
It exists only because TD-1 exists, it is bounded by the same thing — `render` fits the pass to the
|
||||
canvas, so both transfers are at viewport resolution — and TD-1's "Done when" already covers it:
|
||||
whichever of the three fixes above lands removes the readback for the frame and the overlay
|
||||
together, because both are the same missing capability.
|
||||
|
||||
Two things worth saying plainly. The doubling is **reasoned, not measured on the device** — the same
|
||||
gap TD-1 admits about its own cost, and the reason neither number should be quoted as a measurement.
|
||||
And it is paid only while the photographer has the overlay switched on: `DevelopSession::focus_overlay`
|
||||
returns on its first line when peaking is off, so with it off there is no dispatch and no transfer,
|
||||
and the Android frame path is exactly what it was before this feature existed.
|
||||
|
||||
### Paying it off
|
||||
|
||||
Any one of these removes it:
|
||||
|
||||
+124
-1
@@ -14,7 +14,8 @@ use std::sync::Arc;
|
||||
|
||||
use dr_decode::RawImage;
|
||||
use dr_gpu::{
|
||||
AdjustPass, DemosaicedImage, Demosaicer, GpuContext, Histogram, HistogramPass, MaskPass,
|
||||
AdjustPass, DemosaicedImage, Demosaicer, FocusPeakPass, FocusPeaking, GpuContext, Histogram,
|
||||
HistogramPass, MaskPass,
|
||||
};
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource};
|
||||
|
||||
@@ -722,6 +723,25 @@ pub struct DevelopSession {
|
||||
/// old driver, a device without the storage-buffer atomics it needs — the
|
||||
/// photographer loses the histogram and keeps the photograph.
|
||||
histogram: Option<HistogramPass>,
|
||||
/// TRACES: FR-CULL-3
|
||||
/// The focus-peaking overlay, on the same terms as the histogram above:
|
||||
/// optional, because a device that cannot compile the pass is still a
|
||||
/// device that can develop the photograph. What is lost is an instrument,
|
||||
/// not the picture.
|
||||
peak: Option<FocusPeakPass>,
|
||||
/// TRACES: FR-CULL-3
|
||||
/// What the photographer asked the overlay to look like, or `None` for
|
||||
/// off.
|
||||
///
|
||||
/// **Interface state, not part of the edit** — the same category as
|
||||
/// `show_overlay` beside it. It changes no pixel of the photograph, it is
|
||||
/// not in the sidecar, and it is not on the undo stack: pressing undo
|
||||
/// after switching peaking on should take back the last *edit*, not the
|
||||
/// last thing looked at.
|
||||
///
|
||||
/// An `Option` rather than a bool plus a settings field, so that "off" and
|
||||
/// "on, in some configuration" cannot disagree with each other.
|
||||
peaking: Option<FocusPeaking>,
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The region map local masks select from, once it has been computed.
|
||||
@@ -889,6 +909,10 @@ impl DevelopSession {
|
||||
histogram: HistogramPass::new(ctx)
|
||||
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
|
||||
.ok(),
|
||||
peak: FocusPeakPass::new(ctx)
|
||||
.inspect_err(|e| log::warn!("no focus peaking on this device: {e}"))
|
||||
.ok(),
|
||||
peaking: None,
|
||||
segmentation: None,
|
||||
masks: None,
|
||||
subjects: None,
|
||||
@@ -2903,6 +2927,105 @@ impl DevelopSession {
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// TRACES: FR-CULL-3
|
||||
/// Whether this device could build the focus-peaking overlay.
|
||||
///
|
||||
/// Asked by the interface so that it can say the overlay is unavailable
|
||||
/// rather than offer a switch that does nothing. The same courtesy the
|
||||
/// histogram is not paid, and should be: a control that silently does
|
||||
/// nothing is worse than one that is visibly absent.
|
||||
pub fn peaking_available(&self) -> bool {
|
||||
self.peak.is_some()
|
||||
}
|
||||
|
||||
/// TRACES: FR-CULL-3
|
||||
/// What the overlay is set to, or `None` when it is off.
|
||||
pub fn peaking(&self) -> Option<FocusPeaking> {
|
||||
self.peaking
|
||||
}
|
||||
|
||||
/// TRACES: FR-CULL-3
|
||||
/// Switch the overlay on with these settings, or off.
|
||||
///
|
||||
/// Asking for peaking on a device that could not build the pass leaves it
|
||||
/// off, so that [`Self::peaking`] never claims something is being drawn
|
||||
/// that is not. Switching off drops the overlay textures rather than
|
||||
/// merely stopping drawing them: a resident overlay from the last frame is
|
||||
/// one interface bug away from being laid over the next photograph.
|
||||
pub fn set_peaking(&mut self, settings: Option<FocusPeaking>) {
|
||||
self.peaking = settings.filter(|_| self.peak.is_some());
|
||||
if self.peaking.is_none() {
|
||||
if let Some(pass) = self.peak.as_mut() {
|
||||
pass.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-CULL-3 | NFR-P14
|
||||
/// Mark the in-focus regions of the frame that is currently on the canvas.
|
||||
///
|
||||
/// **Reads the frame [`Self::render`] last produced**, exactly as
|
||||
/// [`Self::histogram`] does and for the same reason: the overlay has to
|
||||
/// describe what the photographer is looking at, and rendering a second
|
||||
/// time to measure it would cost a pass and admit the possibility of the
|
||||
/// two disagreeing about the picture.
|
||||
///
|
||||
/// That the frame is the displayed one is what makes the marks land where
|
||||
/// the eye is. It is at viewport resolution, cropped and zoomed as the
|
||||
/// view is, and — the point of FR-CULL-3 — descended from sensor data
|
||||
/// through the demosaic rather than from the camera's embedded JPEG, whose
|
||||
/// in-body sharpening this would otherwise be measuring at least as much
|
||||
/// as the lens.
|
||||
///
|
||||
/// **Call this only after a settled render.** See
|
||||
/// [`dr_gpu::FocusPeakPass::render`] for why a half-resolution draft frame
|
||||
/// cannot be measured for sharpness.
|
||||
///
|
||||
/// `None` where nothing has been rendered, where peaking is off, or where
|
||||
/// the device could not build the pass.
|
||||
pub fn focus_overlay(&mut self) -> Option<slint::Image> {
|
||||
let settings = self.peaking?;
|
||||
// Cloned rather than borrowed: a `wgpu::Texture` handle is an `Arc`,
|
||||
// and holding a shared borrow of `self.adjust` across the mutable
|
||||
// borrow of `self.peak` would cost a `Self { .. }` destructure to say
|
||||
// something the clone says in one word.
|
||||
let frame = self.adjust.output()?.clone();
|
||||
let pass = self.peak.as_mut()?;
|
||||
let overlay = pass
|
||||
.render(&frame, settings)
|
||||
.inspect_err(|e| log::warn!("focus peaking failed: {e}"))
|
||||
.ok()?
|
||||
.clone();
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
// A layer over the canvas rather than a tint in it, so nothing
|
||||
// here reaches the histogram or an export — see `FocusPeakPass`
|
||||
// for the whole of that argument.
|
||||
slint::Image::try_from(overlay)
|
||||
.inspect_err(|e| log::warn!("the focus overlay is not importable: {e}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
// Android draws with Skia over OpenGL and cannot sample a
|
||||
// `wgpu::Texture`, so the overlay follows the frame it belongs to back
|
||||
// through memory (technical-debt.md TD-1). The measurement still
|
||||
// happens on the GPU; only this last hop does not.
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let _ = overlay;
|
||||
let (rgba, w, h) = pass
|
||||
.read_overlay()
|
||||
.inspect_err(|e| log::warn!("reading the focus overlay back: {e}"))
|
||||
.ok()?;
|
||||
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(w, h);
|
||||
let wanted = (w as usize) * (h as usize) * 4;
|
||||
let src = &rgba[..wanted.min(rgba.len())];
|
||||
buf.make_mut_bytes()[..src.len()].copy_from_slice(src);
|
||||
Some(slint::Image::from_rgba8(buf))
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the *whole* frame for the crop overlay to be drawn over.
|
||||
///
|
||||
/// Crop mode cannot use [`Self::render`]: that applies the crop, so the
|
||||
|
||||
@@ -39,6 +39,7 @@ mod library_ui;
|
||||
mod live_style;
|
||||
mod masks_ui;
|
||||
mod net_runtime;
|
||||
mod peaking;
|
||||
mod presets;
|
||||
mod remote;
|
||||
mod segmentation;
|
||||
@@ -316,6 +317,12 @@ fn reset_view_state(window: &AppWindow) {
|
||||
// beside the next one's filename is a confident, precise lie, and the gap
|
||||
// before the new frame settles is exactly long enough to read it.
|
||||
window.set_histogram(histogram::empty());
|
||||
// TRACES: FR-CULL-3
|
||||
// The marks go down with it, and for the same reason. What is *not* reset
|
||||
// is whether peaking is switched on: that is a way of looking at a folder
|
||||
// rather than a property of one photograph, so it survives to the next
|
||||
// frame — see `chosen_peaking` for the whole of that argument.
|
||||
window.set_focus_overlay_ready(false);
|
||||
// TRACES: FR-DEV-3
|
||||
// The region map belongs to one photograph. Carrying the stack, the
|
||||
// overlay or the crosshair to the next one would offer a selection of
|
||||
@@ -1464,11 +1471,24 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
// is redrawn, and those are very different rates.
|
||||
let drawn_history: Rc<Cell<Option<u64>>> = Rc::new(Cell::new(None));
|
||||
|
||||
// TRACES: FR-CULL-3
|
||||
// How the photographer wants focus peaking drawn, or `None` for off.
|
||||
//
|
||||
// **Held here rather than on the session, which is the opposite of where
|
||||
// every edit lives.** A session is one photograph; peaking is a way of
|
||||
// *looking* at a folder of them. Someone culling three thousand frames
|
||||
// switches it on once, and a flag that reset with the session would ask
|
||||
// them to switch it on three thousand times — which is why
|
||||
// `reset_view_state` deliberately leaves it alone while emptying the
|
||||
// histogram beside it.
|
||||
let chosen_peaking: Rc<Cell<Option<dr_gpu::FocusPeaking>>> = Rc::new(Cell::new(None));
|
||||
|
||||
let render_now: Render = {
|
||||
let session = session.clone();
|
||||
let viewport = viewport.clone();
|
||||
let drawn_history = drawn_history.clone();
|
||||
let display = display.clone();
|
||||
let chosen_peaking = chosen_peaking.clone();
|
||||
Rc::new(move |window: &AppWindow, draft: bool| {
|
||||
let mut slot = session.borrow_mut();
|
||||
let Some(s) = slot.as_mut() else { return };
|
||||
@@ -1529,6 +1549,16 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
// arrival takes.
|
||||
spots_ui::sync_panel(window, s);
|
||||
|
||||
// TRACES: FR-CULL-3
|
||||
// The session owns the pass and the interface owns the choice, so
|
||||
// they are joined here — on the one path every frame takes, which
|
||||
// is also what makes a photograph opened with peaking already on
|
||||
// arrive with its marks rather than without them.
|
||||
if s.peaking() != chosen_peaking.get() {
|
||||
s.set_peaking(chosen_peaking.get());
|
||||
}
|
||||
window.set_peaking_available(s.peaking_available());
|
||||
|
||||
let (mut w, mut h) = *viewport.borrow();
|
||||
|
||||
// **Half resolution while the gesture is still moving.**
|
||||
@@ -1591,6 +1621,34 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
.map_or_else(histogram::empty, histogram::view),
|
||||
);
|
||||
}
|
||||
|
||||
// TRACES: FR-CULL-3 | NFR-P14
|
||||
// **Marked on the settled frame and no other**, and unlike
|
||||
// the histogram beside it the marks are taken *down* in
|
||||
// between rather than left standing.
|
||||
//
|
||||
// The reason is not budget — the dispatch is a fraction of
|
||||
// a millisecond and would fit inside a draft frame
|
||||
// comfortably. It is that peaking measures the top octave
|
||||
// of the frame it is given, and a draft frame is rendered
|
||||
// at half resolution: a defocused edge that spans four
|
||||
// pixels there spans two, which is the signature of a
|
||||
// sharp one. Measuring it would mark the out-of-focus
|
||||
// background of every photograph, briefly, during every
|
||||
// drag. A stale overlay is no better, because a pan moves
|
||||
// the picture out from under it.
|
||||
//
|
||||
// So the marks pause while a control is moving and return
|
||||
// when it stops, which the panel says out loud rather than
|
||||
// leaving to be discovered.
|
||||
let overlay = (!draft).then(|| s.focus_overlay()).flatten();
|
||||
match overlay {
|
||||
Some(image) => {
|
||||
window.set_focus_overlay(image);
|
||||
window.set_focus_overlay_ready(true);
|
||||
}
|
||||
None => window.set_focus_overlay_ready(false),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("render failed: {e}");
|
||||
@@ -1598,6 +1656,11 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
// No frame, so nothing to describe. The stale plot would
|
||||
// otherwise sit beside the error message looking current.
|
||||
window.set_histogram(histogram::empty());
|
||||
// TRACES: FR-CULL-3
|
||||
// And nothing to mark. Focus marks over the last frame
|
||||
// that rendered, beside a message saying this one did not,
|
||||
// is the same confident lie in a second instrument.
|
||||
window.set_focus_overlay_ready(false);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2818,6 +2881,70 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-CULL-3
|
||||
// The peaking switch and its two choices.
|
||||
//
|
||||
// All three write `chosen_peaking` and then redraw, because the marks are
|
||||
// produced by a compute pass over the rendered frame: there is nothing the
|
||||
// interface can change about the overlay that does not require the frame
|
||||
// to be measured again. Turning peaking *off* redraws for the same reason
|
||||
// — that render is what drops the overlay textures and clears the flag.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let chosen = chosen_peaking.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_peaking_toggled(move |on| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
// Built from the chips as they currently stand rather than from a
|
||||
// remembered value: they are what the photographer can see, and an
|
||||
// overlay that came back in a configuration the panel is not
|
||||
// showing would be the panel lying about itself.
|
||||
let next = on.then(|| dr_gpu::FocusPeaking {
|
||||
sensitivity: peaking::sensitivity(w.get_peaking_sensitivity()),
|
||||
colour: peaking::colour(w.get_peaking_colour()),
|
||||
});
|
||||
chosen.set(next);
|
||||
w.set_peaking_on(next.is_some());
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let chosen = chosen_peaking.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_peaking_sensitivity_picked(move |index| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
w.set_peaking_sensitivity(index);
|
||||
// Only reachable while peaking is on — the chips are not drawn
|
||||
// otherwise — but written as a conditional rather than an
|
||||
// `expect`, because a panel is free to change its mind about that
|
||||
// and nothing here should fall over when it does.
|
||||
if let Some(mut current) = chosen.get() {
|
||||
current.sensitivity = peaking::sensitivity(index);
|
||||
chosen.set(Some(current));
|
||||
redraw(&w);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let chosen = chosen_peaking.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_peaking_colour_picked(move |index| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
w.set_peaking_colour(index);
|
||||
if let Some(mut current) = chosen.get() {
|
||||
current.colour = peaking::colour(index);
|
||||
chosen.set(Some(current));
|
||||
redraw(&w);
|
||||
}
|
||||
});
|
||||
}
|
||||
// The chips open on whatever the vocabulary calls its default, so the
|
||||
// panel and the pass agree before anything has been pressed.
|
||||
window.set_peaking_sensitivity(peaking::sensitivity_index(Default::default()));
|
||||
window.set_peaking_colour(peaking::colour_index(Default::default()));
|
||||
|
||||
// TRACES: FR-DSP-8 | FR-DSP-6
|
||||
// And which display that canvas is on, from now until the window closes.
|
||||
display_ui::attach(&window, &display, &viewport, redraw.clone());
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! TRACES: FR-CULL-3
|
||||
//! The focus-peaking vocabulary, as the indices a chip row can carry.
|
||||
//!
|
||||
//! `dr_gpu` decides what peaking *is* — the measure, the thresholds, the
|
||||
//! marks. This decides how a menu of three sensitivities and four colours
|
||||
//! crosses the boundary into Slint, which has no notion of a Rust enum and
|
||||
//! carries the choice as an `int` into an array of labels.
|
||||
//!
|
||||
//! That translation is small and it is the kind of small that goes wrong
|
||||
//! silently. An index the interface sends that Rust reads as a different
|
||||
//! variant produces a control that changes something other than what it says,
|
||||
//! which nobody notices as a bug — they notice it as peaking behaving oddly.
|
||||
//! So the order lives in one place here, both directions are asserted to round
|
||||
//! trip, and a test checks that the labels in `ui/peaking.slint` still number
|
||||
//! the same as the vocabularies they claim to name.
|
||||
//!
|
||||
//! Free-standing functions over plain integers, deliberately, for the reason
|
||||
//! `crate::histogram` gives: none of this needs a GPU, a window or a
|
||||
//! photograph to be checked, and all of it is invisible when wrong.
|
||||
|
||||
use dr_gpu::{PeakColour, PeakSensitivity};
|
||||
|
||||
/// The sensitivities, in the order the chip row shows them.
|
||||
///
|
||||
/// Least sensitive first, so the row reads left to right as "mark less" to
|
||||
/// "mark more" — the axis the photographer is actually moving along.
|
||||
pub(crate) const SENSITIVITIES: [PeakSensitivity; 3] = [
|
||||
PeakSensitivity::Low,
|
||||
PeakSensitivity::Medium,
|
||||
PeakSensitivity::High,
|
||||
];
|
||||
|
||||
/// The mark colours, in the order the chip row shows them.
|
||||
pub(crate) const COLOURS: [PeakColour; 4] = [
|
||||
PeakColour::Red,
|
||||
PeakColour::Yellow,
|
||||
PeakColour::Cyan,
|
||||
PeakColour::Magenta,
|
||||
];
|
||||
|
||||
/// The sensitivity an index names.
|
||||
///
|
||||
/// Out of range falls back to the default rather than panicking. The index
|
||||
/// arrives from the interface, and the interface is the half of this that can
|
||||
/// be recompiled without recompiling the other — a chip row that grew an entry
|
||||
/// should degrade to a sane setting, not take the application down mid-cull.
|
||||
pub(crate) fn sensitivity(index: i32) -> PeakSensitivity {
|
||||
usize::try_from(index)
|
||||
.ok()
|
||||
.and_then(|i| SENSITIVITIES.get(i).copied())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The colour an index names, on the same terms.
|
||||
pub(crate) fn colour(index: i32) -> PeakColour {
|
||||
usize::try_from(index)
|
||||
.ok()
|
||||
.and_then(|i| COLOURS.get(i).copied())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Which chip is lit for this sensitivity.
|
||||
pub(crate) fn sensitivity_index(value: PeakSensitivity) -> i32 {
|
||||
SENSITIVITIES.iter().position(|s| *s == value).unwrap_or(0) as i32
|
||||
}
|
||||
|
||||
/// Which chip is lit for this colour.
|
||||
pub(crate) fn colour_index(value: PeakColour) -> i32 {
|
||||
COLOURS.iter().position(|c| *c == value).unwrap_or(0) as i32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_variant_appears_exactly_once_in_its_row() {
|
||||
// A variant missing from the row is a setting the photographer cannot
|
||||
// reach; one listed twice is two chips that do the same thing, of
|
||||
// which only the first can ever look selected. Both are invisible in
|
||||
// the running application until somebody presses the wrong chip.
|
||||
for s in SENSITIVITIES {
|
||||
assert_eq!(
|
||||
SENSITIVITIES.iter().filter(|x| **x == s).count(),
|
||||
1,
|
||||
"{s:?} is listed more than once"
|
||||
);
|
||||
}
|
||||
for c in COLOURS {
|
||||
assert_eq!(COLOURS.iter().filter(|x| **x == c).count(), 1);
|
||||
}
|
||||
// Named rather than counted, so adding a variant to `dr_gpu` without
|
||||
// adding it here fails to compile instead of passing quietly.
|
||||
assert!(SENSITIVITIES.contains(&PeakSensitivity::Low));
|
||||
assert!(SENSITIVITIES.contains(&PeakSensitivity::Medium));
|
||||
assert!(SENSITIVITIES.contains(&PeakSensitivity::High));
|
||||
assert!(COLOURS.contains(&PeakColour::Red));
|
||||
assert!(COLOURS.contains(&PeakColour::Yellow));
|
||||
assert!(COLOURS.contains(&PeakColour::Cyan));
|
||||
assert!(COLOURS.contains(&PeakColour::Magenta));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_index_and_its_variant_agree_in_both_directions() {
|
||||
// The failure this catches is a chip that lights up under the pointer
|
||||
// while a different setting takes effect — the two directions drifting
|
||||
// apart is exactly what one shared array is here to prevent, and the
|
||||
// only way to see it is to go round.
|
||||
for (i, s) in SENSITIVITIES.iter().enumerate() {
|
||||
assert_eq!(sensitivity(i as i32), *s);
|
||||
assert_eq!(sensitivity_index(*s), i as i32);
|
||||
}
|
||||
for (i, c) in COLOURS.iter().enumerate() {
|
||||
assert_eq!(colour(i as i32), *c);
|
||||
assert_eq!(colour_index(*c), i as i32);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_index_from_nowhere_lands_on_the_default_rather_than_panicking() {
|
||||
// Slint has no bound on the `int` it sends and Rust has no way to
|
||||
// refuse one. A panic here would be an application that closes because
|
||||
// a chip row was edited.
|
||||
assert_eq!(sensitivity(-1), PeakSensitivity::default());
|
||||
assert_eq!(sensitivity(99), PeakSensitivity::default());
|
||||
assert_eq!(colour(-1), PeakColour::default());
|
||||
assert_eq!(colour(99), PeakColour::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_offers_exactly_the_choices_this_module_knows_about() {
|
||||
// **The one seam neither compiler checks.** The labels live in
|
||||
// `ui/peaking.slint` and the meanings live here, joined only by an
|
||||
// integer; a fifth colour added to the chip row would send index 4 to
|
||||
// `colour`, which would quietly answer Red. Reading the file is
|
||||
// clumsier than a derive, and it is what there is.
|
||||
let src = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/peaking.slint"))
|
||||
.expect("the panel this module serves");
|
||||
|
||||
let listed = |line_start: &str| -> usize {
|
||||
let line = src
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| l.starts_with(line_start))
|
||||
.unwrap_or_else(|| panic!("no `{line_start}` row in peaking.slint"));
|
||||
line.matches('"').count() / 2
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
listed("options: [\"Low\""),
|
||||
SENSITIVITIES.len(),
|
||||
"the sensitivity chips and `SENSITIVITIES` disagree"
|
||||
);
|
||||
assert_eq!(
|
||||
listed("options: [\"Red\""),
|
||||
COLOURS.len(),
|
||||
"the colour chips and `COLOURS` disagree"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChi
|
||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||||
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
||||
import { HistogramPanel, HistogramView } from "histogram.slint";
|
||||
import { FocusMarks, FocusPanel } from "peaking.slint";
|
||||
import { SettingsPage } from "settings.slint";
|
||||
import { ImportPage } from "import.slint";
|
||||
import { StatusBar, InfoPanel } from "develop.slint";
|
||||
@@ -70,6 +71,22 @@ export component AppWindow inherits Window {
|
||||
/// of a draft frame is a histogram of an image nobody is reading.
|
||||
in property <HistogramView> histogram;
|
||||
|
||||
/// TRACES: FR-CULL-3
|
||||
/// Focus peaking: the marks, whether they describe *this* frame, and the
|
||||
/// three things the photographer chose. All of them are Rust's, because
|
||||
/// the marks come from a compute pass — see `peaking.slint` for why
|
||||
/// `focus-overlay-ready` is a separate question from `peaking-on`.
|
||||
in property <image> focus-overlay;
|
||||
in property <bool> focus-overlay-ready: false;
|
||||
in property <bool> peaking-on: false;
|
||||
in property <bool> peaking-available: true;
|
||||
in property <int> peaking-sensitivity: 1;
|
||||
in property <int> peaking-colour: 0;
|
||||
|
||||
callback peaking-toggled(bool);
|
||||
callback peaking-sensitivity-picked(int);
|
||||
callback peaking-colour-picked(int);
|
||||
|
||||
// --- zoom, pan and crop (FR-DEV-4) ---
|
||||
//
|
||||
// Zoom is a *viewing* state, not an edit: it changes the resolution the
|
||||
@@ -1617,6 +1634,18 @@ in property <bool> panel-visible: true;
|
||||
image-rendering: ImageRendering.pixelated;
|
||||
}
|
||||
|
||||
// TRACES: FR-CULL-3
|
||||
// The focus marks, over the same fitted rect. See
|
||||
// `peaking.slint` for why they are a layer over the canvas
|
||||
// rather than a tint in it.
|
||||
if root.focus-overlay-ready && root.total > 0: FocusMarks {
|
||||
x: parent.shown-x;
|
||||
y: parent.shown-y;
|
||||
width: parent.shown-w;
|
||||
height: parent.shown-h;
|
||||
marks: root.focus-overlay;
|
||||
}
|
||||
|
||||
// Where the photograph actually sits inside this box.
|
||||
//
|
||||
// `image-fit: contain` letterboxes, and Slint does not report
|
||||
@@ -2150,6 +2179,26 @@ in property <bool> panel-visible: true;
|
||||
background: Theme.rule;
|
||||
}
|
||||
|
||||
// TRACES: FR-CULL-3
|
||||
// Under the histogram, because the two are the same
|
||||
// kind of thing: instruments that report on the
|
||||
// photograph rather than change it. Kept in every
|
||||
// mode for the same reason the histogram is.
|
||||
FocusPanel {
|
||||
available: root.peaking-available;
|
||||
showing: root.peaking-on;
|
||||
sensitivity: root.peaking-sensitivity;
|
||||
colour: root.peaking-colour;
|
||||
toggled(v) => { root.peaking-toggled(v); }
|
||||
sensitivity-picked(i) => { root.peaking-sensitivity-picked(i); }
|
||||
colour-picked(i) => { root.peaking-colour-picked(i); }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
|
||||
// Framing above the colour work, matching how the edit is
|
||||
// made rather than how it is applied: the frame is decided
|
||||
// by eye first and the pipeline runs it last (see
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// TRACES: FR-CULL-3
|
||||
// The focus-peaking switch, and the two choices it exposes.
|
||||
//
|
||||
// **An instrument, not an operation**, exactly as the histogram above it is:
|
||||
// it has no parameters in the edit graph, changes nothing about the
|
||||
// photograph, and answers a question rather than asking one. So it is written
|
||||
// by hand rather than generated from a descriptor, and FR-DEV-3a is untroubled
|
||||
// by it — nothing here names an operation or reads a parameter out of one.
|
||||
//
|
||||
// **Both choices are words, not swatches.** The colour picker is the obvious
|
||||
// place to draw four coloured squares, and NFR-A11Y-3 is the reason not to:
|
||||
// a control for choosing between hues, presented only as hues, is unusable by
|
||||
// the person most likely to need to change it. The chips say "Red" and "Cyan".
|
||||
//
|
||||
// **Why the two chip rows only exist while peaking is on.** They are settings
|
||||
// for something that is not happening, and the develop column is the
|
||||
// photographer's instrument panel — every row it holds is a slider pushed
|
||||
// below the fold. The panel's own height is bound to its content, so the
|
||||
// column reflows rather than leaving a gap.
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { Button, PanelHeading, Caption } from "widgets.slint";
|
||||
import { Segmented } from "controls.slint";
|
||||
|
||||
// TRACES: FR-CULL-3
|
||||
// The marks themselves, composited over the canvas.
|
||||
//
|
||||
// **A layer over the photograph and not a tint in it**, which is the same rule
|
||||
// `app.slint` states on the region map: a diagnostic "must not reach the
|
||||
// histogram, an export, or the texture the develop pass hands the compositor".
|
||||
// `HistogramPass` counts whatever the develop pass last rendered, so marks
|
||||
// painted into that frame would arrive in the histogram as a spike and in the
|
||||
// clipping figure as blown highlights. The layer is transparent everywhere
|
||||
// except where something is in focus.
|
||||
//
|
||||
// **No `source-clip` and no rotation**, unlike the region map. That is a
|
||||
// source-space picture being windowed down to the visible part; this was
|
||||
// measured on the rendered frame itself, so it is already cropped, zoomed and
|
||||
// turned exactly as the canvas is. One fewer thing that can drift out of
|
||||
// registration.
|
||||
//
|
||||
// The caller places it on `canvas-area`'s fitted rect, which is the shared
|
||||
// contract for anything that lands on the picture.
|
||||
export component FocusMarks inherits Image {
|
||||
/// The overlay `DevelopSession::focus_overlay` produced for this frame.
|
||||
in property <image> marks;
|
||||
|
||||
source: root.marks;
|
||||
image-fit: fill;
|
||||
// Nearest-neighbour: a mark is one pixel wide, and smoothing spreads it
|
||||
// into a grey haze that reads as softness — the opposite of what it is
|
||||
// reporting.
|
||||
image-rendering: ImageRendering.pixelated;
|
||||
}
|
||||
|
||||
// TRACES: FR-CULL-3
|
||||
export component FocusPanel inherits Rectangle {
|
||||
/// Whether this device could build the overlay at all.
|
||||
///
|
||||
/// A compute pass can fail to compile on a driver nobody here has, and the
|
||||
/// honest response is to say so rather than to offer a switch that does
|
||||
/// nothing when pressed. The develop view keeps working without it; only
|
||||
/// this panel changes.
|
||||
in property <bool> available: true;
|
||||
/// Whether the overlay is currently being drawn.
|
||||
///
|
||||
/// `showing` rather than the obvious `on`: Slint has no reserved word
|
||||
/// there today, and a one-word property that might become one is not worth
|
||||
/// the bet on a panel this small.
|
||||
in property <bool> showing: false;
|
||||
/// Index into `PeakSensitivity`, in the order Rust declares it.
|
||||
in property <int> sensitivity: 1;
|
||||
/// Index into `PeakColour`, likewise.
|
||||
in property <int> colour: 0;
|
||||
|
||||
callback toggled(bool);
|
||||
callback sensitivity-picked(int);
|
||||
callback colour-picked(int);
|
||||
|
||||
background: Theme.surface;
|
||||
|
||||
/// TRACES: FR-UI-2
|
||||
/// How wide this panel has to be before it clips itself. The develop
|
||||
/// column is the largest of these and nothing else; see `SpotPanel` and
|
||||
/// `HistogramPanel` for the whole protocol.
|
||||
///
|
||||
/// Both chip rows wrap at three, which is what keeps this number at the
|
||||
/// narrowest column the application supports rather than at four chips
|
||||
/// abreast — a single row of four would set the width of the entire
|
||||
/// sidebar for every other panel in it.
|
||||
out property <length> content-width: layout.preferred-width;
|
||||
min-width: root.content-width;
|
||||
|
||||
// Flat rather than nested, for the reason `SpotPanel` and `MaskPanel` both
|
||||
// give: a nested conditional layout under-reports its height here and the
|
||||
// rows below it get drawn on top of one another. Every row carries its own
|
||||
// `if`.
|
||||
layout := VerticalLayout {
|
||||
padding: Theme.gap;
|
||||
spacing: Theme.gap-sm;
|
||||
alignment: start;
|
||||
|
||||
PanelHeading { text: "FOCUS"; }
|
||||
|
||||
if !root.available: Caption {
|
||||
text: "This device could not build the overlay.";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
if root.available: Button {
|
||||
// The label states the action rather than the state, as the mask
|
||||
// overlay's does: a photographer reads a button for what pressing
|
||||
// it will do.
|
||||
text: root.showing ? "Hide focus peaking" : "Show focus peaking";
|
||||
active: root.showing;
|
||||
clicked => { root.toggled(!root.showing); }
|
||||
}
|
||||
|
||||
if root.available && root.showing: Segmented {
|
||||
label: "Sensitivity";
|
||||
hint: "lower on a noisy frame";
|
||||
options: ["Low", "Medium", "High"];
|
||||
selected: root.sensitivity;
|
||||
columns: 3;
|
||||
picked(i) => { root.sensitivity-picked(i); }
|
||||
}
|
||||
|
||||
if root.available && root.showing: Segmented {
|
||||
label: "Marks";
|
||||
hint: "pick what the subject is not";
|
||||
options: ["Red", "Yellow", "Cyan", "Magenta"];
|
||||
selected: root.colour;
|
||||
columns: 3;
|
||||
picked(i) => { root.colour-picked(i); }
|
||||
}
|
||||
|
||||
if root.available && root.showing: Caption {
|
||||
// Said once, here, rather than left to be discovered: the marks go
|
||||
// away while a control is moving because a half-resolution draft
|
||||
// frame cannot be measured for sharpness (see `FocusPeakPass`).
|
||||
//
|
||||
// Kept to one short sentence on purpose. A wrapping Text reports
|
||||
// its *unwrapped* width as its preferred one, and this panel's
|
||||
// `content-width` is what the develop column sizes itself from —
|
||||
// a paragraph here would hold the whole sidebar open.
|
||||
text: "Marks pause while a control is dragged.";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user