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:
+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
|
||||
|
||||
Reference in New Issue
Block a user