Split develop.rs into develop/ by area of behaviour
develop.rs had grown to 9,327 lines covering everything the develop session does: opening a photograph, the parameter-row and curve-widget panel model, mask viewing and editing, mask creation and the rasteriser that turns a mask stack into GPU arrays, spot repairs, scene segmentation, framing and zoom, white-balance sampling, rendering and film choice, and the undo/snapshot history. docs/dev/code-health.md CH-1 names dr-ui's lack of a view layer as the reason every feature kept landing in a handful of files; this is the first of the two pure splits it recommends as easy, no-behaviour-change wins independent of that larger rework. The boundaries follow the file's own sections (several were already marked off with comment headers) and the seams a full read turned up underneath them -- mask storage/rasterisation turned out to be a distinct concern from mask viewing and editing, and rows/tabs/curves from each other, so those split further than the headers alone suggested. Each module stays under about 1,500 lines. Struct fields and the handful of helper methods now called from a sibling module became `pub(super)`, which is strictly narrower than the whole-crate reachability a single file gave them; nothing gained visibility outside `develop`. Tests moved with the code they test, including the few cases where a helper one file's tests needed was itself only defined in another's -- those became shared fixtures in `mod.rs` alongside the `headless`/`read_back`/`grey_session` helpers that already worked that way. `mod.rs` re-exports every item `develop::` callers outside this module used before, so lib.rs, masks_ui.rs and the rest needed no changes.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,523 @@
|
||||
//! The tone-curve widget: one run of points per subject, and the session's
|
||||
//! curve-channel selection that picks which one is being dragged.
|
||||
use dr_pipeline::ops::curve;
|
||||
use dr_pipeline::{OpCapability, Presentation, WidgetKind};
|
||||
|
||||
use crate::labels;
|
||||
use crate::ParamRow;
|
||||
|
||||
#[cfg(test)]
|
||||
use super::rows::rows_filtered;
|
||||
#[cfg(test)]
|
||||
use super::rows::rows_from;
|
||||
use super::rows::{no_choices, supported};
|
||||
use super::session::DevelopSession;
|
||||
#[cfg(test)]
|
||||
use dr_pipeline::{EditGraph, OpId, ParamId, ParamKind, Unit};
|
||||
|
||||
/// One run of a curve widget's parameters: the points of a single curve.
|
||||
///
|
||||
/// A widget may span several curves — the tone curve is one plot over a master
|
||||
/// curve and three colour channels — and it says so the way the colour mixer
|
||||
/// says it has twelve bands: by faceting each parameter with the *subject* it
|
||||
/// acts on. Consecutive parameters sharing a subject are one curve.
|
||||
struct CurveRun {
|
||||
/// The subject's localisation key, or `None` where the widget's parameters
|
||||
/// carry no facet at all and are therefore a single unnamed curve.
|
||||
subject: Option<&'static str>,
|
||||
/// Where this run's points begin in the operation's parameter list. What
|
||||
/// a drag routes back through, so it must be a position in `op.params`
|
||||
/// and not in the presentation's list.
|
||||
base: usize,
|
||||
/// How many coordinates it holds.
|
||||
len: usize,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3a
|
||||
/// The curves a curve widget spans, in the order the operation declares them.
|
||||
///
|
||||
/// **This is the whole of the panel's knowledge of colour channels: none.** It
|
||||
/// groups by whatever subject the parameters carry, so an operation offering a
|
||||
/// master curve and three channels gets a four-way selector, one offering a
|
||||
/// single unfaceted curve gets no selector at all, and one that grows a fifth
|
||||
/// curve tomorrow needs no change here.
|
||||
///
|
||||
/// Returns `None` where the parameters do not look like point coordinates —
|
||||
/// an odd count, a run that is not contiguous in the capability list — in
|
||||
/// which case the caller falls back to sliders rather than drawing a widget
|
||||
/// over a layout it has guessed at.
|
||||
fn curve_runs(op: &OpCapability, presentation: &Presentation) -> Option<Vec<CurveRun>> {
|
||||
// Points are x/y pairs, so an odd count means the operation and this code
|
||||
// disagree about the layout.
|
||||
if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) {
|
||||
log::warn!("{}: curve widget needs an even parameter count", op.id);
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut runs: Vec<CurveRun> = Vec::new();
|
||||
for id in &presentation.params {
|
||||
// The widget addresses points by offset from the first of its run, so
|
||||
// a run has to be contiguous in the capability list.
|
||||
let at = op.params.iter().position(|p| p.id == *id)?;
|
||||
let subject = op.params[at].facet.as_ref().map(|f| f.subject.0);
|
||||
|
||||
match runs.last_mut() {
|
||||
Some(run) if run.subject == subject && run.base + run.len == at => run.len += 1,
|
||||
_ => runs.push(CurveRun {
|
||||
subject,
|
||||
base: at,
|
||||
len: 1,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
if runs.iter().any(|r| !r.len.is_multiple_of(2)) {
|
||||
log::warn!("{}: a curve's points are not contiguous", op.id);
|
||||
return None;
|
||||
}
|
||||
Some(runs)
|
||||
}
|
||||
|
||||
/// One row standing for a whole curve.
|
||||
///
|
||||
/// `channel` picks which of the widget's curves is plotted; it is clamped
|
||||
/// rather than validated, because the selection is interface state that
|
||||
/// outlives a change of photograph and the new image's operation may have
|
||||
/// fewer curves than the old one's.
|
||||
///
|
||||
/// Returns `None` if the operation's parameters do not look like point
|
||||
/// coordinates, in which case the caller falls back to sliders rather than
|
||||
/// rendering a broken widget.
|
||||
pub(super) fn curve_row(
|
||||
op_index: usize,
|
||||
group_head: usize,
|
||||
op: &OpCapability,
|
||||
presentation: &Presentation,
|
||||
channel: usize,
|
||||
) -> Option<ParamRow> {
|
||||
let runs = curve_runs(op, presentation)?;
|
||||
let run = runs.get(channel.min(runs.len().saturating_sub(1)))?;
|
||||
|
||||
let points: Vec<f32> = op.params[run.base..run.base + run.len]
|
||||
.iter()
|
||||
.map(|p| p.value)
|
||||
.collect();
|
||||
|
||||
Some(ParamRow {
|
||||
op_index: op_index as i32,
|
||||
// The first point parameter *of the curve on show*; the widget offsets
|
||||
// from here, so switching curve is what re-points the drag.
|
||||
param_index: run.base as i32,
|
||||
op_label: labels::resolve(op.label.0).into(),
|
||||
param_label: String::new().into(),
|
||||
// A widget spanning a whole operation is not a row in anyone's
|
||||
// grid, so it heads no run and carries no swatch.
|
||||
facet_label: String::new().into(),
|
||||
starts_facet: false,
|
||||
swatch_hue: -1.0,
|
||||
group_head: group_head as i32,
|
||||
// One widget standing for every parameter of the operation, so
|
||||
// the group it heads is itself and nothing else.
|
||||
group_len: 1,
|
||||
group_modified: op.params.iter().any(|p| p.value != p.default),
|
||||
// A curve is drawn, not sampled. Its own affordance is the plot.
|
||||
group_samples: false,
|
||||
kind: "curve".into(),
|
||||
value: 0.0,
|
||||
default_value: 0.0,
|
||||
minimum: 0.0,
|
||||
maximum: 1.0,
|
||||
precision: 4,
|
||||
unit: String::new().into(),
|
||||
points: slint::ModelRc::new(slint::VecModel::from(points)),
|
||||
// A curve is not a choice between named alternatives. The curves it
|
||||
// can switch between are named on the panel rather than on the row —
|
||||
// see `DevelopSession::curve_channels` for why they cannot ride here.
|
||||
choices: no_choices(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Sort ascending and force a minimum separation.
|
||||
///
|
||||
/// Mirrors what the curve operation does before handing points to the
|
||||
/// shader. Duplicated rather than shared because the operation keeps it
|
||||
/// private, and the consequence of drift is only a drawn line that lags the
|
||||
/// rendered one by a pixel — not a wrong image.
|
||||
fn sort_with_gap(xs: &mut [f32]) {
|
||||
const MIN_GAP: f32 = 0.001;
|
||||
for i in 1..xs.len() {
|
||||
let mut j = i;
|
||||
while j > 0 && xs[j - 1] > xs[j] {
|
||||
xs.swap(j - 1, j);
|
||||
j -= 1;
|
||||
}
|
||||
}
|
||||
for i in 1..xs.len() {
|
||||
if xs[i] - xs[i - 1] < MIN_GAP {
|
||||
xs[i] = xs[i - 1] + MIN_GAP;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DevelopSession {
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The names of the curves the widget can switch between.
|
||||
///
|
||||
/// Empty where there is only one, which is also the answer for a frontend
|
||||
/// with no curve at all: a selector over a single choice is a row of
|
||||
/// nothing.
|
||||
///
|
||||
/// **Derived from the facets, so nothing here names a colour channel.**
|
||||
/// The operation says its forty points are one control applied to four
|
||||
/// subjects and publishes a localisation key for each; this resolves the
|
||||
/// keys and hands over four words. An operation that grew a fifth curve
|
||||
/// would appear here on its own.
|
||||
///
|
||||
/// A panel property rather than a field on the curve's `ParamRow`, and the
|
||||
/// reason is Slint's: a row's models are compared by identity, so a fresh
|
||||
/// list of names built on every parameter event would make the row look
|
||||
/// changed every time, and rewriting a row rebuilds the repeater item
|
||||
/// underneath it — destroying the `TouchArea` holding the drag in
|
||||
/// progress. The same hazard `rows`'s in-place point update exists to
|
||||
/// avoid. Nothing in this list is a drag target, so up here it is safe to
|
||||
/// replace wholesale, exactly as [`Self::curve_samples`] is.
|
||||
pub fn curve_channels(&self) -> Vec<String> {
|
||||
for op in &self.scoped_capabilities() {
|
||||
let Some(presentation) = &op.presentation else {
|
||||
continue;
|
||||
};
|
||||
if presentation.choose(supported) != Some(WidgetKind::ToneCurve) {
|
||||
continue;
|
||||
}
|
||||
let Some(runs) = curve_runs(op, presentation) else {
|
||||
continue;
|
||||
};
|
||||
if runs.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
return runs
|
||||
.iter()
|
||||
.map(|r| r.subject.map(labels::resolve).unwrap_or_default())
|
||||
.collect();
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Which curve the widget is plotting, as an index into
|
||||
/// [`Self::curve_channels`].
|
||||
pub fn curve_channel(&self) -> i32 {
|
||||
self.curve_channel as i32
|
||||
}
|
||||
|
||||
/// Plot a different one of the operation's curves.
|
||||
///
|
||||
/// Out-of-range indices are ignored rather than clamped: the only thing
|
||||
/// that can send one is a stale interface event, and quietly moving the
|
||||
/// selection somewhere the user did not point is worse than doing nothing.
|
||||
pub fn set_curve_channel(&mut self, index: i32) {
|
||||
let Ok(index) = usize::try_from(index) else {
|
||||
return;
|
||||
};
|
||||
if index < self.curve_channels().len() {
|
||||
self.curve_channel = index;
|
||||
}
|
||||
}
|
||||
|
||||
/// The plotted curve's shape, sampled for drawing.
|
||||
///
|
||||
/// Evaluated with `dr_pipeline`'s own spline, so the line the user drags
|
||||
/// is the line the shader applies. The alternative — reading the curve
|
||||
/// back off the GPU — is the round-trip ARCH §6.1 forbids, to draw a
|
||||
/// polyline.
|
||||
///
|
||||
/// The line drawn is the *selected* curve's own shape, not the composition
|
||||
/// of it with the master. Two curves overlaid on one grid is a plot of two
|
||||
/// things, and the one being dragged has to be the one whose points are
|
||||
/// under the pointer.
|
||||
pub fn curve_samples(&self) -> Vec<f32> {
|
||||
const SAMPLES: usize = 96;
|
||||
|
||||
// The selection is an index over the subjects the panel found, which
|
||||
// for this operation is its channel order. Clamped rather than
|
||||
// trusted: a selection made on one photograph outlives the change to
|
||||
// the next.
|
||||
let channel = curve::Channel::ALL[self.curve_channel.min(curve::CHANNELS - 1)];
|
||||
|
||||
let mut xs = [0.0f32; curve::POINTS];
|
||||
let mut ys = [0.0f32; curve::POINTS];
|
||||
let mut found = false;
|
||||
|
||||
for cap in self.graph.capabilities() {
|
||||
if cap.id != curve::ID {
|
||||
continue;
|
||||
}
|
||||
found = true;
|
||||
// By id rather than by position, so which curve is plotted is
|
||||
// decided by naming it and not by arithmetic over the parameter
|
||||
// list.
|
||||
let value = |id| {
|
||||
cap.params
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.map_or(0.0, |p| p.value)
|
||||
};
|
||||
for i in 0..curve::POINTS {
|
||||
xs[i] = value(curve::coordinate(channel, i, curve::Axis::X));
|
||||
ys[i] = value(curve::coordinate(channel, i, curve::Axis::Y));
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Sorted the same way the operation sorts before handing points to
|
||||
// the shader, or a dragged-past point would draw differently from
|
||||
// how it renders.
|
||||
sort_with_gap(&mut xs);
|
||||
|
||||
(0..SAMPLES)
|
||||
.map(|i| {
|
||||
let x = i as f32 / (SAMPLES - 1) as f32;
|
||||
curve::evaluate(&xs, &ys, x).clamp(0.0, 1.0)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_curve_collapses_to_a_single_row() {
|
||||
// Every point parameter — all four curves' worth — must appear as one
|
||||
// curve control, not as forty sliders. Otherwise the widget and the
|
||||
// sliders both render and the panel shows the same values twice.
|
||||
let graph = EditGraph::default_chain();
|
||||
let curve_cap = graph
|
||||
.capabilities()
|
||||
.into_iter()
|
||||
.find(|c| c.id == curve::ID)
|
||||
.expect("the chain includes a tone curve");
|
||||
|
||||
assert_eq!(
|
||||
curve_cap.params.len(),
|
||||
curve::CHANNELS * curve::POINTS * 2,
|
||||
"a master curve and one per colour channel"
|
||||
);
|
||||
let presentation = curve_cap
|
||||
.presentation
|
||||
.as_ref()
|
||||
.expect("the curve declares a widget");
|
||||
// Asked the way the panel asks it: the first preference this frontend
|
||||
// implements, not a fixed single kind.
|
||||
assert_eq!(presentation.choose(supported), Some(WidgetKind::ToneCurve));
|
||||
// Every parameter is owned by the widget, so none is left over to be
|
||||
// rendered as a stray slider.
|
||||
assert_eq!(presentation.params.len(), curve_cap.params.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_point_parameters_are_contiguous() {
|
||||
// The widget addresses points by offset from the first. Were they
|
||||
// interleaved with anything else, dragging a point would write to
|
||||
// the wrong parameter.
|
||||
let graph = EditGraph::default_chain();
|
||||
let cap = graph
|
||||
.capabilities()
|
||||
.into_iter()
|
||||
.find(|c| c.id == curve::ID)
|
||||
.expect("tone curve present");
|
||||
let presentation = cap.presentation.as_ref().expect("declares a widget");
|
||||
|
||||
let base = cap
|
||||
.params
|
||||
.iter()
|
||||
.position(|p| p.id == presentation.params[0])
|
||||
.expect("first point is a parameter");
|
||||
for (i, id) in presentation.params.iter().enumerate() {
|
||||
assert_eq!(
|
||||
cap.params[base + i].id,
|
||||
*id,
|
||||
"point parameter {i} is out of order"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel's whole knowledge of colour channels, asserted to be none.
|
||||
///
|
||||
/// It groups the widget's parameters by the subject the *operation* put on
|
||||
/// them and finds four curves; nothing below says "red", and an operation
|
||||
/// that grew a fifth curve would arrive here on its own.
|
||||
#[test]
|
||||
fn a_curve_widget_offers_one_run_per_subject() {
|
||||
let graph = EditGraph::default_chain();
|
||||
let cap = graph
|
||||
.capabilities()
|
||||
.into_iter()
|
||||
.find(|c| c.id == curve::ID)
|
||||
.expect("tone curve present");
|
||||
let presentation = cap.presentation.as_ref().expect("declares a widget");
|
||||
|
||||
let runs = curve_runs(&cap, presentation).expect("a curve-shaped operation");
|
||||
assert_eq!(runs.len(), curve::CHANNELS);
|
||||
for (i, run) in runs.iter().enumerate() {
|
||||
assert_eq!(run.len, curve::POINTS * 2, "run {i} is not five points");
|
||||
assert_eq!(run.base, i * curve::POINTS * 2);
|
||||
assert!(run.subject.is_some(), "run {i} is unnamed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_curve_repoints_the_row() {
|
||||
use slint::Model as _;
|
||||
|
||||
// What a drag routes through. The row's `param_index` is the base of
|
||||
// the curve *on show*, so picking a different one must move it — if it
|
||||
// did not, dragging a point on the red curve would write to the
|
||||
// master's.
|
||||
let graph = EditGraph::default_chain();
|
||||
let caps = graph.capabilities();
|
||||
let curve_at = caps
|
||||
.iter()
|
||||
.position(|c| c.id == curve::ID)
|
||||
.expect("tone curve present");
|
||||
|
||||
let mut bases = Vec::new();
|
||||
for channel in 0..curve::CHANNELS {
|
||||
let rows = rows_filtered(&caps, |_| true, channel);
|
||||
let row = rows
|
||||
.iter()
|
||||
.find(|r| r.op_index as usize == curve_at)
|
||||
.expect("the curve has a row");
|
||||
assert_eq!(row.kind, "curve");
|
||||
assert_eq!(
|
||||
row.points.row_count(),
|
||||
curve::POINTS * 2,
|
||||
"one curve's points, not all four curves'"
|
||||
);
|
||||
bases.push(row.param_index);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
bases,
|
||||
(0..curve::CHANNELS)
|
||||
.map(|i| (i * curve::POINTS * 2) as i32)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_the_operation_cannot_honour_falls_back_to_its_last_curve() {
|
||||
// The selection outlives the photograph it was made on, and the next
|
||||
// image's operation may offer fewer curves. Clamping keeps a plot on
|
||||
// the grid; the alternative is a curve row that vanishes, which reads
|
||||
// as the tone curve having disappeared from the panel.
|
||||
let graph = EditGraph::default_chain();
|
||||
let caps = graph.capabilities();
|
||||
let rows = rows_filtered(&caps, |_| true, 99);
|
||||
let row = rows
|
||||
.iter()
|
||||
.find(|r| r.kind == "curve")
|
||||
.expect("the curve still has a row");
|
||||
assert_eq!(
|
||||
row.param_index,
|
||||
((curve::CHANNELS - 1) * curve::POINTS * 2) as i32
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_operation_whose_points_are_unfaceted_is_one_curve() {
|
||||
// A curve widget that spans a single unnamed curve — which is what
|
||||
// this operation was before the channels arrived, and what any other
|
||||
// node declaring a `tone_curve` widget over ten scalars would be.
|
||||
// It must draw, and it must offer no choice.
|
||||
use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand};
|
||||
use slint::Model as _;
|
||||
|
||||
static IDS: [ParamId; 4] = [
|
||||
ParamId("p0_x"),
|
||||
ParamId("p0_y"),
|
||||
ParamId("p1_x"),
|
||||
ParamId("p1_y"),
|
||||
];
|
||||
let param = |id: ParamId| ParamCapability {
|
||||
id,
|
||||
label: LocalizedKey("param.point"),
|
||||
kind: ParamKind::Scalar {
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
scale: dr_pipeline::Scale::Linear,
|
||||
unit: Unit::None,
|
||||
precision: 4,
|
||||
},
|
||||
default: 0.0,
|
||||
value: 0.0,
|
||||
facet: None,
|
||||
};
|
||||
let plain = OpCapability {
|
||||
id: OpId("invented_curve"),
|
||||
label: LocalizedKey("op.invented_curve"),
|
||||
active: false,
|
||||
presentation: Some(Presentation {
|
||||
widgets: vec![WidgetKind::ToneCurve],
|
||||
demand: WidgetDemand {
|
||||
two_dimensional: true,
|
||||
precise_pointing: true,
|
||||
},
|
||||
params: IDS.to_vec(),
|
||||
}),
|
||||
params: IDS.iter().map(|id| param(*id)).collect(),
|
||||
attributes: vec![dr_pipeline::Attribute::Tone],
|
||||
};
|
||||
|
||||
let presentation = plain.presentation.as_ref().expect("declares a widget");
|
||||
let runs = curve_runs(&plain, presentation).expect("curve-shaped");
|
||||
assert_eq!(runs.len(), 1, "one unnamed curve");
|
||||
assert_eq!(runs[0].subject, None);
|
||||
|
||||
let rows = rows_from(&[plain]);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].kind, "curve");
|
||||
assert_eq!(rows[0].points.row_count(), IDS.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_samples_start_on_the_diagonal() {
|
||||
// A fresh curve is the identity, so the drawn line must be the 45°
|
||||
// diagonal — anything else means the widget opens showing a shape
|
||||
// the image does not have.
|
||||
let mut xs = [0.0f32; curve::POINTS];
|
||||
let mut ys = [0.0f32; curve::POINTS];
|
||||
for i in 0..curve::POINTS {
|
||||
let t = i as f32 / (curve::POINTS - 1) as f32;
|
||||
xs[i] = t;
|
||||
ys[i] = t;
|
||||
}
|
||||
for i in 0..=20 {
|
||||
let x = i as f32 / 20.0;
|
||||
let y = curve::evaluate(&xs, &ys, x);
|
||||
assert!((y - x).abs() < 1e-4, "at {x} the identity gave {y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_enforces_a_minimum_gap() {
|
||||
// Two points dragged onto each other would divide by zero in the
|
||||
// spline; the drawn curve must survive it exactly as the shader does.
|
||||
let mut xs = [0.5, 0.5, 0.5, 0.5, 0.5];
|
||||
sort_with_gap(&mut xs);
|
||||
for i in 1..xs.len() {
|
||||
assert!(xs[i] > xs[i - 1], "not separated: {xs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_orders_reversed_points() {
|
||||
let mut xs = [0.9, 0.7, 0.5, 0.3, 0.1];
|
||||
sort_with_gap(&mut xs);
|
||||
for i in 1..xs.len() {
|
||||
assert!(xs[i] > xs[i - 1], "not sorted: {xs:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,952 @@
|
||||
//! The crop, its locked aspect ratio, rotation and flips, and the zoom/pan
|
||||
//! and pixel-inspection state a viewport keeps on top of the framed image.
|
||||
use dr_pipeline::{CropRect, Edit};
|
||||
|
||||
use crate::labels;
|
||||
|
||||
use super::render::fit;
|
||||
use super::session::DevelopSession;
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// A shape the crop rectangle is held to while it is dragged.
|
||||
///
|
||||
/// A photographer cropping for a print, a phone wallpaper or a 16:9 frame is
|
||||
/// not choosing four edges — they are choosing one edge and a known shape, and
|
||||
/// a free crop makes them do the arithmetic by eye on every drag. This is the
|
||||
/// lock that removes it.
|
||||
///
|
||||
/// **The ratio is of output pixels, not of the rect's own numbers.** The rect
|
||||
/// is stored in fractions of a frame that is not square, so `CropRect` needs
|
||||
/// the frame's size to hold a shape; see [`CropRect::with_aspect`], which is
|
||||
/// where that conversion is done and explained.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum CropAspect {
|
||||
/// Any shape. The handles move independently, as they always have.
|
||||
#[default]
|
||||
Free,
|
||||
/// Whatever the frame already is, so a crop trims without reshaping.
|
||||
///
|
||||
/// Not the same as `Fixed(3, 2)` even on a 3:2 camera: it follows the
|
||||
/// frame, so it stays right on the next photograph from another body and
|
||||
/// after a quarter turn.
|
||||
Original,
|
||||
/// A named ratio of `w:h`, before the portrait switch is applied.
|
||||
Fixed(u32, u32),
|
||||
}
|
||||
|
||||
impl CropAspect {
|
||||
/// The ratios the panel offers, in the order it draws them.
|
||||
///
|
||||
/// Short on purpose. These sit as chips in a column narrow enough for a
|
||||
/// tablet, and every ratio a photographer reaches for repeatedly is here:
|
||||
/// the frame's own shape, the square, the two classic camera ratios, the
|
||||
/// large-format one that most print papers follow, and video's.
|
||||
pub const CHOICES: [Self; 6] = [
|
||||
Self::Free,
|
||||
Self::Original,
|
||||
Self::Fixed(1, 1),
|
||||
Self::Fixed(3, 2),
|
||||
Self::Fixed(4, 3),
|
||||
Self::Fixed(16, 9),
|
||||
];
|
||||
|
||||
/// The chip's text.
|
||||
pub fn label(self) -> String {
|
||||
match self {
|
||||
Self::Free => "Free".to_string(),
|
||||
Self::Original => "Original".to_string(),
|
||||
Self::Fixed(w, h) => format!("{w}:{h}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this choice has a portrait form at all.
|
||||
///
|
||||
/// A square does not, and neither does `Free`. The switch is disabled
|
||||
/// rather than hidden for those, so the row does not change shape as the
|
||||
/// chips are tried.
|
||||
pub fn has_orientation(self) -> bool {
|
||||
!matches!(self, Self::Free | Self::Fixed(1, 1))
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Whether a quarter turn of the frame has to flip the orientation switch
|
||||
/// to leave this ratio describing the same shape.
|
||||
///
|
||||
/// A quarter turn carries the crop with it — that is what makes turning a
|
||||
/// photograph keep its composition — so a rect locked to 16:9 comes out of
|
||||
/// the turn at 9:16, and the switch has to agree or the next drag would
|
||||
/// snap the crop back and undo the turn's effect on it.
|
||||
///
|
||||
/// `Original` is deliberately *not* included, and getting that wrong flips
|
||||
/// it twice. It is resolved against the framed size every time it is
|
||||
/// asked for, and a quarter turn swaps that frame's axes — so it has
|
||||
/// already turned by the time anything asks.
|
||||
pub fn turns_with_the_frame(self) -> bool {
|
||||
matches!(self, Self::Fixed(w, h) if w != h)
|
||||
}
|
||||
|
||||
/// Width over height in output pixels, or `None` where nothing is locked.
|
||||
///
|
||||
/// `frame` is the framed size the crop is measured against — the turned
|
||||
/// frame, not the sensor — which is what makes `Original` follow a quarter
|
||||
/// turn instead of becoming a portrait crop on a landscape photograph.
|
||||
pub fn ratio(self, frame: (u32, u32), portrait: bool) -> Option<f32> {
|
||||
let (fw, fh) = (frame.0.max(1) as f32, frame.1.max(1) as f32);
|
||||
let landscape = match self {
|
||||
Self::Free => return None,
|
||||
Self::Original => fw / fh,
|
||||
Self::Fixed(w, h) => w.max(1) as f32 / h.max(1) as f32,
|
||||
};
|
||||
Some(if portrait && self.has_orientation() {
|
||||
1.0 / landscape
|
||||
} else {
|
||||
landscape
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-express a crop rect after the frame it is measured against turns.
|
||||
///
|
||||
/// The crop lives in fractions of the *framed* image — the one the quarter
|
||||
/// turns have already produced — so turning the frame another quarter leaves
|
||||
/// the rect describing the wrong region unless it turns with it. Without this,
|
||||
/// rotating a portrait crop on a landscape photograph slides the selection
|
||||
/// onto a different part of the picture, which reads as the rotation having
|
||||
/// moved the image rather than the frame.
|
||||
///
|
||||
/// One clockwise quarter takes `(x, y)` to `(1 - y - h, x)` and exchanges the
|
||||
/// extents; anticlockwise is the same map run the other way. Applied
|
||||
/// `turns.rem_euclid(4)` times so the caller's wrapping and this agree.
|
||||
fn rotate_crop(rect: CropRect, turns: i32) -> CropRect {
|
||||
let mut r = rect;
|
||||
for _ in 0..turns.rem_euclid(4) {
|
||||
r = CropRect {
|
||||
x: 1.0 - r.y - r.height,
|
||||
y: r.x,
|
||||
width: r.height,
|
||||
height: r.width,
|
||||
};
|
||||
}
|
||||
r.normalised()
|
||||
}
|
||||
|
||||
impl DevelopSession {
|
||||
/// The sensor's own dimensions, before framing.
|
||||
///
|
||||
/// What a crop overlay needs: its handles are placed against the full
|
||||
/// frame, since that is what the user is selecting *from*.
|
||||
pub fn sensor_size(&self) -> (u32, u32) {
|
||||
self.demosaiced.size()
|
||||
}
|
||||
|
||||
/// Whether one source pixel now covers more than one screen pixel.
|
||||
///
|
||||
/// The question the interface asks to decide how the canvas is *filtered*,
|
||||
/// not how it is rendered. Below 1:1 there are more source pixels than
|
||||
/// screen pixels and smoothing is what stops the image aliasing; past it
|
||||
/// there is no more detail to show, and smoothing only invents values
|
||||
/// between real ones — at which point a photographer inspecting focus or
|
||||
/// noise wants to see the pixels, not a blur of them.
|
||||
///
|
||||
/// Measured against the visible region rather than the zoom factor alone,
|
||||
/// because the two differ: a 24 MP file in a 1200px viewport is still
|
||||
/// showing five sensor pixels per screen pixel at 4×, while a small JPEG is
|
||||
/// already magnified at 1×.
|
||||
pub fn magnifies_source(&self, viewport_w: u32, viewport_h: u32) -> bool {
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
let (fw, fh) = self.graph.output_size(sw, sh);
|
||||
let (rw, rh) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1));
|
||||
|
||||
// How many source pixels lie behind the render target: the framed
|
||||
// image narrowed to the region the view selects. The target keeps its
|
||||
// size while that region shrinks, which is what raises the ratio.
|
||||
let view = self.graph.framing().view();
|
||||
let behind_w = f64::from(fw) * f64::from(view.width.max(f32::EPSILON));
|
||||
let behind_h = f64::from(fh) * f64::from(view.height.max(f32::EPSILON));
|
||||
|
||||
// Strictly greater, with a margin: at exactly 1:1 either filter gives
|
||||
// the same answer, and flipping mode on a rounding error would make the
|
||||
// canvas visibly change character mid-scroll.
|
||||
f64::from(rw) > behind_w * 1.001 && f64::from(rh) > behind_h * 1.001
|
||||
}
|
||||
|
||||
/// Set the crop rectangle, in fractions of the source.
|
||||
pub fn set_crop(&mut self, rect: CropRect) {
|
||||
self.graph.set_crop(rect);
|
||||
// Keyed on the operation, not on a parameter: one drag of one handle
|
||||
// moves the origin and the extent together.
|
||||
self.history
|
||||
.record(&self.graph, Edit::Op(dr_pipeline::framing::ID));
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Set the crop rectangle, held to `aspect` about `anchor`.
|
||||
///
|
||||
/// The frame size the ratio needs is this session's own, so the caller
|
||||
/// passes a shape rather than a rectangle and never has to know what a
|
||||
/// quarter turn did to the frame's dimensions.
|
||||
///
|
||||
/// `anchor` is the point of the rect that must not move, in the rect's own
|
||||
/// `0..1` coordinates — the corner *opposite* the handle being dragged, so
|
||||
/// that shaping the rect onto the ratio pushes the held corner and leaves
|
||||
/// the far one where the user put it.
|
||||
pub fn set_crop_locked(
|
||||
&mut self,
|
||||
rect: CropRect,
|
||||
aspect: CropAspect,
|
||||
portrait: bool,
|
||||
anchor: (f32, f32),
|
||||
) {
|
||||
let frame = self.framed_size();
|
||||
let rect = match aspect.ratio(frame, portrait) {
|
||||
Some(r) => rect.with_aspect(frame.0, frame.1, r, anchor),
|
||||
None => rect,
|
||||
};
|
||||
self.set_crop(rect);
|
||||
}
|
||||
|
||||
pub fn crop(&self) -> CropRect {
|
||||
self.graph.crop()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The whole frame the crop is measured against, in output pixels.
|
||||
///
|
||||
/// The *framed* size, not the sensor's: quarter turns swap the axes, and a
|
||||
/// ratio resolved against the sensor would come out on its side the moment
|
||||
/// a portrait photograph was turned upright. The crop is excluded because
|
||||
/// this is the shape being selected *from*.
|
||||
pub fn framed_size(&self) -> (u32, u32) {
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
self.graph.framing().output_size_uncropped(sw, sh)
|
||||
}
|
||||
|
||||
/// Rotate by quarter turns, wrapping. The rotate-left/right buttons.
|
||||
///
|
||||
/// The crop travels with the frame rather than staying where it was on
|
||||
/// screen. A crop is a decision about *this part of the photograph*, and
|
||||
/// leaving the rect in place while the image turns under it would move the
|
||||
/// selection onto a different part of the picture — so the rect is turned
|
||||
/// by the same quarter and the composition survives the rotation.
|
||||
pub fn rotate_quarters(&mut self, turns: i32) {
|
||||
let crop = self.graph.crop();
|
||||
if !crop.is_full() {
|
||||
self.graph.set_crop(rotate_crop(crop, turns));
|
||||
}
|
||||
self.graph.rotate_quarters(turns);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::ROTATE));
|
||||
}
|
||||
|
||||
/// Straightening, in degrees. Positive turns the image clockwise.
|
||||
pub fn angle(&self) -> f32 {
|
||||
self.graph.framing().angle()
|
||||
}
|
||||
|
||||
/// Quarter turns clockwise, 0..=3 — for the panel's readout.
|
||||
pub fn quarter_turns(&self) -> u8 {
|
||||
self.graph.framing().quarter_turns()
|
||||
}
|
||||
|
||||
pub fn flips(&self) -> (bool, bool) {
|
||||
self.graph.framing().flips()
|
||||
}
|
||||
|
||||
/// Mirror horizontally, about the frame's vertical centre line.
|
||||
pub fn toggle_flip_h(&mut self) {
|
||||
let (h, _) = self.graph.framing().flips();
|
||||
self.graph.set_param(
|
||||
dr_pipeline::framing::ID,
|
||||
dr_pipeline::framing::FLIP_H,
|
||||
f32::from(u8::from(!h)),
|
||||
);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::FLIP_H));
|
||||
}
|
||||
|
||||
pub fn toggle_flip_v(&mut self) {
|
||||
let (_, v) = self.graph.framing().flips();
|
||||
self.graph.set_param(
|
||||
dr_pipeline::framing::ID,
|
||||
dr_pipeline::framing::FLIP_V,
|
||||
f32::from(u8::from(!v)),
|
||||
);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::FLIP_V));
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The crop the user chose, as distinct from the one currently applied.
|
||||
///
|
||||
/// The remembered intent, but only while it is still credible: if the
|
||||
/// graph no longer holds what the auto-crop wrote, something else has set
|
||||
/// the crop since — a handle, a ratio, a sidecar, a paste, an undo — and
|
||||
/// that new rectangle *is* the intent. See [`Self::auto_crop`].
|
||||
pub(super) fn intended_crop(&self) -> CropRect {
|
||||
match self.auto_crop {
|
||||
Some((applied, intended)) if applied == self.graph.crop() => intended,
|
||||
_ => self.graph.crop(),
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Fit the crop to the area the straightening angle leaves defined.
|
||||
///
|
||||
/// Turning a rectangle inside its own bounds exposes its corners: there is
|
||||
/// no source pixel out there and the shader renders it black. Nothing in
|
||||
/// the render prevents it — a free angle deliberately does *not* change the
|
||||
/// output size, so that straightening a horizon leaves the frame where the
|
||||
/// user put it — which is correct for the drag and leaves black wedges in
|
||||
/// the corners of the finished photograph.
|
||||
///
|
||||
/// This is the correction, and it runs when the gesture **finishes**.
|
||||
/// Applied continuously it would fight the drag, shrinking the crop on
|
||||
/// every frame of the slider.
|
||||
///
|
||||
/// **It grows as well as shrinks.** The crop is recomputed from
|
||||
/// [`Self::intended_crop`] rather than from itself, so straightening
|
||||
/// further in takes more away and straightening back out gives it back,
|
||||
/// stopping at the rectangle the user actually chose. Deriving it from the
|
||||
/// applied crop instead — the obvious way, and how this first shipped —
|
||||
/// ratchets: every angle the slider rested at takes its cut and none of
|
||||
/// them is ever returned, so coming back to zero leaves a crop that
|
||||
/// nothing on screen explains.
|
||||
///
|
||||
/// The crop keeps its own shape — so a locked ratio survives — and keeps
|
||||
/// the side of the frame it was on; see [`CropRect::fitted_into`] for why
|
||||
/// it is not simply replaced by the inscribed rectangle.
|
||||
pub fn auto_crop_to_angle(&mut self) {
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
// At zero this is the whole frame, and fitting into it is the identity
|
||||
// — which is what returns an over-corrected crop to its full size.
|
||||
// There is deliberately no early exit for the upright case: that exit
|
||||
// is precisely what would strand the crop small.
|
||||
let bound = self.graph.framing().max_inscribed_crop(sw, sh);
|
||||
let intended = self.intended_crop();
|
||||
let want = intended.fitted_into(bound);
|
||||
|
||||
if want != self.graph.crop() {
|
||||
self.graph.set_crop(want);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Op(dr_pipeline::framing::ID));
|
||||
}
|
||||
// Recorded even when nothing moved: the pairing is what tells the next
|
||||
// call that this rectangle is a correction rather than a choice.
|
||||
self.auto_crop = Some((want, intended));
|
||||
}
|
||||
|
||||
/// Set the straightening angle, in degrees.
|
||||
pub fn set_angle(&mut self, degrees: f32) {
|
||||
self.graph.set_param(
|
||||
dr_pipeline::framing::ID,
|
||||
dr_pipeline::framing::ANGLE,
|
||||
degrees,
|
||||
);
|
||||
self.history.record(
|
||||
&self.graph,
|
||||
Edit::Param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE),
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether the framing currently changes the image — what lights the
|
||||
/// section's modified dot and enables its reset.
|
||||
///
|
||||
/// Asks whether it *edits*, not whether it is active: a zoomed view makes
|
||||
/// the framing active without changing the photograph, and a section that
|
||||
/// claimed an edit because the user scrolled would be lying.
|
||||
pub fn framing_edits_image(&self) -> bool {
|
||||
self.graph.framing().edits_image()
|
||||
}
|
||||
|
||||
/// Return crop, straightening, rotation and flips to neutral, leaving
|
||||
/// every colour adjustment alone.
|
||||
///
|
||||
/// The zoom is deliberately preserved: it is a viewing state, and resetting
|
||||
/// the framing is an edit, so throwing away where the user was looking
|
||||
/// would be an unrelated second effect.
|
||||
pub fn reset_framing(&mut self) {
|
||||
let view = self.graph.framing().view();
|
||||
self.graph.framing_mut().reset();
|
||||
self.graph.framing_mut().set_view(view);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::RESET_FRAMING));
|
||||
}
|
||||
|
||||
/// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×.
|
||||
pub fn zoom(&self) -> f32 {
|
||||
let v = self.graph.framing().view();
|
||||
if v.width <= 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
1.0 / v.width
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_zoomed(&self) -> bool {
|
||||
self.graph.framing().is_zoomed()
|
||||
}
|
||||
|
||||
/// Zoom about a point, given in fractions of the *visible* area.
|
||||
///
|
||||
/// Anchoring matters: zooming about the pointer keeps whatever is under
|
||||
/// it stationary, which is what makes a scroll-wheel zoom feel like it is
|
||||
/// magnifying the photograph rather than sliding it around.
|
||||
///
|
||||
/// `factor` multiplies the current zoom — above 1 moves in.
|
||||
pub fn zoom_about(&mut self, factor: f32, at_x: f32, at_y: f32) {
|
||||
const MAX_ZOOM: f32 = 16.0;
|
||||
|
||||
let view = self.graph.framing().view();
|
||||
let current = if view.width > 0.0 {
|
||||
1.0 / view.width
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let target = (current * factor).clamp(1.0, MAX_ZOOM);
|
||||
// Snapped so scrolling back out reliably reaches "fit" rather than
|
||||
// stopping a fraction short and leaving the image imperceptibly
|
||||
// panned.
|
||||
let target = if (target - 1.0).abs() < 0.01 {
|
||||
1.0
|
||||
} else {
|
||||
target
|
||||
};
|
||||
|
||||
let extent = (1.0 / target).clamp(CropRect::MIN_EXTENT, 1.0);
|
||||
|
||||
// The point under the cursor, in framed coordinates, must land back
|
||||
// under the cursor afterwards.
|
||||
let anchor_x = view.x + at_x.clamp(0.0, 1.0) * view.width;
|
||||
let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height;
|
||||
|
||||
self.set_view_clamped(
|
||||
anchor_x - at_x.clamp(0.0, 1.0) * extent,
|
||||
anchor_y - at_y.clamp(0.0, 1.0) * extent,
|
||||
extent,
|
||||
);
|
||||
}
|
||||
|
||||
/// Pan by a fraction of the *visible* area — what a drag reports.
|
||||
pub fn pan_by(&mut self, dx: f32, dy: f32) {
|
||||
let view = self.graph.framing().view();
|
||||
self.set_view_clamped(
|
||||
view.x + dx * view.width,
|
||||
view.y + dy * view.height,
|
||||
view.width,
|
||||
);
|
||||
}
|
||||
|
||||
/// Back to fitting the whole frame.
|
||||
pub fn reset_zoom(&mut self) {
|
||||
self.graph.framing_mut().set_view(CropRect::default());
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-4 | FR-DSP-1
|
||||
/// The zoom that puts one source pixel under one screen pixel.
|
||||
///
|
||||
/// **Derived from the file and the viewport rather than fixed at some
|
||||
/// multiple**, because 1:1 is not a number: a 60 MP frame in a 1200px
|
||||
/// viewport needs about 7× before its pixels are its own, and a
|
||||
/// screen-sized JPEG needs none at all. The same arithmetic
|
||||
/// [`Self::magnifies_source`] uses to decide how to *filter* the canvas,
|
||||
/// asked in the other direction — which is what keeps the readout the
|
||||
/// canvas shows and the zoom this lands on from disagreeing about what
|
||||
/// 100% means.
|
||||
///
|
||||
/// Never below 1.0: fitting is as far out as the view goes, so a
|
||||
/// photograph already smaller than the viewport is at 1:1 the moment it
|
||||
/// is fitted.
|
||||
pub fn one_to_one_zoom(&self, viewport_w: u32, viewport_h: u32) -> f32 {
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
let (fw, fh) = self.graph.output_size(sw, sh);
|
||||
let (rw, _) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1));
|
||||
if rw == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
(fw as f32 / rw as f32).max(1.0)
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-4
|
||||
/// Where the view is centred, in fractions of the framed image.
|
||||
///
|
||||
/// The form the inspection point is *remembered* in, and it has to be
|
||||
/// this one: the point is carried to the next photograph, and fractions
|
||||
/// of the frame are the only coordinates two different files share.
|
||||
pub fn inspection_point(&self) -> (f32, f32) {
|
||||
let v = self.graph.framing().view();
|
||||
(v.x + v.width / 2.0, v.y + v.height / 2.0)
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-4
|
||||
/// Put the view at 1:1, centred on a point in fractions of the framed
|
||||
/// image.
|
||||
///
|
||||
/// Separate from [`Self::toggle_inspection`] because the two callers are
|
||||
/// not the same person: the toggle is a photographer pressing something,
|
||||
/// and this is the next photograph arriving under the magnifier the last
|
||||
/// one was left under.
|
||||
pub fn inspect_at(&mut self, x: f32, y: f32, viewport_w: u32, viewport_h: u32) {
|
||||
let extent =
|
||||
(1.0 / self.one_to_one_zoom(viewport_w, viewport_h)).clamp(CropRect::MIN_EXTENT, 1.0);
|
||||
self.set_view_clamped(x - extent / 2.0, y - extent / 2.0, extent);
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-4 | FR-DEV-3
|
||||
/// Toggle between fitting the frame and inspecting it at 1:1.
|
||||
///
|
||||
/// **Why 1:1 and not "zoom in a bit".** Noise reduction and capture
|
||||
/// sharpening are judgements about individual pixels, and at a fitted
|
||||
/// view several source pixels are averaged into each screen pixel — so
|
||||
/// the frame looks cleaner and softer than it is, and the photographer
|
||||
/// corrects for a softness the display invented. Over-sharpening is the
|
||||
/// documented result. There is exactly one magnification at which those
|
||||
/// two controls are telling the truth, and this is the gesture that
|
||||
/// reaches it without anyone reading a percentage.
|
||||
///
|
||||
/// `at_x`/`at_y` are fractions of the *visible* area — the same
|
||||
/// coordinates [`Self::zoom_about`] takes, because they come from the
|
||||
/// same pointer over the same box.
|
||||
///
|
||||
/// Returns the point now under inspection in fractions of the framed
|
||||
/// image, or `None` where the view has gone back to fit. That is the
|
||||
/// answer *after* clamping, so a point near an edge is remembered where
|
||||
/// the view actually landed rather than where the finger was — otherwise
|
||||
/// the next photograph would be inspected somewhere the previous one
|
||||
/// never showed.
|
||||
///
|
||||
/// Leaves the history alone, and must: this changes no pixel of the file.
|
||||
/// See [`Self::framing_edits_image`] for the same distinction drawn from
|
||||
/// the other side.
|
||||
pub fn toggle_inspection(
|
||||
&mut self,
|
||||
at_x: f32,
|
||||
at_y: f32,
|
||||
viewport_w: u32,
|
||||
viewport_h: u32,
|
||||
) -> Option<(f32, f32)> {
|
||||
// Out from *any* zoom, not only from 1:1. The gesture means "show me
|
||||
// the whole photograph again", and a scroll wheel that stopped at
|
||||
// 173% must not leave the toggle inert.
|
||||
if self.is_zoomed() {
|
||||
self.reset_zoom();
|
||||
return None;
|
||||
}
|
||||
|
||||
let view = self.graph.framing().view();
|
||||
let x = view.x + at_x.clamp(0.0, 1.0) * view.width;
|
||||
let y = view.y + at_y.clamp(0.0, 1.0) * view.height;
|
||||
self.inspect_at(x, y, viewport_w, viewport_h);
|
||||
Some(self.inspection_point())
|
||||
}
|
||||
|
||||
/// Place a square view of `extent`, keeping it inside the frame.
|
||||
///
|
||||
/// Clamped rather than allowed to run off the edge: panning past the
|
||||
/// boundary would show undefined area beside the photograph, which reads
|
||||
/// as a rendering fault rather than as the end of the image.
|
||||
pub(super) fn set_view_clamped(&mut self, x: f32, y: f32, extent: f32) {
|
||||
let extent = extent.clamp(CropRect::MIN_EXTENT, 1.0);
|
||||
let max = 1.0 - extent;
|
||||
self.graph.framing_mut().set_view(CropRect {
|
||||
x: x.clamp(0.0, max.max(0.0)),
|
||||
y: y.clamp(0.0, max.max(0.0)),
|
||||
width: extent,
|
||||
height: extent,
|
||||
});
|
||||
}
|
||||
|
||||
/// The largest centred crop that, at the current straightening angle,
|
||||
/// contains no undefined area. What a "straighten and fill" action
|
||||
/// applies.
|
||||
pub fn max_inscribed_crop(&self) -> CropRect {
|
||||
let (w, h) = self.demosaiced.size();
|
||||
self.graph.framing().max_inscribed_crop(w, h)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::develop::test_support::*;
|
||||
|
||||
// --- the crop ratio lock ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn a_locked_ratio_is_resolved_in_output_pixels() {
|
||||
// 3:2 means three pixels across to two down, whatever shape the frame
|
||||
// it is being cut out of happens to be.
|
||||
let landscape = CropAspect::Fixed(3, 2);
|
||||
assert_eq!(landscape.ratio((6000, 4000), false), Some(1.5));
|
||||
assert_eq!(landscape.ratio((4000, 6000), false), Some(1.5));
|
||||
// Stood on its short edge.
|
||||
assert_eq!(landscape.ratio((6000, 4000), true), Some(2.0 / 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_frames_own_ratio_follows_the_frame() {
|
||||
// What separates `Original` from naming the same numbers: it is right
|
||||
// on the next photograph from another body, and after a quarter turn.
|
||||
let a = CropAspect::Original;
|
||||
assert_eq!(a.ratio((6000, 4000), false), Some(1.5));
|
||||
assert_eq!(a.ratio((4000, 6000), false), Some(2.0 / 3.0));
|
||||
assert_eq!(a.ratio((5000, 5000), false), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_locks_nothing() {
|
||||
assert_eq!(CropAspect::Free.ratio((6000, 4000), false), None);
|
||||
assert_eq!(CropAspect::Free.ratio((6000, 4000), true), None);
|
||||
assert!(!CropAspect::Free.has_orientation());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_square_has_no_second_orientation() {
|
||||
// Turning it would be a control that visibly does nothing, so the
|
||||
// switch is disabled and the flag is ignored either way.
|
||||
let square = CropAspect::Fixed(1, 1);
|
||||
assert!(!square.has_orientation());
|
||||
assert_eq!(square.ratio((6000, 4000), true), Some(1.0));
|
||||
assert_eq!(square.ratio((6000, 4000), false), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_named_ratio_has_to_be_turned_with_the_frame() {
|
||||
// The distinction that stops `Original` being flipped twice: a quarter
|
||||
// turn swaps the frame's axes, so a ratio resolved *against* the frame
|
||||
// has already turned by the time anything asks it.
|
||||
assert!(CropAspect::Fixed(16, 9).turns_with_the_frame());
|
||||
assert!(CropAspect::Fixed(3, 2).turns_with_the_frame());
|
||||
assert!(!CropAspect::Original.turns_with_the_frame());
|
||||
assert!(!CropAspect::Free.turns_with_the_frame());
|
||||
assert!(!CropAspect::Fixed(1, 1).turns_with_the_frame());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quarter_turn_leaves_a_locked_crop_the_shape_it_already_was() {
|
||||
// The whole reason the switch is flipped on a quarter turn. A crop
|
||||
// locked to 16:9 is carried through the turn by `rotate_crop`, coming
|
||||
// out at 9:16 of a frame whose axes have also swapped — so the lock
|
||||
// must now read as portrait, or the next drag would snap the crop back
|
||||
// upright and undo what the turn did to the composition.
|
||||
let (fw, fh) = (6000u32, 4000u32);
|
||||
let aspect = CropAspect::Fixed(16, 9);
|
||||
let before = CropRect::default().with_aspect(
|
||||
fw,
|
||||
fh,
|
||||
aspect.ratio((fw, fh), false).unwrap(),
|
||||
(0.5, 0.5),
|
||||
);
|
||||
|
||||
let after = rotate_crop(before, 1);
|
||||
let (tw, th) = (fh, fw);
|
||||
let got = (after.width * tw as f32) / (after.height * th as f32);
|
||||
let want = aspect.ratio((tw, th), true).unwrap();
|
||||
assert!(
|
||||
(got / want - 1.0).abs() < 1e-3,
|
||||
"turned crop is {got}, the flipped lock says {want}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_offered_ratio_has_a_name_and_a_place() {
|
||||
// The chips are drawn from this list, so a duplicate would light two
|
||||
// at once and an empty label would draw a blank button.
|
||||
let mut seen = Vec::new();
|
||||
for a in CropAspect::CHOICES {
|
||||
assert!(!a.label().is_empty(), "{a:?} has no label");
|
||||
assert!(!seen.contains(&a), "{a:?} is offered twice");
|
||||
seen.push(a);
|
||||
}
|
||||
assert_eq!(
|
||||
CropAspect::CHOICES[0],
|
||||
CropAspect::Free,
|
||||
"free is the default"
|
||||
);
|
||||
assert_eq!(CropAspect::default(), CropAspect::Free);
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-4
|
||||
/// The inspection zoom is 1:1 for *this* file in *this* viewport.
|
||||
///
|
||||
/// The number is the whole point. A magnifier that lands on some fixed
|
||||
/// multiple tells the photographer nothing about whether they are looking
|
||||
/// at the file's own pixels, and that is the only question noise reduction
|
||||
/// and capture sharpening can honestly be judged by.
|
||||
#[test]
|
||||
fn inspecting_lands_on_one_source_pixel_per_screen_pixel() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let (mut session, _) = grey_session(&ctx);
|
||||
|
||||
// Sixty-four source pixels fitted into thirty-two is one screen pixel
|
||||
// per two of the file's, so 1:1 is 2×.
|
||||
let one_to_one = session.one_to_one_zoom(32, 32);
|
||||
assert!(
|
||||
(one_to_one - 2.0).abs() < 1e-3,
|
||||
"a 64px frame in a 32px viewport is 2× at 1:1, not {one_to_one}"
|
||||
);
|
||||
|
||||
assert!(
|
||||
session.toggle_inspection(0.5, 0.5, 32, 32).is_some(),
|
||||
"the first toggle goes in"
|
||||
);
|
||||
assert!(
|
||||
(session.zoom() - one_to_one).abs() < 1e-3,
|
||||
"the view should have landed on 1:1, not {}",
|
||||
session.zoom()
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-4
|
||||
/// The second press goes back to fit — from any zoom, not only from 1:1.
|
||||
///
|
||||
/// A scroll wheel that stopped at 173% must not leave the toggle inert:
|
||||
/// the gesture means "show me the whole photograph again".
|
||||
#[test]
|
||||
fn the_inspection_toggle_returns_to_fit_from_any_zoom() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let (mut session, _) = grey_session(&ctx);
|
||||
|
||||
session.zoom_about(3.0, 0.5, 0.5);
|
||||
assert!(session.is_zoomed(), "the premise");
|
||||
|
||||
assert_eq!(
|
||||
session.toggle_inspection(0.5, 0.5, 32, 32),
|
||||
None,
|
||||
"toggling out reports no inspection point"
|
||||
);
|
||||
assert!(!session.is_zoomed());
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-4 | FR-DEV-5
|
||||
/// Inspecting is a way of looking, and leaves no trace on the photograph.
|
||||
///
|
||||
/// The failure this guards is quiet and expensive: a zoom that recorded a
|
||||
/// step would put a viewport rectangle on the undo stack and into the
|
||||
/// sidecar, and the photograph would then open on another device cropped
|
||||
/// to wherever somebody once looked.
|
||||
#[test]
|
||||
fn inspecting_writes_nothing_the_file_would_remember() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let (mut session, _) = grey_session(&ctx);
|
||||
assert!(!session.can_undo(), "the premise: nothing has been done");
|
||||
|
||||
session.toggle_inspection(0.25, 0.75, 32, 32);
|
||||
|
||||
assert!(!session.can_undo(), "a zoom is not a step to take back");
|
||||
assert!(session.is_neutral(), "and it is not an edit either");
|
||||
assert!(!session.framing_edits_image());
|
||||
}
|
||||
|
||||
/// The whole scroll-to-zoom path, end to end, in the order the user drives
|
||||
/// it: show the image fitted, *then* turn the wheel.
|
||||
///
|
||||
/// The lower layers each had zoom tests and each passed while this was
|
||||
/// broken, because every one of them set a view before its first render.
|
||||
/// That ordering hid the bug — a neutral framing compiles a prologue that
|
||||
/// never reads the crop rect, and while zoom was absent from the structure
|
||||
/// hash that pipeline stayed cached once zoomed. The session reported the
|
||||
/// new zoom, the uniforms carried the new view, and the pixels never moved.
|
||||
///
|
||||
/// So this asserts on the rendered pixels rather than on `zoom()`: the
|
||||
/// symptom was precisely that the state was right and the image was not.
|
||||
#[test]
|
||||
fn zooming_after_a_fitted_render_changes_the_pixels() {
|
||||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||||
log::warn!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
// A gradient, so any change in the sampled region moves the pixels.
|
||||
let (w, h) = (64u32, 64u32);
|
||||
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
rgba.extend_from_slice(&[(x * 4) as u8, (y * 4) as u8, 128, 255]);
|
||||
}
|
||||
}
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(&ctx, &rgba, w, h, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
|
||||
let fitted = session.render(64, 64).expect("fitted render");
|
||||
session.zoom_about(4.0, 0.5, 0.5);
|
||||
assert!(session.is_zoomed(), "the session did not register the zoom");
|
||||
let zoomed = session.render(64, 64).expect("zoomed render");
|
||||
|
||||
// Both images are still readable here because consecutive frames go to
|
||||
// alternating textures; see `AdjustPass::targets`. Holding two frames
|
||||
// at once would be meaningless against a single reused target.
|
||||
let before = read_back(&ctx, &fitted);
|
||||
let after = read_back(&ctx, &zoomed);
|
||||
let differing = before
|
||||
.iter()
|
||||
.zip(after.iter())
|
||||
.filter(|(a, b)| a != b)
|
||||
.count();
|
||||
|
||||
assert!(
|
||||
differing > 0,
|
||||
"zooming 4x after a fitted render produced identical pixels — the \
|
||||
view reached the session but not the shader"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magnification_follows_the_source_resolution_and_not_the_zoom_factor() {
|
||||
// What decides whether the canvas is filtered. The distinction this
|
||||
// guards is the reason the interface cannot answer it from `zoom()`
|
||||
// alone: the same 4x on a large source is still showing more source
|
||||
// pixels than screen pixels, while on a small one it is already
|
||||
// inventing values between them.
|
||||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||||
log::warn!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
// Bigger than the viewport it is shown in: `fit` scales it down, so
|
||||
// every screen pixel still has several source pixels behind it.
|
||||
let big = vec![128u8; (800 * 800 * 4) as usize];
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(&ctx, &big, 800, 800, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
assert!(
|
||||
!session.magnifies_source(200, 200),
|
||||
"a downscaled image is not magnified"
|
||||
);
|
||||
session.zoom_about(2.0, 0.5, 0.5);
|
||||
assert!(
|
||||
!session.magnifies_source(200, 200),
|
||||
"2x on a 4x-downscaled source is still below 1:1"
|
||||
);
|
||||
session.zoom_about(8.0, 0.5, 0.5);
|
||||
assert!(
|
||||
session.magnifies_source(200, 200),
|
||||
"16x on a 4x-downscaled source magnifies and must not be filtered"
|
||||
);
|
||||
|
||||
// Smaller than the viewport: `fit` refuses to upscale, so the render is
|
||||
// 1:1 and unzoomed is exactly the boundary — not past it.
|
||||
let small = vec![128u8; (100 * 100 * 4) as usize];
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(&ctx, &small, 100, 100, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
assert!(
|
||||
!session.magnifies_source(800, 800),
|
||||
"1:1 is the boundary, not past it — filtering must not flip on a \
|
||||
rounding error"
|
||||
);
|
||||
session.zoom_about(2.0, 0.5, 0.5);
|
||||
assert!(
|
||||
session.magnifies_source(800, 800),
|
||||
"any zoom past a 1:1 render magnifies"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_quarter_turns_return_a_crop_where_it_started() {
|
||||
// The property that makes rotation safe to repeat: a user who turns
|
||||
// past the orientation they wanted and keeps going must arrive back at
|
||||
// the crop they had, not at a slowly drifting one.
|
||||
let start = CropRect {
|
||||
x: 0.1,
|
||||
y: 0.2,
|
||||
width: 0.3,
|
||||
height: 0.4,
|
||||
};
|
||||
let mut r = start;
|
||||
for _ in 0..4 {
|
||||
r = rotate_crop(r, 1);
|
||||
}
|
||||
assert!((r.x - start.x).abs() < 1e-5, "x drifted to {}", r.x);
|
||||
assert!((r.y - start.y).abs() < 1e-5, "y drifted to {}", r.y);
|
||||
assert!((r.width - start.width).abs() < 1e-5);
|
||||
assert!((r.height - start.height).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quarter_turn_exchanges_a_crops_extents() {
|
||||
// A portrait selection on a landscape frame must come out landscape.
|
||||
// Were the extents left alone, the rect would keep its old shape while
|
||||
// the frame changed to the other one, and the crop would spill off the
|
||||
// photograph.
|
||||
let r = rotate_crop(
|
||||
CropRect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 0.25,
|
||||
height: 1.0,
|
||||
},
|
||||
1,
|
||||
);
|
||||
assert!((r.width - 1.0).abs() < 1e-5, "width was {}", r.width);
|
||||
assert!((r.height - 0.25).abs() < 1e-5, "height was {}", r.height);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotating_a_crop_keeps_it_inside_the_frame() {
|
||||
// Whatever the angle and wherever the rect, the result must still be a
|
||||
// rect the pipeline can render: outside the unit square it would
|
||||
// sample undefined area, and degenerate it is a zero-sized texture.
|
||||
for turns in -5..=5 {
|
||||
for rect in [
|
||||
CropRect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
},
|
||||
CropRect {
|
||||
x: 0.7,
|
||||
y: 0.8,
|
||||
width: 0.3,
|
||||
height: 0.2,
|
||||
},
|
||||
CropRect {
|
||||
x: 0.0,
|
||||
y: 0.45,
|
||||
width: 0.02,
|
||||
height: 0.02,
|
||||
},
|
||||
] {
|
||||
let r = rotate_crop(rect, turns);
|
||||
assert!(
|
||||
r.x >= 0.0 && r.y >= 0.0,
|
||||
"{turns} turns of {rect:?} gave {r:?}"
|
||||
);
|
||||
assert!(
|
||||
r.x + r.width <= 1.0 + 1e-5 && r.y + r.height <= 1.0 + 1e-5,
|
||||
"{turns} turns of {rect:?} left the frame: {r:?}"
|
||||
);
|
||||
assert!(
|
||||
r.width >= CropRect::MIN_EXTENT && r.height >= CropRect::MIN_EXTENT,
|
||||
"{turns} turns of {rect:?} went degenerate: {r:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opposite_quarter_turns_cancel() {
|
||||
// The rotate-left and rotate-right buttons must undo one another, or
|
||||
// correcting an over-rotation would land somewhere new each time.
|
||||
let start = CropRect {
|
||||
x: 0.15,
|
||||
y: 0.05,
|
||||
width: 0.5,
|
||||
height: 0.25,
|
||||
};
|
||||
let there_and_back = rotate_crop(rotate_crop(start, 1), -1);
|
||||
assert!((there_and_back.x - start.x).abs() < 1e-5);
|
||||
assert!((there_and_back.y - start.y).abs() < 1e-5);
|
||||
assert!((there_and_back.width - start.width).abs() < 1e-5);
|
||||
assert!((there_and_back.height - start.height).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_crop_survives_rotation_as_a_full_crop() {
|
||||
// The common case: rotating an uncropped photograph must not quietly
|
||||
// introduce a crop, which would shrink the exported image.
|
||||
assert!(rotate_crop(CropRect::default(), 1).is_full());
|
||||
assert!(rotate_crop(CropRect::default(), -3).is_full());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
//! Named snapshots and the undo/redo stack.
|
||||
use dr_pipeline::Edit;
|
||||
|
||||
use crate::labels;
|
||||
|
||||
use super::session::DevelopSession;
|
||||
|
||||
impl DevelopSession {
|
||||
// ---- named snapshots ---------------------------------------------------
|
||||
|
||||
/// TRACES: FR-DEV-5
|
||||
/// Hand the session the snapshots its sidecar holds. Called once, on
|
||||
/// open, beside [`Self::apply_version`].
|
||||
pub fn set_snapshots(&mut self, snapshots: Vec<dr_pipeline::Version>) {
|
||||
self.snapshots = snapshots;
|
||||
self.removed_snapshots.clear();
|
||||
}
|
||||
|
||||
/// The snapshots as they stand, oldest first.
|
||||
pub fn snapshots(&self) -> &[dr_pipeline::Version] {
|
||||
&self.snapshots
|
||||
}
|
||||
|
||||
/// The ids deleted this sitting, for the save.
|
||||
pub fn removed_snapshots(&self) -> &[String] {
|
||||
&self.removed_snapshots
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5
|
||||
/// Name the state the photograph is in, and keep it. Returns the id.
|
||||
///
|
||||
/// Not a history step: taking a snapshot changes nothing about the edit,
|
||||
/// and an undo that removed one would be undoing a decision to remember
|
||||
/// rather than a change to the photograph. Deleting one is the same.
|
||||
///
|
||||
/// The id is stamped with the second and a per-process random word
|
||||
/// rather than counted, because two devices can each take a snapshot of
|
||||
/// the same photograph and both have to survive the merge — which keys
|
||||
/// on this id, and would fold two `snap-3`s into one.
|
||||
pub fn take_snapshot(&mut self, name: &str) -> String {
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
let salt = std::collections::hash_map::RandomState::new()
|
||||
.build_hasher()
|
||||
.finish();
|
||||
let id = format!("snap-{now}-{:08x}", salt as u32);
|
||||
|
||||
let name = name.trim();
|
||||
let name = if name.is_empty() {
|
||||
format!("Snapshot {}", self.snapshots.len() + 1)
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
let mut version = dr_pipeline::Version::from_graph(id.clone(), name, &self.graph);
|
||||
// The stack with the model's coverage folded in, for the reason the
|
||||
// save uses it: a subject layer stored by identity alone renders as
|
||||
// nothing until a model is run, and a snapshot restored on the other
|
||||
// device, or in a batch export, never gets one.
|
||||
version.masks = self.masks_for_storage();
|
||||
version.modified = now;
|
||||
self.snapshots.push(version);
|
||||
id
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5
|
||||
/// Put the photograph back the way a snapshot has it. One history step,
|
||||
/// so it is undoable as a whole, exactly as a paste is.
|
||||
pub fn restore_snapshot(&mut self, id: &str) -> bool {
|
||||
let Some(version) = self.snapshots.iter().find(|v| v.uuid == id).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let rebake = version.apply(&mut self.graph);
|
||||
self.pay_film_debt(&rebake);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::SNAPSHOT_RESTORED));
|
||||
true
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5
|
||||
pub fn rename_snapshot(&mut self, id: &str, name: &str) {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(v) = self.snapshots.iter_mut().find(|v| v.uuid == id) {
|
||||
v.name = name.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5
|
||||
/// Forget a snapshot. Remembered as a deletion so the save takes it out
|
||||
/// of the file rather than merely not putting it back.
|
||||
pub fn delete_snapshot(&mut self, id: &str) {
|
||||
let before = self.snapshots.len();
|
||||
self.snapshots.retain(|v| v.uuid != id);
|
||||
if self.snapshots.len() != before {
|
||||
self.removed_snapshots.push(id.to_string());
|
||||
}
|
||||
if self.compared_snapshot.as_deref() == Some(id) {
|
||||
self.compared_snapshot = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-7
|
||||
/// Hold a comparison against a snapshot, or let it go. Returns whether
|
||||
/// anything changed, so a repeat costs no render.
|
||||
pub fn compare_snapshot(&mut self, id: Option<&str>) -> bool {
|
||||
let id = id.filter(|id| self.snapshots.iter().any(|v| v.uuid == *id));
|
||||
if self.compared_snapshot.as_deref() == id {
|
||||
return false;
|
||||
}
|
||||
self.compared_snapshot = id.map(str::to_string);
|
||||
true
|
||||
}
|
||||
|
||||
/// The snapshot being held against the edit, if one is.
|
||||
pub fn compared_snapshot(&self) -> Option<&str> {
|
||||
self.compared_snapshot.as_deref()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-7
|
||||
/// Render the photograph as a snapshot has it, without becoming it.
|
||||
///
|
||||
/// The same suspension [`Self::render_original`] uses — borrow the graph
|
||||
/// for one render and hand it back — because it is the same question
|
||||
/// about a different reference point: "the version I liked twenty
|
||||
/// minutes ago" instead of the file. Nothing is recorded and nothing is
|
||||
/// marked modified. A held comparison against a snapshot that has since
|
||||
/// been deleted falls back to the edit itself, which is what is on
|
||||
/// screen anyway.
|
||||
pub fn render_compared(&mut self, width: u32, height: u32) -> Result<slint::Image, String> {
|
||||
let Some(version) = self
|
||||
.compared_snapshot
|
||||
.as_deref()
|
||||
.and_then(|id| self.snapshots.iter().find(|v| v.uuid == id))
|
||||
.cloned()
|
||||
else {
|
||||
return self.render(width, height);
|
||||
};
|
||||
let saved = self.graph.state();
|
||||
let debt = version.apply(&mut self.graph);
|
||||
self.pay_film_debt(&debt);
|
||||
let rendered = self.render(width, height);
|
||||
let debt = self.graph.set_state(&saved);
|
||||
self.pay_film_debt(&debt);
|
||||
rendered
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5
|
||||
/// The snapshot list as the panel draws it, oldest first.
|
||||
pub fn snapshot_rows(&self) -> Vec<crate::SnapshotRow> {
|
||||
self.snapshots
|
||||
.iter()
|
||||
.map(|v| crate::SnapshotRow {
|
||||
id: v.uuid.as_str().into(),
|
||||
name: v.name.as_str().into(),
|
||||
comparing: self.compared_snapshot.as_deref() == Some(v.uuid.as_str()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5
|
||||
/// [`Self::pay_film_debt`] for a history step, when the step went
|
||||
/// anywhere.
|
||||
///
|
||||
/// The guard is the whole difference between the two: a step that found
|
||||
/// nowhere to go left the graph alone, and clearing the film because
|
||||
/// undo hit the floor would take the picture's stock off it.
|
||||
pub(super) fn settle(&mut self, step: &dr_pipeline::Step) {
|
||||
if let dr_pipeline::Step::Took(rebake) = step {
|
||||
self.pay_film_debt(rebake);
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5
|
||||
/// Step the edit back one, returning whether anything moved.
|
||||
///
|
||||
/// The panel must be rebuilt from [`Self::rows`] afterwards, for the same
|
||||
/// reason a paste must: this moves values the controls are showing and
|
||||
/// nothing here pushes them.
|
||||
pub fn undo(&mut self) -> bool {
|
||||
let step = self.history.undo(&mut self.graph);
|
||||
self.settle(&step);
|
||||
step.moved()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5
|
||||
/// Step the edit forward one, returning whether anything moved.
|
||||
pub fn redo(&mut self) -> bool {
|
||||
let step = self.history.redo(&mut self.graph);
|
||||
self.settle(&step);
|
||||
step.moved()
|
||||
}
|
||||
|
||||
pub fn can_undo(&self) -> bool {
|
||||
self.history.can_undo()
|
||||
}
|
||||
|
||||
pub fn can_redo(&self) -> bool {
|
||||
self.history.can_redo()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5 | FR-DEV-7
|
||||
/// Every step this photograph has been through, newest first.
|
||||
///
|
||||
/// Newest first because the list is consulted to take back something just
|
||||
/// done, not browsed chronologically — the order `dr_catalog::trash`
|
||||
/// settled on for the same question. It also keeps the interesting end
|
||||
/// against the heading, so a stack sixty-four deep does not put the step
|
||||
/// the photographer is looking for at the bottom of a long scroll.
|
||||
///
|
||||
/// The reversal happens here rather than in the core, which returns the
|
||||
/// stack in stack order and stamps each row with its own index — so
|
||||
/// nothing on this side does arithmetic to turn a row back into a step.
|
||||
pub fn history_rows(&self) -> Vec<crate::HistoryRow> {
|
||||
let mut rows: Vec<_> = self
|
||||
.history
|
||||
.entries(&self.graph)
|
||||
.into_iter()
|
||||
.map(|entry| crate::HistoryRow {
|
||||
index: entry.index as i32,
|
||||
label: labels::resolve(entry.label.0).into(),
|
||||
current: entry.current,
|
||||
// Everything past the mark is a future the photographer
|
||||
// stepped out of. Still listed, because it is still reachable
|
||||
// by redo and hiding it would make redo arrive somewhere the
|
||||
// panel never mentioned — but drawn as the branch it is.
|
||||
undone: entry.index > self.history.cursor(),
|
||||
})
|
||||
.collect();
|
||||
rows.reverse();
|
||||
rows
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5 | FR-DEV-7
|
||||
/// Step straight to one row of [`Self::history_rows`].
|
||||
///
|
||||
/// Takes the row's own `index`, not its position in that list.
|
||||
/// TRACES: FR-DEV-5
|
||||
/// A number that changes exactly when [`Self::history_rows`] would.
|
||||
///
|
||||
/// The panel is rebuilt off this rather than every redraw: a drag ends in
|
||||
/// a redraw per frame and changes no row, and pushing a fresh model makes
|
||||
/// the toolkit tear down and recreate every one of them.
|
||||
pub fn history_revision(&self) -> u64 {
|
||||
self.history.revision()
|
||||
}
|
||||
|
||||
pub fn go_to_history(&mut self, index: i32) -> bool {
|
||||
let Ok(index) = usize::try_from(index) else {
|
||||
return false;
|
||||
};
|
||||
let step = self.history.go_to(&mut self.graph, index);
|
||||
self.settle(&step);
|
||||
step.moved()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5
|
||||
/// What undo would take back, and what redo would put back.
|
||||
///
|
||||
/// Named on the buttons rather than left to the bare verb. "Undo" asks the
|
||||
/// photographer to remember what they last did, which after a run of small
|
||||
/// adjustments is exactly what they have stopped tracking — and it is the
|
||||
/// moment they are least willing to press a button and find out.
|
||||
///
|
||||
/// Empty when there is nowhere to go, so the caller falls back to the verb
|
||||
/// alone rather than printing a label for a disabled control.
|
||||
pub fn undo_label(&self) -> String {
|
||||
// Undo takes back the step the graph is *standing on*, so the row to
|
||||
// name is the current one — not the one it will land on.
|
||||
self.step_name(self.history.cursor(), self.history.can_undo())
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-5
|
||||
pub fn redo_label(&self) -> String {
|
||||
self.step_name(self.history.cursor() + 1, self.history.can_redo())
|
||||
}
|
||||
|
||||
pub(super) fn step_name(&self, index: usize, offered: bool) -> String {
|
||||
if !offered {
|
||||
return String::new();
|
||||
}
|
||||
self.history
|
||||
.entries(&self.graph)
|
||||
.into_iter()
|
||||
.find(|e| e.index == index)
|
||||
.map(|e| labels::resolve(e.label.0))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::develop::test_support::*;
|
||||
|
||||
/// TRACES: FR-DEV-5
|
||||
/// A snapshot is a state the photographer named: taking one changes
|
||||
/// nothing, going back to it is one step, and undo takes the whole of
|
||||
/// that step back.
|
||||
#[test]
|
||||
fn a_snapshot_is_restored_as_one_step_and_undone_as_one() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let (mut session, _) = grey_session(&ctx);
|
||||
|
||||
let rows = session.rows();
|
||||
let row = rows
|
||||
.iter()
|
||||
.find(|row| {
|
||||
session.set_param(row.op_index, row.param_index, row.maximum);
|
||||
!session.is_neutral()
|
||||
})
|
||||
.expect("some control in the panel moves the picture")
|
||||
.clone();
|
||||
let liked = session.copy_settings();
|
||||
let steps_before = session.history_rows().len();
|
||||
|
||||
let id = session.take_snapshot("Liked this");
|
||||
assert_eq!(session.snapshots().len(), 1);
|
||||
assert_eq!(session.snapshots()[0].name, "Liked this");
|
||||
assert_eq!(
|
||||
session.history_rows().len(),
|
||||
steps_before,
|
||||
"naming a state is not a change to the photograph"
|
||||
);
|
||||
|
||||
// Move on, then go back.
|
||||
session.set_param(row.op_index, row.param_index, row.minimum);
|
||||
let moved_on = session.copy_settings();
|
||||
assert_ne!(moved_on, liked, "the premise: the edit has moved");
|
||||
let steps_moved = session.history_rows().len();
|
||||
|
||||
assert!(session.restore_snapshot(&id));
|
||||
assert_eq!(session.copy_settings(), liked, "back to the named state");
|
||||
assert_eq!(
|
||||
session.history_rows().len(),
|
||||
steps_moved + 1,
|
||||
"restoring is one step"
|
||||
);
|
||||
|
||||
assert!(session.undo());
|
||||
assert_eq!(
|
||||
session.copy_settings(),
|
||||
moved_on,
|
||||
"and undo takes the whole restore back"
|
||||
);
|
||||
|
||||
// A name nobody typed is numbered rather than blank.
|
||||
session.take_snapshot(" ");
|
||||
assert_eq!(session.snapshots()[1].name, "Snapshot 2");
|
||||
|
||||
session.delete_snapshot(&id);
|
||||
assert_eq!(session.snapshots().len(), 1);
|
||||
assert_eq!(session.removed_snapshots(), [id.as_str()]);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-7 | FR-DEV-5
|
||||
/// Holding a snapshot against the edit is the same bargain as holding
|
||||
/// the original: the picture changes, and nothing else does.
|
||||
#[test]
|
||||
fn comparing_against_a_snapshot_leaves_the_edit_exactly_as_it_was() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let (mut session, _) = grey_session(&ctx);
|
||||
|
||||
let rows = session.rows();
|
||||
let row = rows
|
||||
.iter()
|
||||
.find(|row| {
|
||||
session.set_param(row.op_index, row.param_index, row.maximum);
|
||||
!session.is_neutral()
|
||||
})
|
||||
.expect("some control in the panel moves the picture")
|
||||
.clone();
|
||||
let id = session.take_snapshot("Bright");
|
||||
session.set_param(row.op_index, row.param_index, row.minimum);
|
||||
let edit = session.copy_settings();
|
||||
let steps = session.history_rows().len();
|
||||
|
||||
assert!(session.compare_snapshot(Some(&id)), "the hold began");
|
||||
assert!(
|
||||
!session.compare_snapshot(Some(&id)),
|
||||
"a repeat of the same hold is not a change"
|
||||
);
|
||||
assert_eq!(session.compared_snapshot(), Some(id.as_str()));
|
||||
session
|
||||
.render_compared(64, 64)
|
||||
.expect("render the snapshot");
|
||||
|
||||
assert_eq!(session.copy_settings(), edit, "every parameter comes back");
|
||||
assert_eq!(session.history_rows().len(), steps, "looking is not a step");
|
||||
|
||||
assert!(session.compare_snapshot(None), "and letting go is one");
|
||||
assert!(session.compared_snapshot().is_none());
|
||||
assert!(
|
||||
!session.compare_snapshot(Some("nothing-by-this-name")),
|
||||
"a snapshot that does not exist cannot be held"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,920 @@
|
||||
//! Looking at masks: view style, the segmentation overlay, the layer list,
|
||||
//! and editing a mask by hand with the brush.
|
||||
#[cfg(test)]
|
||||
use dr_gpu::GpuContext;
|
||||
use dr_pipeline::mask::MaskSource;
|
||||
|
||||
use crate::labels;
|
||||
use dr_pipeline::Edit;
|
||||
|
||||
use super::session::DevelopSession;
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// How one layer's mask is shown on the canvas.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct MaskView {
|
||||
shown: bool,
|
||||
/// Index into [`MASK_COLOURS`].
|
||||
colour: usize,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// The colours a mask may be shown in, in linear sRGB.
|
||||
///
|
||||
/// Six, chosen to be told apart at half strength over a photograph rather
|
||||
/// than to be pretty: red and green and blue at the corners, and the three
|
||||
/// between them. Exposed so the panel draws its swatches from the same table
|
||||
/// the shader is handed, and a seventh colour is one line here and nowhere
|
||||
/// else.
|
||||
pub const MASK_COLOURS: [[f32; 3]; 6] = [
|
||||
[0.85, 0.10, 0.15],
|
||||
[0.15, 0.80, 0.25],
|
||||
[0.20, 0.45, 1.00],
|
||||
[0.95, 0.80, 0.10],
|
||||
[0.90, 0.20, 0.85],
|
||||
[0.15, 0.85, 0.90],
|
||||
];
|
||||
|
||||
impl DevelopSession {
|
||||
// ----------------------------------------------------------------------
|
||||
// Seeing the mask (FR-DEV-19c)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// What the canvas should draw over the photograph, if anything.
|
||||
///
|
||||
/// Every layer whose eye is open, in stack order, each in its colour —
|
||||
/// and `None` when no eye is, so the rasteriser and the composer can
|
||||
/// take the path they always took.
|
||||
///
|
||||
/// Rebuilt per call rather than kept in step with the stack, because it
|
||||
/// is a walk over at most eight layers — cheaper than the invalidation a
|
||||
/// cached copy would need every time a layer is added, removed, renamed
|
||||
/// or reordered.
|
||||
pub(crate) fn reveal(&self) -> Option<dr_pipeline::mask::Reveal> {
|
||||
use dr_pipeline::mask::{Reveal, RevealedLayer};
|
||||
// Only while masking. The eyes are per layer and outlive the mode,
|
||||
// so a photographer coming back finds the layers they were looking
|
||||
// at still lit — but a tint is a way of looking at a *mask*, and
|
||||
// outside Local there is no mask being looked at. Without this the
|
||||
// sky stayed red through Repair and back in Photo, a mode that had
|
||||
// been left leaving its overlay behind (ui-navigation.md D-N1).
|
||||
if !self.show_overlay {
|
||||
return None;
|
||||
}
|
||||
let layers: Vec<RevealedLayer> = self
|
||||
.graph
|
||||
.masks()
|
||||
.layers()
|
||||
.iter()
|
||||
.filter_map(|l| {
|
||||
let view = self.mask_views.get(&l.id).filter(|v| v.shown)?;
|
||||
Some(RevealedLayer {
|
||||
layer: l.id.clone(),
|
||||
colour: MASK_COLOURS[view.colour % MASK_COLOURS.len()],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if layers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Reveal {
|
||||
layers,
|
||||
style: self.reveal_style,
|
||||
})
|
||||
}
|
||||
|
||||
/// How shown masks are drawn, as an index into
|
||||
/// [`dr_pipeline::mask::RevealStyle::ALL`].
|
||||
///
|
||||
/// An index because the panel offers it as a strip of chips and an index
|
||||
/// is what a strip of chips reports. The enum stays the thing that is
|
||||
/// stored, so a fourth style is a variant and a label rather than a number
|
||||
/// two files have to agree on.
|
||||
pub fn mask_view_style(&self) -> usize {
|
||||
dr_pipeline::mask::RevealStyle::ALL
|
||||
.iter()
|
||||
.position(|&a| a == self.reveal_style)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Choose how shown masks are drawn.
|
||||
///
|
||||
/// Takes no history step and marks nothing dirty: this is how the
|
||||
/// photograph is being *looked at*, not an edit to it.
|
||||
pub fn set_mask_view_style(&mut self, style: usize) {
|
||||
if let Some(&s) = dr_pipeline::mask::RevealStyle::ALL.get(style) {
|
||||
self.reveal_style = s;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this layer's mask is drawn over the photograph.
|
||||
pub fn mask_shown(&self, id: &str) -> bool {
|
||||
self.mask_views.get(id).is_some_and(|v| v.shown)
|
||||
}
|
||||
|
||||
/// Open or close one layer's eye.
|
||||
pub fn set_mask_shown(&mut self, id: &str, shown: bool) {
|
||||
let colour = self.next_mask_colour();
|
||||
self.mask_views
|
||||
.entry(id.to_string())
|
||||
.or_insert(MaskView {
|
||||
shown: false,
|
||||
colour,
|
||||
})
|
||||
.shown = shown;
|
||||
}
|
||||
|
||||
/// Whether any mask at all is being shown.
|
||||
///
|
||||
/// What the region overlay asks before drawing: two overlays that mean
|
||||
/// different things, on top of each other, is neither.
|
||||
pub fn any_mask_shown(&self) -> bool {
|
||||
self.reveal().is_some()
|
||||
}
|
||||
|
||||
/// Which of [`MASK_COLOURS`] this layer is shown in.
|
||||
pub fn mask_colour(&self, id: &str) -> usize {
|
||||
self.mask_views
|
||||
.get(id)
|
||||
.map_or(0, |v| v.colour % MASK_COLOURS.len())
|
||||
}
|
||||
|
||||
/// Give this layer a colour from [`MASK_COLOURS`].
|
||||
///
|
||||
/// Choosing a colour is asking to see it: a swatch pressed on a layer
|
||||
/// whose eye was closed opens the eye, because nothing else the press
|
||||
/// could mean would change a pixel.
|
||||
pub fn set_mask_colour(&mut self, id: &str, colour: usize) {
|
||||
let colour = colour % MASK_COLOURS.len();
|
||||
self.mask_views
|
||||
.entry(id.to_string())
|
||||
.and_modify(|v| {
|
||||
v.colour = colour;
|
||||
v.shown = true;
|
||||
})
|
||||
.or_insert(MaskView {
|
||||
shown: true,
|
||||
colour,
|
||||
});
|
||||
}
|
||||
|
||||
/// The colour the next layer to be shown should take: the first not
|
||||
/// already in use, or round the palette again once all are.
|
||||
///
|
||||
/// So that two masks made one after the other come up in two colours
|
||||
/// without anyone having to choose — which is the case that matters,
|
||||
/// since "how do these two meet" is the question two masks are shown to
|
||||
/// answer.
|
||||
fn next_mask_colour(&self) -> usize {
|
||||
let used: Vec<usize> = self.mask_views.values().map(|v| v.colour).collect();
|
||||
(0..MASK_COLOURS.len())
|
||||
.find(|c| !used.contains(c))
|
||||
.unwrap_or(self.mask_views.len() % MASK_COLOURS.len())
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// Show the mask of a layer that has just been made.
|
||||
///
|
||||
/// **Making a mask is asking what it selected**, and for a subject or a
|
||||
/// category that question has no other answer: the model's outline is not
|
||||
/// derivable from anything on screen, the layer carries no adjustment yet,
|
||||
/// and the list it was chosen from says "architecture 23%" and nothing
|
||||
/// about *which* 23%. So the mask appears with the layer rather than
|
||||
/// waiting to be asked for a second time — its eye open, in the next
|
||||
/// colour nothing else is using.
|
||||
///
|
||||
/// Only this layer's eye. Every other layer keeps whatever the
|
||||
/// photographer set it to, which is the trap `Masking.overlay-hidden`
|
||||
/// documents: an automatic reveal that undoes a switch somebody turned
|
||||
/// off is worse than none.
|
||||
pub(super) fn show_new_mask(&mut self, id: &str) {
|
||||
let colour = self.next_mask_colour();
|
||||
self.mask_views.insert(
|
||||
id.to_string(),
|
||||
MaskView {
|
||||
shown: true,
|
||||
colour,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// The region overlay
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
pub fn overlay_enabled(&self) -> bool {
|
||||
self.show_overlay
|
||||
}
|
||||
|
||||
pub fn set_overlay(&mut self, on: bool) {
|
||||
self.show_overlay = on;
|
||||
}
|
||||
|
||||
/// The part of the overlay the view is currently showing, in overlay
|
||||
/// pixels: `(x, y, width, height)`.
|
||||
///
|
||||
/// The overlay is a **source-space** picture, and the canvas beside it
|
||||
/// shows whatever the crop, the zoom and the pan selected out of that same
|
||||
/// space. Drawn whole, it stays the size of the frame while the photograph
|
||||
/// moves underneath — which is exactly the fault this exists to fix.
|
||||
///
|
||||
/// Reported as a clip rectangle rather than resampled here: the compositor
|
||||
/// crops and scales a texture for nothing, where doing it on the CPU would
|
||||
/// mean rebuilding a megapixel image on every frame of a drag.
|
||||
///
|
||||
/// **Known gap.** A quarter turn or a flip permutes the axes, and a clip
|
||||
/// rectangle cannot express that — the straightening angle is handled
|
||||
/// alongside this, but a quarter-turned frame shows the overlay unturned.
|
||||
/// Fixing it properly means running the overlay through the same shader
|
||||
/// prologue the image goes through, which is the right answer and a larger
|
||||
/// one than this.
|
||||
pub fn overlay_clip(&self) -> (i32, i32, i32, i32) {
|
||||
let Some(seg) = self.segmentation.as_ref() else {
|
||||
return (0, 0, 0, 0);
|
||||
};
|
||||
// **Shown pixels, matching `overlay_image`.** The crop and the
|
||||
// viewport are fractions of the photograph as the user sees it — the
|
||||
// prologue maps an output pixel through `crop_rect` *before* it
|
||||
// unturns the frame — so measuring them against the sensor's width
|
||||
// and height puts the clip on the wrong axis the moment the two
|
||||
// differ. That is the same confusion as the overlay itself had, one
|
||||
// layer down, and it is silent for exactly the images where it is
|
||||
// wrong: a landscape frame has nothing to notice.
|
||||
let (sw, sh) = seg.proxy_size();
|
||||
let (w, h) = self
|
||||
.graph
|
||||
.framing()
|
||||
.effective_orientation()
|
||||
.oriented_size(sw as u32, sh as u32);
|
||||
let rect = self.graph.framing().visible_rect();
|
||||
|
||||
// Rounded outward, so half a pixel of rounding never shows as a strip
|
||||
// of missing overlay along an edge.
|
||||
let x = (rect.x * w as f32).floor().max(0.0) as i32;
|
||||
let y = (rect.y * h as f32).floor().max(0.0) as i32;
|
||||
let right = ((rect.x + rect.width) * w as f32).ceil().min(w as f32) as i32;
|
||||
let bottom = ((rect.y + rect.height) * h as f32).ceil().min(h as f32) as i32;
|
||||
|
||||
(x, y, (right - x).max(1), (bottom - y).max(1))
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// A false-coloured picture of what a click can select, for the canvas.
|
||||
///
|
||||
/// Returned as a CPU image rather than a texture, and deliberately: it is
|
||||
/// regenerated only when the segmentation changes, it is proxy-sized
|
||||
/// rather than viewport-sized, and the compositor scales and clips it for
|
||||
/// free. Putting it on the GPU would buy nothing and add a second texture
|
||||
/// to keep in step with the view.
|
||||
///
|
||||
/// `None` when the overlay is off or nothing has been segmented, so the
|
||||
/// caller can bind this straight to an image source.
|
||||
pub fn overlay_image(&self) -> Option<slint::Image> {
|
||||
if !self.show_overlay {
|
||||
return None;
|
||||
}
|
||||
let (rgba, w, h) = self.segmentation.as_ref()?.overlay_rgba();
|
||||
|
||||
// TRACES: FR-DEV-3h
|
||||
// **Turned the right way up before it is drawn.** Instance masks live
|
||||
// in sensor space, because the generated shader samples them after
|
||||
// the framing map (`uv_src`) — but this is not sampled by that shader.
|
||||
// It is a flat image handed to the compositor to lay over a
|
||||
// photograph that *has* been through the framing map, so it has to
|
||||
// arrive in the same space the photograph is in.
|
||||
//
|
||||
// Without this the outlines are drawn in the sensor's orientation over
|
||||
// an upright picture: on a portrait frame the colour sits nowhere near
|
||||
// the subject, which reads as the detector having failed rather than
|
||||
// as the overlay being turned. Nothing announces it, and it is
|
||||
// invisible on landscape frames, which is most of them.
|
||||
let (rgba, w, h) = self
|
||||
.graph
|
||||
.framing()
|
||||
.effective_orientation()
|
||||
.into_shown(&rgba, w, h, 4);
|
||||
|
||||
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(&rgba, w, h);
|
||||
Some(slint::Image::from_rgba8(buffer))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Mask layers
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/// The layers, as `(id, name, enabled, is_active_selection)`.
|
||||
pub fn mask_layers(&self) -> Vec<(String, String, bool, bool)> {
|
||||
self.graph
|
||||
.masks()
|
||||
.layers()
|
||||
.iter()
|
||||
.map(|l| {
|
||||
(
|
||||
l.id.clone(),
|
||||
l.display_name().to_string(),
|
||||
l.enabled,
|
||||
self.active_masks.iter().any(|a| a == &l.id),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What kind of mask a layer is — "regions", "linear", "radial".
|
||||
pub fn mask_kind(&self, id: &str) -> &'static str {
|
||||
self.part_of(id).map_or("", |p| p.source.kind())
|
||||
}
|
||||
|
||||
pub fn mask_inverted(&self, id: &str) -> bool {
|
||||
self.graph.masks().get(id).is_some_and(|l| l.invert)
|
||||
}
|
||||
|
||||
pub fn mask_opacity(&self, id: &str) -> f32 {
|
||||
self.graph.masks().get(id).map_or(1.0, |l| l.opacity)
|
||||
}
|
||||
|
||||
/// Whether a layer has any adjustment on it yet.
|
||||
///
|
||||
/// Distinct from `is_active`, which also asks whether the layer is enabled
|
||||
/// and visible. The panel wants specifically "you have made a selection
|
||||
/// and not yet done anything with it", because that state looks identical
|
||||
/// to a broken mask and is the most likely thing a first-time user hits.
|
||||
pub fn mask_is_adjusted(&self, id: &str) -> bool {
|
||||
self.graph
|
||||
.masks()
|
||||
.get(id)
|
||||
.is_some_and(|l| l.active_ops().next().is_some())
|
||||
}
|
||||
|
||||
/// The panel's representative selection — see [`Self::active_layer`] for
|
||||
/// what "representative" means once more than one layer is selected.
|
||||
pub fn active_mask(&self) -> Option<&str> {
|
||||
self.active_masks.first().map(String::as_str)
|
||||
}
|
||||
|
||||
/// Every selected layer's id, in selection order.
|
||||
pub fn active_masks(&self) -> &[String] {
|
||||
&self.active_masks
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3 | FR-UI-3
|
||||
/// The selected gradient's handles, in fractions of the shown image.
|
||||
///
|
||||
/// Empty unless **exactly one** gradient layer is selected. Dragging a
|
||||
/// shared handle for several gradients at once has no single geometry to
|
||||
/// move — each one's centre, angle and extent differ — so multi-select
|
||||
/// simply offers no handles rather than moving one layer's shape while
|
||||
/// silently leaving the others behind.
|
||||
///
|
||||
/// Recomputed on every redraw rather than cached, because the answer
|
||||
/// changes with the *view* and not only with the mask: a pan moves every
|
||||
/// handle and touches no geometry. Four handles through an affine map is
|
||||
/// not work worth caching, and a cache keyed on the wrong thing is how a
|
||||
/// handle comes to sit where the mask used to be.
|
||||
pub fn gradient_handles(&self) -> Vec<crate::GradientHandle> {
|
||||
if self.active_masks.len() != 1 {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(layer) = self.active_layer() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
crate::gradient::handles(&layer.base().source, self.graph.framing(), (sw, sh))
|
||||
}
|
||||
|
||||
/// Drag one handle of the selected gradient, from `press` to `now`, both
|
||||
/// in fractions of the shown image.
|
||||
///
|
||||
/// `origin` is the geometry the gesture started from — see
|
||||
/// [`crate::gradient::drag`] for why a drag is applied to that rather than
|
||||
/// accumulated. Returns it, so the caller can hold it for the rest of the
|
||||
/// gesture; `None` when there is no gradient selected to drag.
|
||||
pub fn drag_gradient_handle(
|
||||
&mut self,
|
||||
role: crate::HandleRole,
|
||||
origin: Option<&MaskSource>,
|
||||
press: (f32, f32),
|
||||
now: (f32, f32),
|
||||
) -> Option<MaskSource> {
|
||||
if self.active_masks.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
let framing = *self.graph.framing();
|
||||
let id = self.active_masks.first()?.clone();
|
||||
let start = match origin {
|
||||
Some(s) => s.clone(),
|
||||
None => self.graph.masks().get(&id)?.base().source.clone(),
|
||||
};
|
||||
|
||||
let moved = crate::gradient::drag(&start, role, press, now, &framing, (sw, sh));
|
||||
self.graph.masks_mut().get_mut(&id)?.base_mut().source = moved;
|
||||
// **Nothing recorded here.** A drag delivers a pointer event a frame,
|
||||
// and a history step per frame would make undo walk a gesture back
|
||||
// pixel by pixel. `Edit` coalesces by operation id and a mask's shape
|
||||
// is not an operation, so there is no key to coalesce under — the
|
||||
// honest answer is to record once, on release.
|
||||
Some(start)
|
||||
}
|
||||
|
||||
/// A handle drag finished: one history step for the whole gesture.
|
||||
///
|
||||
/// Called on the pointer's release rather than on each move, which is what
|
||||
/// makes a drag one decision in the undo stack however many frames it took.
|
||||
pub fn commit_gradient_drag(&mut self) {
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::MASK_MOVED));
|
||||
}
|
||||
|
||||
// --- editing a mask by hand (FR-DEV-19) --------------------------------
|
||||
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// Which part of `id` the edge controls act on.
|
||||
///
|
||||
/// The selected part when this is the layer being edited, and the base
|
||||
/// otherwise — because a panel that is not showing a layer's parts has not
|
||||
/// offered anybody a way to choose one, and answering with a part they
|
||||
/// cannot see would make the same slider mean different things depending
|
||||
/// on what was selected a moment ago.
|
||||
pub(super) fn shaped_part(&self, id: &str) -> usize {
|
||||
match self.active_masks.as_slice() {
|
||||
[only] if only == id => self.active_part,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of `id` the edge controls read.
|
||||
pub(super) fn part_of(&self, id: &str) -> Option<&dr_pipeline::mask::MaskPart> {
|
||||
let index = self.shaped_part(id);
|
||||
self.graph.masks().get(id)?.part(index)
|
||||
}
|
||||
|
||||
/// The same, to write through.
|
||||
pub(super) fn part_of_mut(&mut self, id: &str) -> Option<&mut dr_pipeline::mask::MaskPart> {
|
||||
let index = self.shaped_part(id);
|
||||
self.graph.masks_mut().get_mut(id)?.part_mut(index)
|
||||
}
|
||||
|
||||
/// Which part of the selected layer the tools point at.
|
||||
pub fn active_part(&self) -> usize {
|
||||
self.active_part
|
||||
}
|
||||
|
||||
/// Point the tools at one part, or at the base when the index is past the
|
||||
/// end — which is what a part being removed under the selection leaves.
|
||||
pub fn set_active_part(&mut self, index: usize) {
|
||||
let parts = self.active_layer().map_or(1, |l| l.parts().len());
|
||||
self.active_part = if index < parts { index } else { 0 };
|
||||
}
|
||||
|
||||
/// The parts of a layer: id, what to call it, and how it joins.
|
||||
///
|
||||
/// The join of the first is meaningless — there is nothing before it to
|
||||
/// join to — and the panel shows it as the selection the layer *is*
|
||||
/// rather than as a row with a chip that does nothing.
|
||||
pub fn mask_parts(&self, id: &str) -> Vec<(String, String, usize, bool)> {
|
||||
let Some(layer) = self.graph.masks().get(id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
layer
|
||||
.parts()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let join = dr_pipeline::mask::Join::ALL
|
||||
.iter()
|
||||
.position(|&j| j == p.join)
|
||||
.unwrap_or(0);
|
||||
(p.id.clone(), p.source.kind().to_string(), join, p.hidden)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// Leave one part out of the build, or put it back. An edit, and one
|
||||
/// history step, for the same reason the layer's own switch is: the part
|
||||
/// really is out until it is switched back.
|
||||
pub fn set_mask_part_hidden(&mut self, id: &str, index: usize, hidden: bool) {
|
||||
let Some(layer) = self.graph.masks_mut().get_mut(id) else {
|
||||
return;
|
||||
};
|
||||
let Some(part) = layer.part_mut(index) else {
|
||||
return;
|
||||
};
|
||||
if part.hidden == hidden {
|
||||
return;
|
||||
}
|
||||
part.hidden = hidden;
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::MASK_PART_TOGGLED));
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// Join a fresh painted part to a layer, returning its index.
|
||||
///
|
||||
/// Painted, because that is the correction a photographer reaches for
|
||||
/// first and the only source that needs nothing found for it. The other
|
||||
/// sources arrive when a part can carry its own distance field.
|
||||
pub fn add_mask_part(&mut self, id: &str, join: usize) -> Option<usize> {
|
||||
use dr_pipeline::mask::{Join, MaskPart};
|
||||
let &join = Join::ALL.get(join)?;
|
||||
let layer = self.graph.masks_mut().get_mut(id)?;
|
||||
let part_id = layer.next_part_id();
|
||||
if !layer.push_part(MaskPart::painted(part_id, join)) {
|
||||
return None;
|
||||
}
|
||||
let index = layer.parts().len() - 1;
|
||||
self.active_part = index;
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::MASK_PART_ADDED));
|
||||
Some(index)
|
||||
}
|
||||
|
||||
/// Take a part back out of a layer. The base is not removable — removing
|
||||
/// the selection a layer *is* is removing the layer.
|
||||
pub fn remove_mask_part(&mut self, id: &str, index: usize) {
|
||||
let Some(layer) = self.graph.masks_mut().get_mut(id) else {
|
||||
return;
|
||||
};
|
||||
if layer.remove_part(index).is_none() {
|
||||
return;
|
||||
}
|
||||
self.set_active_part(self.active_part.min(index.saturating_sub(1)));
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::MASK_PART_REMOVED));
|
||||
}
|
||||
|
||||
/// Change how a part joins: added to the mask, or taken out of it.
|
||||
pub fn set_mask_part_join(&mut self, id: &str, index: usize, join: usize) {
|
||||
use dr_pipeline::mask::Join;
|
||||
let Some(&join) = Join::ALL.get(join) else {
|
||||
return;
|
||||
};
|
||||
let Some(layer) = self.graph.masks_mut().get_mut(id) else {
|
||||
return;
|
||||
};
|
||||
// The first part joins nothing, so saying how it joins would be a
|
||||
// control that moves and changes no pixel.
|
||||
if index == 0 {
|
||||
return;
|
||||
}
|
||||
let Some(part) = layer.part_mut(index) else {
|
||||
return;
|
||||
};
|
||||
part.join = join;
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::MASK_JOINED));
|
||||
}
|
||||
|
||||
/// The brush: radius, hardness, flow.
|
||||
pub fn brush(&self) -> (f32, f32, f32) {
|
||||
self.brush
|
||||
}
|
||||
|
||||
/// Set the brush. Radius is a fraction of the frame's shorter edge, so it
|
||||
/// means the same thing on the phone and on the desktop and at any zoom.
|
||||
pub fn set_brush(&mut self, radius: f32, hardness: f32, flow: f32) {
|
||||
self.brush = (
|
||||
radius.clamp(0.002, 0.5),
|
||||
hardness.clamp(0.0, 1.0),
|
||||
flow.clamp(0.01, 1.0),
|
||||
);
|
||||
}
|
||||
|
||||
/// How many stroke points the selected layer may still record.
|
||||
///
|
||||
/// Asked by the panel so that a mask approaching its budget can say so
|
||||
/// before a gesture is refused mid-stroke — which is the moment the
|
||||
/// refusal is least explicable.
|
||||
pub fn mask_room(&self) -> usize {
|
||||
self.active_layer().map_or(0, |l| l.room())
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-19b
|
||||
/// Begin a stroke at a point in fractions of the shown image.
|
||||
///
|
||||
/// **Paints into the active part, or joins one if that part cannot hold a
|
||||
/// stroke.** Pressing Paint on a mask the model made is the ordinary way
|
||||
/// this is reached, and it must not answer with a refusal explaining that
|
||||
/// a subject is not a brush: the correction the photographer is about to
|
||||
/// make *is* a new part, so it is made.
|
||||
///
|
||||
/// Returns whether a stroke was started. `false` means the layer is full
|
||||
/// or there is nothing selected, and the caller should not send moves.
|
||||
pub fn begin_mask_stroke(&mut self, x: f32, y: f32, erase: bool) -> bool {
|
||||
let Some(id) = self.active_masks.first().cloned() else {
|
||||
return false;
|
||||
};
|
||||
if self.active_masks.len() != 1 {
|
||||
// Several layers share the slider drags; a stroke has one target
|
||||
// and guessing which of three it is would be worse than refusing.
|
||||
return false;
|
||||
}
|
||||
|
||||
let paintable = self
|
||||
.graph
|
||||
.masks()
|
||||
.get(&id)
|
||||
.and_then(|l| l.part(self.active_part))
|
||||
.is_some_and(|p| matches!(p.source, MaskSource::Brush { .. }));
|
||||
if !paintable {
|
||||
use dr_pipeline::mask::Join;
|
||||
let join = if erase { Join::Subtract } else { Join::Union };
|
||||
let position = Join::ALL.iter().position(|&j| j == join).unwrap_or(0);
|
||||
if self.add_mask_part(&id, position).is_none() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let part = self.active_part;
|
||||
let (radius, hardness, flow) = self.brush;
|
||||
// An erase stroke inside a part that subtracts would take away from
|
||||
// what the part removes, which reads backwards. In a subtracting part
|
||||
// the brush's two modes are already the right way round.
|
||||
let subtracting = self
|
||||
.graph
|
||||
.masks()
|
||||
.get(&id)
|
||||
.and_then(|l| l.part(part))
|
||||
.is_some_and(|p| p.join == dr_pipeline::mask::Join::Subtract);
|
||||
let erase = erase && !subtracting;
|
||||
|
||||
let Some(layer) = self.graph.masks_mut().get_mut(&id) else {
|
||||
return false;
|
||||
};
|
||||
if !layer.begin_stroke(part, erase, radius, hardness, flow) {
|
||||
return false;
|
||||
}
|
||||
self.painting = Some((id, part));
|
||||
self.extend_mask_stroke(x, y);
|
||||
true
|
||||
}
|
||||
|
||||
/// Carry the stroke to another point, in fractions of the shown image.
|
||||
///
|
||||
/// The point is mapped into normalised **source** coordinates on the way
|
||||
/// in, through the same framing map the shader applies — so a stroke stays
|
||||
/// on the thing it was painted on through a zoom, a pan, a crop and a
|
||||
/// straighten, and lands in an export at any size where it was drawn.
|
||||
pub fn extend_mask_stroke(&mut self, x: f32, y: f32) {
|
||||
let Some((id, part)) = self.painting.clone() else {
|
||||
return;
|
||||
};
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
let (sx, sy) = self.graph.framing().source_at((x, y), sw, sh);
|
||||
if let Some(layer) = self.graph.masks_mut().get_mut(&id) {
|
||||
layer.extend_stroke(part, sx, sy);
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish the stroke: one history step for the whole gesture.
|
||||
///
|
||||
/// One step, on release, for the reason a handle drag records once — a
|
||||
/// stroke is a decision, and undo that walked it back dab by dab would
|
||||
/// make taking a mark back cost as many presses as making it did.
|
||||
pub fn end_mask_stroke(&mut self) {
|
||||
let Some((id, part)) = self.painting.take() else {
|
||||
return;
|
||||
};
|
||||
let erased = self
|
||||
.graph
|
||||
.masks()
|
||||
.get(&id)
|
||||
.and_then(|l| l.part(part))
|
||||
.and_then(|p| p.strokes().last())
|
||||
.is_some_and(|s| s.erase);
|
||||
if let Some(layer) = self.graph.masks_mut().get_mut(&id) {
|
||||
layer.end_stroke(part);
|
||||
}
|
||||
let step = if erased {
|
||||
labels::step::MASK_ERASED
|
||||
} else {
|
||||
labels::step::MASK_PAINTED
|
||||
};
|
||||
self.history.record(&self.graph, Edit::Action(step));
|
||||
}
|
||||
|
||||
/// Abandon a stroke that turned out to be something else — a pinch, or a
|
||||
/// gesture the window cancelled. Nothing is recorded, because nothing
|
||||
/// happened as far as the photographer is concerned.
|
||||
pub fn cancel_mask_stroke(&mut self) {
|
||||
let Some((id, part)) = self.painting.take() else {
|
||||
return;
|
||||
};
|
||||
if let Some(layer) = self.graph.masks_mut().get_mut(&id) {
|
||||
layer.drop_last_stroke(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::develop::test_support::*;
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// The overlay's clip rectangle
|
||||
// ----------------------------------------------------------------------
|
||||
//
|
||||
// The overlay is a source-space picture and the canvas shows whatever the
|
||||
// crop, the zoom and the pan selected out of that space. Drawn whole it
|
||||
// stays frame-sized while the photograph moves underneath, which is what
|
||||
// these pin down.
|
||||
|
||||
/// A session with a segmentation, so the clip has a proxy to measure
|
||||
/// against.
|
||||
///
|
||||
/// The model finds nothing in flat grey, and that is fine: the clip is
|
||||
/// computed from the framing and the proxy size, neither of which depends
|
||||
/// on what was detected.
|
||||
fn segmented_session(ctx: &GpuContext) -> Option<DevelopSession> {
|
||||
let rgba: Vec<u8> = (0..100 * 100).flat_map(|_| [128, 128, 128, 255]).collect();
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
session
|
||||
.segment(&crate::segmentation::Options::default())
|
||||
.ok()?;
|
||||
Some(session)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3 | FR-CAT-8
|
||||
/// The whole claim, end to end: what is stored renders what was rendered.
|
||||
///
|
||||
/// A session with a model's coverage in hand draws the mask; the stack it
|
||||
/// hands the sidecar writer goes through the file and into a session with
|
||||
/// no model at all; and the two frames must be the same. Anything weaker
|
||||
/// — that the coverage is present, that it round-trips as bytes — would
|
||||
/// still pass if the raster came back at the wrong scale, upside down, or
|
||||
/// a threshold out.
|
||||
#[test]
|
||||
fn a_shown_mask_is_only_shown_while_masking() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let mut s = session_with_a_left_half_subject(&ctx);
|
||||
let id = s.add_subject_mask(0).expect("a subject layer");
|
||||
s.set_overlay(true);
|
||||
s.set_mask_shown(&id, true);
|
||||
assert!(s.any_mask_shown(), "lit, in Local mode");
|
||||
|
||||
// Leaving the mode — what `on_mode_picked` does for Photo and Spots.
|
||||
s.set_overlay(false);
|
||||
assert!(
|
||||
!s.any_mask_shown(),
|
||||
"the tint belongs to the mode, not to the photograph"
|
||||
);
|
||||
assert!(s.mask_shown(&id), "the eye itself is remembered");
|
||||
|
||||
s.set_overlay(true);
|
||||
assert!(s.any_mask_shown(), "and is lit again on return");
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Multi-select: one slider, applied to every selected layer.
|
||||
///
|
||||
/// `toggle_active_mask` builds the selection a control-click makes, and
|
||||
/// `set_param`/`reset_op` are what a drag and a reset call — this pins
|
||||
/// down that both fan out to every layer in it rather than only the
|
||||
/// first, which is the whole point of selecting more than one.
|
||||
#[test]
|
||||
fn a_slider_moved_with_two_layers_selected_moves_both() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
|
||||
let a = session.add_gradient_mask(true).expect("first gradient");
|
||||
let b = session.add_gradient_mask(false).expect("second gradient");
|
||||
// Adding `b` selected it alone — build the multi-selection a
|
||||
// control-click would, starting from that single-layer state.
|
||||
session.toggle_active_mask(&a);
|
||||
assert_eq!(session.active_masks(), [b.clone(), a.clone()].as_slice());
|
||||
|
||||
assert!(
|
||||
!session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b),
|
||||
"neither layer has been touched yet"
|
||||
);
|
||||
|
||||
let row = session.rows()[0].clone();
|
||||
session.set_param(row.op_index, row.param_index, row.maximum);
|
||||
|
||||
assert!(
|
||||
session.mask_is_adjusted(&a) && session.mask_is_adjusted(&b),
|
||||
"one slider, both layers selected, both layers must show the edit"
|
||||
);
|
||||
|
||||
// And a reset walks the same set.
|
||||
session.reset_op(row.op_index);
|
||||
assert!(
|
||||
!session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b),
|
||||
"resetting with both selected must clear both, not just the one \
|
||||
the panel happens to read values from"
|
||||
);
|
||||
}
|
||||
|
||||
/// A control-click twice — once to add, once to remove — is a no-op on
|
||||
/// the selection, which is the sanity check for `toggle_active_mask`
|
||||
/// itself before trusting anything built on it.
|
||||
#[test]
|
||||
fn toggling_a_layer_twice_returns_to_the_starting_selection() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
|
||||
let a = session.add_gradient_mask(true).expect("gradient");
|
||||
assert_eq!(session.active_masks(), [a.clone()].as_slice());
|
||||
|
||||
session.toggle_active_mask(&a);
|
||||
assert!(session.active_masks().is_empty(), "removed by the toggle");
|
||||
|
||||
session.toggle_active_mask(&a);
|
||||
assert_eq!(session.active_masks(), [a.clone()].as_slice(), "added back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unzoomed_overlay_shows_the_whole_frame() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let Some(session) = segmented_session(&ctx) else {
|
||||
eprintln!("no model; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let (x, y, w, h) = session.overlay_clip();
|
||||
assert_eq!((x, y), (0, 0));
|
||||
assert!(w > 1 && h > 1, "the whole proxy: {w}x{h}");
|
||||
}
|
||||
|
||||
/// The bug this exists for: zooming must narrow the clip, or the overlay
|
||||
/// keeps showing the whole picture at frame size while the canvas shows a
|
||||
/// detail of it.
|
||||
#[test]
|
||||
fn zooming_narrows_the_overlay_to_what_is_visible() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let Some(mut session) = segmented_session(&ctx) else {
|
||||
eprintln!("no model; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let (_, _, full_w, full_h) = session.overlay_clip();
|
||||
session.zoom_about(4.0, 0.5, 0.5);
|
||||
let (_, _, zoomed_w, zoomed_h) = session.overlay_clip();
|
||||
|
||||
assert!(
|
||||
zoomed_w < full_w && zoomed_h < full_h,
|
||||
"zoomed in, the overlay should show less: {zoomed_w}x{zoomed_h} \
|
||||
against {full_w}x{full_h}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panning_moves_the_overlay_with_the_photograph() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let Some(mut session) = segmented_session(&ctx) else {
|
||||
eprintln!("no model; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
session.zoom_about(4.0, 0.5, 0.5);
|
||||
let (before_x, _, _, _) = session.overlay_clip();
|
||||
session.pan_by(0.3, 0.0);
|
||||
let (after_x, _, _, _) = session.overlay_clip();
|
||||
|
||||
assert!(
|
||||
after_x > before_x,
|
||||
"panning right moves the visible window right: {before_x} then {after_x}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cropping_narrows_the_overlay_too() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let Some(mut session) = segmented_session(&ctx) else {
|
||||
eprintln!("no model; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let (_, _, full_w, _) = session.overlay_clip();
|
||||
session.set_crop(dr_pipeline::CropRect {
|
||||
x: 0.25,
|
||||
y: 0.25,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
let (x, y, w, _) = session.overlay_clip();
|
||||
|
||||
assert!(w < full_w, "a half-width crop shows half the overlay");
|
||||
assert!(x > 0 && y > 0, "and it starts inside the frame");
|
||||
}
|
||||
|
||||
/// Nothing segmented means no overlay, and no rectangle a caller might
|
||||
/// divide by.
|
||||
#[test]
|
||||
fn no_segmentation_means_no_clip() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
||||
let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
assert_eq!(session.overlay_clip(), (0, 0, 0, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! The develop session — capabilities in, rendered image out.
|
||||
//!
|
||||
//! This is the only place the UI touches the pipeline, and it does so through
|
||||
//! two calls: [`dr_pipeline::EditGraph::capabilities`] to learn what controls
|
||||
//! to build, and `set_param` to change one. It never names an operation, and
|
||||
//! it knows nothing about shaders.
|
||||
//!
|
||||
//! Whether a control is a slider or a switch follows from the parameter's
|
||||
//! declared [`ParamKind`], not from which parameter it is (ARCH §4.3), so a
|
||||
//! new operation appears in the panel with no change here (FR-DEV-3c).
|
||||
//!
|
||||
//! Split into one module per area of the session (framing, masks,
|
||||
//! segmentation, rendering, ...) rather than kept as one file — see
|
||||
//! `docs/dev/code-health.md` CH-1. `DevelopSession`'s fields are `pub(super)`
|
||||
//! so its `impl` blocks can live beside the area of behaviour they belong to
|
||||
//! instead of all in one place; nothing outside this module sees them, since
|
||||
//! only methods were ever exported.
|
||||
|
||||
mod curves;
|
||||
mod framing;
|
||||
mod history;
|
||||
mod mask_ops;
|
||||
mod masks;
|
||||
mod render;
|
||||
mod repairs;
|
||||
mod rows;
|
||||
mod segmentation;
|
||||
mod session;
|
||||
mod tabs;
|
||||
mod white_balance;
|
||||
|
||||
pub use framing::CropAspect;
|
||||
pub use masks::MASK_COLOURS;
|
||||
pub use segmentation::{Abandon, RefinedInstance, Segmented, SessionId};
|
||||
pub use session::DevelopSession;
|
||||
|
||||
/// Longest edge the model and the masks work at.
|
||||
///
|
||||
/// ~1.3 MP at 3:2. Large enough that an outline is within a pixel or two of
|
||||
/// where it belongs, small enough that a distance transform over it is a few
|
||||
/// milliseconds and its field a few megabytes.
|
||||
pub(super) const SEGMENT_PROXY_EDGE: u32 = 1600;
|
||||
|
||||
/// Test-only helpers shared by more than one of this module's submodules.
|
||||
///
|
||||
/// `headless`, `read_back` and `grey_session` were each defined once in the
|
||||
/// pre-split file and called from tests all over it. Splitting the tests with
|
||||
/// the code they exercise left these three needed in most of the resulting
|
||||
/// files, so they live here once instead of being copied.
|
||||
#[cfg(test)]
|
||||
pub(super) mod test_support {
|
||||
use super::*;
|
||||
use dr_gpu::GpuContext;
|
||||
|
||||
/// TRACES: FR-DSP-1 | AC-8
|
||||
/// Copy a displayed frame back to the CPU, for assertions and nothing else.
|
||||
///
|
||||
/// The library has no such function on purpose: S1 removed the display
|
||||
/// readback, and AC-8 is the assertion that it stayed removed. A test that
|
||||
/// wants to look at the pixels therefore has to do the copy itself, which
|
||||
/// is exactly the right shape — the round-trip lives in the test binary
|
||||
/// and cannot be reached from a shipping one.
|
||||
///
|
||||
/// Doubles as the proof: this only compiles because the image *is* a wgpu
|
||||
/// texture. Hand it a `SharedPixelBuffer`-backed image and it panics.
|
||||
pub(super) fn read_back(ctx: &GpuContext, image: &slint::Image) -> Vec<u8> {
|
||||
let texture = image
|
||||
.to_wgpu_29_texture()
|
||||
.expect("the develop canvas must be a GPU texture, not a pixel buffer");
|
||||
let (w, h) = (texture.width(), texture.height());
|
||||
|
||||
// Buffer rows must be aligned to COPY_BYTES_PER_ROW_ALIGNMENT.
|
||||
let unpadded = w * 4;
|
||||
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||
let padded = unpadded.div_ceil(align) * align;
|
||||
|
||||
let buf = ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("test-readback"),
|
||||
size: u64::from(padded * h),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
let mut enc = ctx.device.create_command_encoder(&Default::default());
|
||||
enc.copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &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,
|
||||
},
|
||||
);
|
||||
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);
|
||||
});
|
||||
ctx.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.expect("poll");
|
||||
rx.recv().expect("map").expect("map");
|
||||
|
||||
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();
|
||||
out
|
||||
}
|
||||
|
||||
/// One device for the whole test binary.
|
||||
///
|
||||
/// This opened a *new* `GpuContext` per test, and `cargo test` runs tests
|
||||
/// on as many threads as there are cores — so a full run asked the driver
|
||||
/// to bring up a dozen Vulkan devices at once and the binary died with
|
||||
/// SIGSEGV. Serially it passed, which is what made it look like flakiness
|
||||
/// rather than a bug in the harness.
|
||||
///
|
||||
/// A `GpuContext` is an `Arc<Device>` and an `Arc<Queue>`, so sharing one
|
||||
/// is a refcount rather than a copy, and wgpu is explicit that both are
|
||||
/// safe to use from several threads. Nothing here mutates the context; the
|
||||
/// per-test state is in the passes and the sessions built on top of it.
|
||||
///
|
||||
/// `OnceLock` rather than `lazy_static`: the initialiser runs once however
|
||||
/// many threads arrive together, and the losers block until it is done —
|
||||
/// which is precisely the property that was missing.
|
||||
pub(super) fn headless() -> Option<GpuContext> {
|
||||
static SHARED: std::sync::OnceLock<Option<GpuContext>> = std::sync::OnceLock::new();
|
||||
SHARED
|
||||
.get_or_init(|| pollster::block_on(dr_gpu::GpuContext::new_headless()).ok())
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// A flat grey session with nothing segmented, and its render.
|
||||
pub(super) fn grey_session(ctx: &GpuContext) -> (DevelopSession, Vec<u8>) {
|
||||
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
assert!(
|
||||
!session.has_segmentation(),
|
||||
"the premise: no model has been run"
|
||||
);
|
||||
let before = read_back(ctx, &session.render(64, 64).expect("render"));
|
||||
(session, before)
|
||||
}
|
||||
|
||||
/// A session holding a segmentation with one instance over the left half.
|
||||
///
|
||||
/// Built rather than detected. What these tests need is coverage of a
|
||||
/// *known* shape, so that "the stored raster renders what the model's did"
|
||||
/// is a comparison rather than a hope — and asking a real run what it
|
||||
/// happened to find in a synthetic frame would make the assertion depend
|
||||
/// on the weights.
|
||||
pub(super) fn session_with_a_left_half_subject(ctx: &GpuContext) -> DevelopSession {
|
||||
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
|
||||
let (pw, ph) = session.mask_raster_size();
|
||||
let (pw, ph) = (pw as usize, ph as usize);
|
||||
let mut mask = vec![0u8; pw * ph];
|
||||
for y in 0..ph {
|
||||
for x in 0..pw / 2 {
|
||||
mask[y * pw + x] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
session.segmentation = Some(crate::segmentation::Segmentation::for_test(
|
||||
vec![crate::segmentation::InstanceSummary {
|
||||
class_name: "dog".into(),
|
||||
score: 0.9,
|
||||
mask,
|
||||
bbox: (0.0, 0.0, (pw / 2) as f32, ph as f32),
|
||||
}],
|
||||
Vec::new(),
|
||||
0xfeed,
|
||||
(pw, ph),
|
||||
));
|
||||
session.subjects = None;
|
||||
session.subject_key = 0;
|
||||
session
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,241 @@
|
||||
//! Spot removal (FR-DEV-8): placing, dragging and clearing heal/clone spots.
|
||||
use dr_pipeline::Edit;
|
||||
|
||||
use crate::labels;
|
||||
|
||||
use super::session::DevelopSession;
|
||||
|
||||
impl DevelopSession {
|
||||
// --- repairs (FR-DEV-8) ------------------------------------------------
|
||||
|
||||
/// TRACES: FR-DEV-8
|
||||
/// Cover what is at `(x, y)`, in fractions of the shown image.
|
||||
///
|
||||
/// The click arrives in *output* coordinates — where the photograph
|
||||
/// currently sits on screen — and a repair is stored against the
|
||||
/// photograph, so it goes through `Framing::source_at`: the same map the
|
||||
/// shader applies, run backwards. Anything less would put the repair where
|
||||
/// the pointer was rather than where the mark is, and the two agree only at
|
||||
/// fit-to-window with no crop.
|
||||
///
|
||||
/// Returns the new repair's id, or `None` when the set is full. Selecting
|
||||
/// it is deliberate: the control that changes its size is in the column,
|
||||
/// and a photographer who has just placed a spot too small should find that
|
||||
/// control already pointed at it.
|
||||
pub fn place_spot(&mut self, x: f32, y: f32) -> Option<String> {
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
let centre = self.graph.framing().source_at((x, y), sw, sh);
|
||||
// Outside the photograph entirely — the letterbox margin, or a drag
|
||||
// that ended off the edge. Placing a repair there would put a disc
|
||||
// somewhere the user cannot see and cannot pick up again.
|
||||
if !(0.0..=1.0).contains(¢re.0) || !(0.0..=1.0).contains(¢re.1) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let aspect = sw.max(1) as f32 / sh.max(1) as f32;
|
||||
let radius = dr_pipeline::spot::DEFAULT_RADIUS;
|
||||
let offset = dr_pipeline::Spot::default_offset(centre, radius, aspect);
|
||||
|
||||
let id = self
|
||||
.graph
|
||||
.spots_mut()
|
||||
.place(dr_pipeline::Spot::new(centre, offset, radius))?;
|
||||
self.selected_spot = Some(id.clone());
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::SPOT_PLACED));
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-8 | FR-UI-3
|
||||
/// Every repair as a circle on the shown image, plus the source circle of
|
||||
/// the selected one.
|
||||
///
|
||||
/// # Why only the selected repair shows its source
|
||||
///
|
||||
/// A dusty sky carries a dozen repairs. Two dozen circles with nothing
|
||||
/// saying which source belongs to which disc is not more information, it is
|
||||
/// less — and there is no room on a phone for a connector between each
|
||||
/// pair. The selection is what disambiguates them, which is also why a
|
||||
/// press on a repair selects it before the drag begins.
|
||||
///
|
||||
/// Recomputed per redraw rather than cached, for the reason
|
||||
/// [`Self::gradient_handles`] gives: the answer changes with the *view*,
|
||||
/// and a pan moves every circle while touching no edit.
|
||||
pub fn spot_handles(&self) -> Vec<crate::SpotHandle> {
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
let framing = self.graph.framing();
|
||||
let aspect = sw.max(1) as f32 / sh.max(1) as f32;
|
||||
// The shown image's own shape, which is not the source's once the frame
|
||||
// has been cropped or turned. A radius is reported against its height,
|
||||
// so this is what converts the x half of the mapped offset.
|
||||
let (ow, oh) = self.graph.output_size(sw, sh);
|
||||
let shown_aspect = ow.max(1) as f32 / oh.max(1) as f32;
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for spot in self.graph.spots().spots() {
|
||||
let selected = self.selected_spot.as_deref() == Some(spot.id.as_str());
|
||||
let centre = framing.output_at(spot.centre, sw, sh);
|
||||
|
||||
// The radius, mapped rather than scaled: a point one radius above
|
||||
// the centre goes through the same map, and the distance between
|
||||
// the two answers is the radius as drawn. The x half is multiplied
|
||||
// by the shown aspect because the two axes are normalised by
|
||||
// different lengths, and a circle measured in mixed units is an
|
||||
// ellipse.
|
||||
let rim = framing.output_at((spot.centre.0, spot.centre.1 + spot.radius), sw, sh);
|
||||
let radius = ((rim.0 - centre.0) * shown_aspect).hypot(rim.1 - centre.1);
|
||||
|
||||
handles.push(crate::SpotHandle {
|
||||
id: spot.id.clone().into(),
|
||||
role: crate::SpotRole::Destination,
|
||||
x: centre.0,
|
||||
y: centre.1,
|
||||
radius,
|
||||
selected,
|
||||
enabled: spot.enabled,
|
||||
});
|
||||
|
||||
if selected {
|
||||
let source = framing.output_at(spot.source(aspect), sw, sh);
|
||||
handles.push(crate::SpotHandle {
|
||||
id: spot.id.clone().into(),
|
||||
role: crate::SpotRole::Source,
|
||||
x: source.0,
|
||||
y: source.1,
|
||||
radius,
|
||||
selected: true,
|
||||
enabled: spot.enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
handles
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-8
|
||||
/// Drag one circle of one repair, from `press` to `now`, both in fractions
|
||||
/// of the shown image.
|
||||
///
|
||||
/// Dragging the disc moves the whole repair and carries its source along —
|
||||
/// what a photographer means by nudging a spot. Dragging the source moves
|
||||
/// the source alone, which is the override FR-DEV-8 asks for over the
|
||||
/// automatic placement.
|
||||
///
|
||||
/// `origin` is the repair as it stood when the gesture began; the caller
|
||||
/// holds it for the duration and hands it back, so a drag is applied to
|
||||
/// that rather than accumulated frame by frame — the rule
|
||||
/// [`Self::drag_gradient_handle`] states, for the same reasons.
|
||||
pub fn drag_spot(
|
||||
&mut self,
|
||||
id: &str,
|
||||
role: crate::SpotRole,
|
||||
origin: Option<&dr_pipeline::Spot>,
|
||||
press: (f32, f32),
|
||||
now: (f32, f32),
|
||||
) -> Option<dr_pipeline::Spot> {
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
let framing = *self.graph.framing();
|
||||
let aspect = sw.max(1) as f32 / sh.max(1) as f32;
|
||||
|
||||
let start = match origin {
|
||||
Some(spot) => spot.clone(),
|
||||
None => self.graph.spots().get(id)?.clone(),
|
||||
};
|
||||
|
||||
// The displacement in source coordinates. Affine, so a movement is a
|
||||
// movement: the map may be run on the two endpoints and subtracted,
|
||||
// which is what makes a drag on a rotated photograph move the repair in
|
||||
// the direction the finger went.
|
||||
let from = framing.source_at(press, sw, sh);
|
||||
let to = framing.source_at(now, sw, sh);
|
||||
let moved = (to.0 - from.0, to.1 - from.1);
|
||||
|
||||
let spot = self.graph.spots_mut().get_mut(id)?;
|
||||
match role {
|
||||
crate::SpotRole::Destination => {
|
||||
spot.set_centre((start.centre.0 + moved.0, start.centre.1 + moved.1));
|
||||
}
|
||||
// In frame units, because that is what an offset is stored in — and
|
||||
// the x half of a normalised displacement is short by the aspect.
|
||||
crate::SpotRole::Source => {
|
||||
spot.set_offset((start.offset.0 + moved.0 * aspect, start.offset.1 + moved.1));
|
||||
}
|
||||
}
|
||||
// Nothing recorded here: a drag delivers a pointer event a frame, and
|
||||
// one history step apiece would make undo walk the gesture back pixel
|
||||
// by pixel. Recorded once, on release.
|
||||
Some(start)
|
||||
}
|
||||
|
||||
/// A repair's drag finished: one history step for the whole gesture.
|
||||
pub fn commit_spot_drag(&mut self) {
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::SPOT_MOVED));
|
||||
}
|
||||
|
||||
/// Which repair the column is describing.
|
||||
pub fn selected_spot(&self) -> Option<&dr_pipeline::Spot> {
|
||||
let id = self.selected_spot.as_deref()?;
|
||||
self.graph.spots().get(id)
|
||||
}
|
||||
|
||||
pub fn selected_spot_id(&self) -> Option<&str> {
|
||||
self.selected_spot.as_deref()
|
||||
}
|
||||
|
||||
/// Choose a repair, or `None` to describe none.
|
||||
///
|
||||
/// An id the graph no longer holds selects nothing rather than being kept:
|
||||
/// the circle that offered it is stale by the time the press lands, and a
|
||||
/// selection pointing at a deleted repair would leave the column describing
|
||||
/// something that is not on the photograph.
|
||||
pub fn select_spot(&mut self, id: Option<&str>) {
|
||||
self.selected_spot = id
|
||||
.filter(|id| self.graph.spots().get(id).is_some())
|
||||
.map(str::to_string);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-8
|
||||
/// Take a repair off the photograph, returning whether one went.
|
||||
pub fn remove_spot(&mut self, id: &str) -> bool {
|
||||
if self.graph.spots_mut().remove(id).is_none() {
|
||||
return false;
|
||||
}
|
||||
if self.selected_spot.as_deref() == Some(id) {
|
||||
self.selected_spot = None;
|
||||
}
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::SPOT_REMOVED));
|
||||
true
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-8
|
||||
/// Change one of the selected repair's settings.
|
||||
///
|
||||
/// Recorded as a named [`Edit::Action`] rather than under a parameter key:
|
||||
/// a repair is not an operation and has no `OpId` to name or coalesce by,
|
||||
/// so a slider drag over it records a step per movement unless the caller
|
||||
/// debounces. `SliderRow` fires once per completed gesture,
|
||||
/// which is what makes that acceptable here and is why this is the one
|
||||
/// panel in the application built from that row rather than from a live
|
||||
/// track.
|
||||
pub fn set_selected_spot<F>(&mut self, change: F) -> bool
|
||||
where
|
||||
F: FnOnce(&mut dr_pipeline::Spot),
|
||||
{
|
||||
let Some(id) = self.selected_spot.clone() else {
|
||||
return false;
|
||||
};
|
||||
let Some(spot) = self.graph.spots_mut().get_mut(&id) else {
|
||||
return false;
|
||||
};
|
||||
change(spot);
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::SPOT));
|
||||
true
|
||||
}
|
||||
|
||||
/// How many repairs this photograph carries.
|
||||
pub fn spot_count(&self) -> usize {
|
||||
self.graph.spots().len()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,834 @@
|
||||
//! Running the scene segmentation model off the UI thread, and the session
|
||||
//! state that adopts what it finds (S15, docs/dev/segmentation.md).
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass};
|
||||
use dr_pipeline::mask::MaskSource;
|
||||
use dr_pipeline::{CropRect, EditGraph};
|
||||
|
||||
use crate::segmentation::{self, Segmentation};
|
||||
|
||||
use super::session::DevelopSession;
|
||||
use super::SEGMENT_PROXY_EDGE;
|
||||
|
||||
/// Which photograph a piece of background work was started for.
|
||||
///
|
||||
/// Minted per session, never reused, and carried by the work rather than
|
||||
/// looked up when it finishes. A segmentation takes most of a second, so the
|
||||
/// user can be two frames further on by the time one lands, and the answer to
|
||||
/// "is this still wanted" has to be decided from what the work *was* rather
|
||||
/// than from what happens to be open.
|
||||
///
|
||||
/// The alternative — a counter beside the session slot, bumped on every open —
|
||||
/// is written from four places in `lib.rs` and would apply one photograph's
|
||||
/// subjects to another the first time somebody added a fifth and forgot.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SessionId(u64);
|
||||
|
||||
impl SessionId {
|
||||
pub(crate) fn next() -> Self {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(1);
|
||||
Self(NEXT.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
}
|
||||
|
||||
/// Says that nobody is waiting for a job's answer any more.
|
||||
///
|
||||
/// Not a cancellation in the sense of stopping the work: the model is one
|
||||
/// opaque call of about half a second and `ort` offers no way in. This is
|
||||
/// checked at the seams there are — before the job starts, and again between
|
||||
/// the proxy readback and the inference — so a job abandoned while the user
|
||||
/// was still paging usually costs nothing, and one abandoned mid-inference
|
||||
/// costs only the run it was already committed to.
|
||||
///
|
||||
/// What it buys in every case is that the next photograph's segmentation is
|
||||
/// the only one anybody is waiting on.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Abandon(Arc<AtomicBool>);
|
||||
|
||||
impl Abandon {
|
||||
pub fn now(&self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn asked(&self) -> bool {
|
||||
self.0.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a finished [`SegmentationJob`] hands back.
|
||||
///
|
||||
/// The rasteriser travels with the subjects because it is needed the instant
|
||||
/// they arrive and nowhere before. Building it is a shader compile — 23 ms on
|
||||
/// a desktop, and compiling shaders is among the slowest things a mobile
|
||||
/// driver does — so building it on adoption put a dropped frame on the one
|
||||
/// redraw the user is waiting for. Here it is on the thread that was waiting
|
||||
/// anyway.
|
||||
///
|
||||
/// `None` where the device has no mask rasteriser at all: the session keeps
|
||||
/// the photograph and loses local adjustments, which is the same bargain the
|
||||
/// histogram makes.
|
||||
pub struct Segmented {
|
||||
seg: Segmentation,
|
||||
masks: Option<MaskPass>,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// A segmentation lifted out of the session that asked for it.
|
||||
///
|
||||
/// [`DevelopSession`] cannot go to a worker. Not because of what it holds —
|
||||
/// the device, the source texture and the passes are all `Send` — but because
|
||||
/// it lives behind one `Rc<RefCell<Option<…>>>` that every callback in the
|
||||
/// window reaches through, and the window has to keep reaching through it
|
||||
/// while the work runs. Handing the session over would freeze the interface
|
||||
/// exactly as thoroughly as blocking on it did.
|
||||
///
|
||||
/// So the work takes a copy of the two things it needs. The device is `Arc`s,
|
||||
/// the source is shared rather than copied, and the answer comes back as plain
|
||||
/// data.
|
||||
pub struct SegmentationJob {
|
||||
ctx: GpuContext,
|
||||
source: Arc<DemosaicedImage>,
|
||||
session: SessionId,
|
||||
abandon: Abandon,
|
||||
/// TRACES: FR-CULL-10
|
||||
/// Confirmed faces in this photograph, **normalised to the long edge** of
|
||||
/// the EXIF-upright image.
|
||||
///
|
||||
/// Carried rather than looked up, because the job runs on a thread with no
|
||||
/// catalog in reach — the same reason it carries the pixels. Normalised
|
||||
/// rather than in pixels because the proxy size is only settled inside
|
||||
/// `run`.
|
||||
names: Vec<crate::identity::NormalisedNamedBox>,
|
||||
/// TRACES: FR-CULL-10
|
||||
/// The file's EXIF turn alone, which is the space `names` is expressed in.
|
||||
///
|
||||
/// **Deliberately not [`SegmentationJob::orientation`].** Faces are found
|
||||
/// on the thumbnail, which is stood up by the EXIF tag and knows nothing
|
||||
/// about the photographer's later turns; the segmentation below composes
|
||||
/// both. Using the composed one here would turn the faces twice on any
|
||||
/// photograph the user has rotated, and the failure would be silent —
|
||||
/// names simply landing on nobody.
|
||||
exif_orientation: dr_types::Orientation,
|
||||
/// TRACES: FR-DEV-3h
|
||||
/// How the sensor's pixels have to be turned to be the photograph.
|
||||
///
|
||||
/// The file's EXIF tag and the photographer's own turns, composed into
|
||||
/// one permutation by `Framing::effective_orientation`. Carried rather
|
||||
/// than read from the session for the reason everything else here is: the
|
||||
/// job runs on a worker and the session stays behind.
|
||||
///
|
||||
/// A *snapshot*, so turning the photograph while a run is in flight
|
||||
/// leaves that run answering the question it was asked. The next press
|
||||
/// takes the new one, and the signature says the two are different runs.
|
||||
orientation: dr_types::Orientation,
|
||||
}
|
||||
|
||||
impl SegmentationJob {
|
||||
/// The photograph this was started for.
|
||||
pub fn session(&self) -> SessionId {
|
||||
self.session
|
||||
}
|
||||
|
||||
/// The handle that tells this job its answer is no longer wanted.
|
||||
pub fn abandon(&self) -> Abandon {
|
||||
self.abandon.clone()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Find the subjects. **Blocking, and roughly two thirds of a second.**
|
||||
///
|
||||
/// `Ok(None)` means abandoned rather than found-nothing: an image with no
|
||||
/// recognisable subject in it still comes back as `Ok(Some(_))` with an
|
||||
/// empty instance list, and the panel says so.
|
||||
///
|
||||
/// There is deliberately no `&mut DevelopSession` in scope here. That is
|
||||
/// the whole point of the split — a caller cannot accidentally hold the
|
||||
/// session across the half second, because it was never given one.
|
||||
pub fn run(&self, options: &segmentation::Options) -> Result<Option<Segmented>, String> {
|
||||
if self.abandon.asked() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Timed and logged because this is the feature's largest cost and the
|
||||
// split between the two halves decides where any further work goes. A
|
||||
// number from the device it actually runs on beats an estimate from
|
||||
// the desktop.
|
||||
let started = std::time::Instant::now();
|
||||
let (rgb, rw, rh) = self.neutral_proxy(SEGMENT_PROXY_EDGE)?;
|
||||
let proxied = started.elapsed();
|
||||
|
||||
// The one seam inside the run. Past here the model owns the thread
|
||||
// until it is done.
|
||||
if self.abandon.asked() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut seg = segmentation::compute(&self.ctx, &rgb, rw, rh, self.orientation, options)?;
|
||||
|
||||
// TRACES: FR-CULL-10
|
||||
// Put names on the people the segmenter found.
|
||||
//
|
||||
// `compute` stands the frame up to detect and lays the instances back
|
||||
// down, so `bbox` is in the sensor's space. The faces came off the
|
||||
// thumbnail and are in the EXIF-upright one. Two different spaces, and
|
||||
// on a portrait photograph they are a quarter turn apart — so the
|
||||
// faces are turned down to meet the instances, through the same
|
||||
// `Orientation` map every other consumer uses rather than a second
|
||||
// copy of the arithmetic.
|
||||
//
|
||||
// The catalog normalises a face to the image's **long edge**, where
|
||||
// `ShownRect` is normalised per axis; the conversions either side of
|
||||
// the turn are that difference and nothing more.
|
||||
if !self.names.is_empty() {
|
||||
let long_edge = rw.max(rh) as f32;
|
||||
let (dw, dh) = self.exif_orientation.oriented_size(rw as u32, rh as u32);
|
||||
let (dw, dh) = (dw as f32, dh as f32);
|
||||
|
||||
let boxes: Vec<dr_face::NamedFace<'_>> = self
|
||||
.names
|
||||
.iter()
|
||||
.map(|(x, y, w, h, name)| {
|
||||
let shown = dr_types::ShownRect {
|
||||
x: x * long_edge / dw,
|
||||
y: y * long_edge / dh,
|
||||
width: w * long_edge / dw,
|
||||
height: h * long_edge / dh,
|
||||
};
|
||||
let stored = self.exif_orientation.into_stored_rect(shown);
|
||||
dr_face::NamedFace {
|
||||
bbox: (
|
||||
stored.x * rw as f32,
|
||||
stored.y * rh as f32,
|
||||
(stored.x + stored.width) * rw as f32,
|
||||
(stored.y + stored.height) * rh as f32,
|
||||
),
|
||||
name,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let named = seg.apply_names(&boxes);
|
||||
if named > 0 {
|
||||
log::info!("named {named} segmented region(s) from known faces");
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"segmented {rw}×{rh}: {} subject(s), proxy {:.0} ms, total {:.0} ms",
|
||||
seg.instances().len(),
|
||||
proxied.as_secs_f32() * 1000.0,
|
||||
started.elapsed().as_secs_f32() * 1000.0,
|
||||
);
|
||||
|
||||
let masks = MaskPass::new(&self.ctx)
|
||||
.inspect_err(|e| log::warn!("no mask rasteriser on this device: {e}"))
|
||||
.ok();
|
||||
Ok(Some(Segmented { seg, masks }))
|
||||
}
|
||||
|
||||
/// Render the *unedited* image to a CPU buffer at proxy size.
|
||||
///
|
||||
/// The model reads the photograph as captured, not as edited: the
|
||||
/// segmentation must survive an exposure change, or every slider would
|
||||
/// invalidate the masks that depend on it (docs/dev/segmentation.md §3).
|
||||
///
|
||||
/// **Still the sensor's orientation, deliberately.** Standing the picture
|
||||
/// up is what `segmentation::compute` does to the buffer this returns,
|
||||
/// and it is done there rather than here because a mask has to come back
|
||||
/// in this space: the generated shader samples the mask array at `uv_src`,
|
||||
/// after the framing map. Rendering an upright proxy would put every mask
|
||||
/// a quarter turn away from the subject it was drawn around — a wrong
|
||||
/// mask rather than a weak one, and nothing would announce it.
|
||||
///
|
||||
/// A throwaway [`AdjustPass`] with a neutral graph rather than the
|
||||
/// session's own — which this could not reach from here in any case, and
|
||||
/// must not: reusing it would overwrite the frame the histogram reads and
|
||||
/// leave the view showing an unedited image until the next redraw.
|
||||
///
|
||||
/// This is `export_pixels`, which is ungated: an export is not the display
|
||||
/// round-trip AC-8 forbids, and neither is this.
|
||||
fn neutral_proxy(&self, max_edge: u32) -> Result<(Vec<f32>, usize, usize), String> {
|
||||
let (sw, sh) = self.source.size();
|
||||
let scale = (max_edge as f32 / sw.max(sh) as f32).min(1.0);
|
||||
let (w, h) = (
|
||||
((sw as f32 * scale) as u32).max(1),
|
||||
((sh as f32 * scale) as u32).max(1),
|
||||
);
|
||||
|
||||
let neutral = EditGraph::default_chain();
|
||||
let mut pass = AdjustPass::new(&self.ctx);
|
||||
pass.render(&self.source, &neutral.compose(), w, h)
|
||||
.map_err(|e| format!("could not render the segmentation proxy: {e}"))?;
|
||||
let (rgba, pw, ph) = pass
|
||||
.export_pixels()
|
||||
.map_err(|e| format!("could not read the segmentation proxy: {e}"))?;
|
||||
|
||||
// Straight to float RGB, dropping alpha. The values stay display-
|
||||
// encoded because that is what the model was trained on — one of the
|
||||
// few places in this codebase where not linearising is correct.
|
||||
let rgb = rgba
|
||||
.chunks_exact(4)
|
||||
.flat_map(|p| {
|
||||
[
|
||||
p[0] as f32 / 255.0,
|
||||
p[1] as f32 / 255.0,
|
||||
p[2] as f32 / 255.0,
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
Ok((rgb, pw as usize, ph as usize))
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest edge the refine crop is rendered at.
|
||||
///
|
||||
/// Matches [`dr_segment::semantic::INPUT_EDGE`] rather than exceeding it: the
|
||||
/// model's own input is still fixed at 640x640, so rendering the crop larger
|
||||
/// only gets downsampled again inside the model's letterbox. The resolution
|
||||
/// win is entirely from *what fills the window* — a padded crop around one
|
||||
/// subject rather than the whole frame — not from feeding the model more
|
||||
/// pixels than it has ever read.
|
||||
const REFINE_EDGE: u32 = 640;
|
||||
|
||||
/// How much of the box's own size is added on each side before cropping.
|
||||
///
|
||||
/// Context for the model to place the subject's edge against, and slack for
|
||||
/// a box that under-ran the subject slightly on the first pass. Not so much
|
||||
/// that a second subject standing nearby gets pulled into the same window.
|
||||
const REFINE_PADDING: f32 = 0.25;
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// A refine pass lifted out of the session that asked for it.
|
||||
///
|
||||
/// The same split as [`SegmentationJob`], for the same reason — see its
|
||||
/// docs. This one answers for a single already-detected instance rather than
|
||||
/// the whole frame: it re-runs the model over a padded crop of just that
|
||||
/// subject's box, so the subject reaches the model at its own size instead
|
||||
/// of squeezed into the model's fixed window alongside everything else in
|
||||
/// the photograph.
|
||||
pub struct RefineJob {
|
||||
ctx: GpuContext,
|
||||
source: Arc<DemosaicedImage>,
|
||||
session: SessionId,
|
||||
abandon: Abandon,
|
||||
/// Which instance this answers for. A snapshot of what it needs from the
|
||||
/// segmentation, taken when the job was built — the same reason
|
||||
/// `SegmentationJob` carries a proxy render rather than the session.
|
||||
index: usize,
|
||||
class_name: Arc<str>,
|
||||
bbox: (f32, f32, f32, f32),
|
||||
proxy: (usize, usize),
|
||||
/// The same permutation, for the same reason — see
|
||||
/// [`SegmentationJob::orientation`]. A refine pass that read the crop
|
||||
/// sideways would hand back a worse mask than the one it was asked to
|
||||
/// improve, on the subject the photographer had just pointed at.
|
||||
orientation: dr_types::Orientation,
|
||||
}
|
||||
|
||||
impl RefineJob {
|
||||
pub fn session(&self) -> SessionId {
|
||||
self.session
|
||||
}
|
||||
|
||||
pub fn abandon(&self) -> Abandon {
|
||||
self.abandon.clone()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Re-detect this one subject at higher effective resolution. **Blocking.**
|
||||
///
|
||||
/// `Ok(None)` covers both "abandoned" and "the model found nothing of the
|
||||
/// same class in the crop" — a photographer pressing refine on a subject
|
||||
/// that the padded window no longer contains is a possible outcome, not
|
||||
/// a bug, and the caller treats it as "kept what was there" either way.
|
||||
pub fn run(&self) -> Result<Option<RefinedInstance>, String> {
|
||||
if self.abandon.asked() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let (px0, py0, px1, py1) = self.bbox;
|
||||
let (pw, ph) = (self.proxy.0 as f32, self.proxy.1 as f32);
|
||||
if pw <= 0.0 || ph <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let (bw, bh) = ((px1 - px0).max(1.0), (py1 - py0).max(1.0));
|
||||
let (padx, pady) = (bw * REFINE_PADDING, bh * REFINE_PADDING);
|
||||
let x0 = (px0 - padx).max(0.0);
|
||||
let y0 = (py0 - pady).max(0.0);
|
||||
let x1 = (px1 + padx).min(pw);
|
||||
let y1 = (py1 + pady).min(ph);
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let (cw_px, ch_px) = (x1 - x0, y1 - y0);
|
||||
|
||||
let view = CropRect {
|
||||
x: x0 / pw,
|
||||
y: y0 / ph,
|
||||
width: cw_px / pw,
|
||||
height: ch_px / ph,
|
||||
};
|
||||
|
||||
// Aspect-matched render target, capped against blowing a tiny box up
|
||||
// absurdly far past what the source ever had to offer.
|
||||
let scale = (REFINE_EDGE as f32 / cw_px.max(ch_px)).min(4.0);
|
||||
let (tw, th) = (
|
||||
(cw_px * scale).round().max(1.0) as u32,
|
||||
(ch_px * scale).round().max(1.0) as u32,
|
||||
);
|
||||
|
||||
if self.abandon.asked() {
|
||||
return Ok(None);
|
||||
}
|
||||
let (rgb, rw, rh) = self.render_view(view, tw, th)?;
|
||||
if self.abandon.asked() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Stood up before the model reads it and laid back down after, the
|
||||
// same way the whole-frame pass does it — `segmentation::upright` is
|
||||
// the one place that permutation is written. A crop rendered in
|
||||
// sensor space is exactly as sideways as the frame it came from.
|
||||
let (upright, uw, uh) = segmentation::upright(&rgb, rw, rh, self.orientation);
|
||||
|
||||
let mut model = dr_segment::SemanticModel::embedded().map_err(|e| e.to_string())?;
|
||||
let options = dr_segment::SemanticOptions {
|
||||
tiling: dr_segment::Tiling::Whole,
|
||||
..dr_segment::SemanticOptions::default()
|
||||
};
|
||||
let found = model
|
||||
.detect(&upright, uw, uh, &options)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// The crop was built around one subject, so the right answer among
|
||||
// whatever the model found in it is the same class closest to the
|
||||
// window's centre — not merely the highest score, which a second,
|
||||
// unrelated instance caught in the padding could win.
|
||||
//
|
||||
// Measured in the upright frame, which is where the model's boxes are.
|
||||
// The centre is the centre either way; the distances are not, once the
|
||||
// window is not square.
|
||||
let (cx, cy) = (uw as f32 * 0.5, uh as f32 * 0.5);
|
||||
let best = found
|
||||
.into_iter()
|
||||
.filter(|i| i.class_name.as_ref() == self.class_name.as_ref())
|
||||
.min_by(|a, b| centre_distance(a, cx, cy).total_cmp(¢re_distance(b, cx, cy)));
|
||||
|
||||
let Some(instance) = best else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Back into the crop's own sensor-space pixels, so everything below
|
||||
// this line measures in the space `self.bbox` and `self.proxy` are in.
|
||||
let (crop_mask, crop_bbox) =
|
||||
segmentation::lay_down(&instance.mask, instance.bbox, uw, uh, self.orientation);
|
||||
|
||||
// Downsampled back onto the shared proxy grid like every other
|
||||
// instance's mask is, but built from a sharper source than the
|
||||
// whole-frame pass ever saw for this subject.
|
||||
let mask = paste_into_proxy(&crop_mask, rw, rh, self.proxy, (x0, y0), (cw_px, ch_px));
|
||||
let bbox = (
|
||||
x0 + crop_bbox.0 / scale,
|
||||
y0 + crop_bbox.1 / scale,
|
||||
x0 + crop_bbox.2 / scale,
|
||||
y0 + crop_bbox.3 / scale,
|
||||
);
|
||||
|
||||
Ok(Some(RefinedInstance {
|
||||
index: self.index,
|
||||
summary: segmentation::InstanceSummary {
|
||||
class_name: instance.class_name,
|
||||
score: instance.score,
|
||||
mask,
|
||||
bbox,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/// Render the *unedited* image, showing only `view`, at `(width, height)`.
|
||||
///
|
||||
/// The same neutral, as-captured render [`SegmentationJob::neutral_proxy`]
|
||||
/// uses — a refine pass must read the same kind of pixels the first pass
|
||||
/// did, or a subject would gain or lose an edge depending on which pass
|
||||
/// found it. `view` is framing's ephemeral viewport (`Framing::set_view`),
|
||||
/// the mechanism the on-screen zoom already uses to render a region at
|
||||
/// more than proxy resolution — not the crop tool's own persisted
|
||||
/// rectangle, and nothing here touches that.
|
||||
fn render_view(
|
||||
&self,
|
||||
view: CropRect,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<(Vec<f32>, usize, usize), String> {
|
||||
let mut neutral = EditGraph::default_chain();
|
||||
neutral.framing_mut().set_view(view);
|
||||
|
||||
let mut pass = AdjustPass::new(&self.ctx);
|
||||
pass.render(&self.source, &neutral.compose(), width, height)
|
||||
.map_err(|e| format!("could not render the refine crop: {e}"))?;
|
||||
let (rgba, pw, ph) = pass
|
||||
.export_pixels()
|
||||
.map_err(|e| format!("could not read the refine crop: {e}"))?;
|
||||
|
||||
let rgb = rgba
|
||||
.chunks_exact(4)
|
||||
.flat_map(|p| {
|
||||
[
|
||||
p[0] as f32 / 255.0,
|
||||
p[1] as f32 / 255.0,
|
||||
p[2] as f32 / 255.0,
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
Ok((rgb, pw as usize, ph as usize))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a finished [`RefineJob`] hands back: a replacement for one instance
|
||||
/// in the segmentation it was run against.
|
||||
pub struct RefinedInstance {
|
||||
index: usize,
|
||||
summary: segmentation::InstanceSummary,
|
||||
}
|
||||
|
||||
fn centre_distance(instance: &dr_segment::Instance, cx: f32, cy: f32) -> f32 {
|
||||
let (x0, y0, x1, y1) = instance.bbox;
|
||||
let (ix, iy) = ((x0 + x1) * 0.5, (y0 + y1) * 0.5);
|
||||
((ix - cx).powi(2) + (iy - cy).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// Sample a crop-local mask back onto its footprint in the shared proxy grid.
|
||||
///
|
||||
/// Bilinear, the same as every other resampling in this mask pipeline
|
||||
/// (`dr_segment::semantic`'s own prototype sampling, the letterbox that feeds
|
||||
/// it) — correct whether the crop was rendered denser than the proxy (the
|
||||
/// common case this feature exists for) or coarser than it (a subject large
|
||||
/// enough that refining it buys little, which still renders a sensible if
|
||||
/// unremarkable answer rather than a distorted one).
|
||||
fn paste_into_proxy(
|
||||
crop_mask: &[f32],
|
||||
crop_w: usize,
|
||||
crop_h: usize,
|
||||
proxy: (usize, usize),
|
||||
origin_px: (f32, f32),
|
||||
extent_px: (f32, f32),
|
||||
) -> Vec<u8> {
|
||||
let (pw, ph) = proxy;
|
||||
let mut out = vec![0u8; pw * ph];
|
||||
let (ew, eh) = extent_px;
|
||||
if crop_w == 0 || crop_h == 0 || pw == 0 || ph == 0 || ew <= 0.0 || eh <= 0.0 {
|
||||
return out;
|
||||
}
|
||||
|
||||
let (ox, oy) = origin_px;
|
||||
let px0 = ox.floor().max(0.0) as usize;
|
||||
let py0 = oy.floor().max(0.0) as usize;
|
||||
let px1 = ((ox + ew).ceil() as usize).min(pw);
|
||||
let py1 = ((oy + eh).ceil() as usize).min(ph);
|
||||
|
||||
for py in py0..py1 {
|
||||
let gy = (py as f32 + 0.5 - oy) / eh * crop_h as f32;
|
||||
if gy < 0.0 || gy >= crop_h as f32 {
|
||||
continue;
|
||||
}
|
||||
for px in px0..px1 {
|
||||
let gx = (px as f32 + 0.5 - ox) / ew * crop_w as f32;
|
||||
if gx < 0.0 || gx >= crop_w as f32 {
|
||||
continue;
|
||||
}
|
||||
let v = bilinear_sample(crop_mask, crop_w, crop_h, gx, gy);
|
||||
out[py * pw + px] = (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn bilinear_sample(mask: &[f32], w: usize, h: usize, x: f32, y: f32) -> f32 {
|
||||
let (fx0, fy0) = (x.floor(), y.floor());
|
||||
let (fx, fy) = (x - fx0, y - fy0);
|
||||
let x0 = (fx0 as isize).clamp(0, w as isize - 1) as usize;
|
||||
let y0 = (fy0 as isize).clamp(0, h as isize - 1) as usize;
|
||||
let x1 = (x0 + 1).min(w - 1);
|
||||
let y1 = (y0 + 1).min(h - 1);
|
||||
let at = |x: usize, y: usize| mask[y * w + x];
|
||||
let top = at(x0, y0) * (1.0 - fx) + at(x1, y0) * fx;
|
||||
let bot = at(x0, y1) * (1.0 - fx) + at(x1, y1) * fx;
|
||||
top * (1.0 - fy) + bot * fy
|
||||
}
|
||||
|
||||
impl DevelopSession {
|
||||
// ----------------------------------------------------------------------
|
||||
// Segmentation (S15, docs/dev/segmentation.md)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/// This session's name, carried by any work started against it.
|
||||
pub fn id(&self) -> SessionId {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Everything a segmentation needs, so it can be run somewhere else.
|
||||
///
|
||||
/// Taking the job is cheap — two `Arc` bumps and a texture handle — and
|
||||
/// nothing about the session is borrowed past the call, which is what
|
||||
/// lets the window go on drawing while the answer is being found.
|
||||
pub fn segmentation_job(&self) -> SegmentationJob {
|
||||
SegmentationJob {
|
||||
ctx: self.ctx.clone(),
|
||||
source: self.demosaiced.clone(),
|
||||
session: self.id,
|
||||
abandon: Abandon::default(),
|
||||
names: self.face_names.clone(),
|
||||
exif_orientation: self.orientation,
|
||||
orientation: self.graph.framing().effective_orientation(),
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-CULL-10 | FR-DEV-3
|
||||
/// The confirmed faces in this photograph, for naming segmented regions.
|
||||
///
|
||||
/// Set once when the image opens, because that is the only moment the
|
||||
/// catalog and the image id are both in reach — the develop session
|
||||
/// deliberately knows nothing about either, and every segmentation run
|
||||
/// after this point picks the names up for free.
|
||||
///
|
||||
/// Boxes are normalised to the long edge, as the catalog stores them, so
|
||||
/// they survive whatever proxy size a run settles on.
|
||||
pub fn set_face_names(&mut self, names: Vec<crate::identity::NormalisedNamedBox>) {
|
||||
self.face_names = names;
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Take on a segmentation found elsewhere.
|
||||
///
|
||||
/// The caller is responsible for checking that this result was computed
|
||||
/// for *this* session — see [`SegmentationJob::session`]. Nothing here can
|
||||
/// tell one photograph's subjects from another's, and a mismatch is
|
||||
/// silent: the masks would rasterise, the overlay would draw, and the
|
||||
/// outlines would simply follow a subject that is not in the picture.
|
||||
pub fn adopt_segmentation(&mut self, found: Segmented) {
|
||||
// Kept rather than replaced where there is one already: a second
|
||||
// segmentation of the same photograph would otherwise throw away a
|
||||
// working rasteriser for an identical one.
|
||||
self.masks = self.masks.take().or(found.masks);
|
||||
|
||||
// The fields themselves are built per *layer*, on demand — there are
|
||||
// none yet, and building one per detected object would transform
|
||||
// several megapixels for masks the user may never make.
|
||||
self.segmentation = Some(found.seg);
|
||||
self.subjects = None;
|
||||
self.subject_key = 0;
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Everything a refine pass needs for one already-detected instance, so
|
||||
/// it can be run somewhere else — see [`Self::segmentation_job`] for why
|
||||
/// the split exists.
|
||||
///
|
||||
/// `None` where there is nothing to refine: no segmentation yet, or an
|
||||
/// index the panel offered a button for a moment ago but the segmentation
|
||||
/// underneath has since changed.
|
||||
pub fn refine_job(&self, index: usize) -> Option<RefineJob> {
|
||||
let seg = self.segmentation.as_ref()?;
|
||||
let instance = seg.instances().get(index)?;
|
||||
Some(RefineJob {
|
||||
ctx: self.ctx.clone(),
|
||||
source: self.demosaiced.clone(),
|
||||
session: self.id,
|
||||
abandon: Abandon::default(),
|
||||
index,
|
||||
class_name: instance.class_name.clone(),
|
||||
bbox: instance.bbox,
|
||||
proxy: seg.proxy_size(),
|
||||
orientation: self.graph.framing().effective_orientation(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Take on a refined instance found elsewhere.
|
||||
///
|
||||
/// Same caution as [`Self::adopt_segmentation`]: the caller checks the
|
||||
/// job's session before handing back its answer, not this. Every mask
|
||||
/// layer pointing at this instance's index reads it fresh next redraw —
|
||||
/// `subject_key` is reset because the pixels changed under an index
|
||||
/// `subject_signature` has no way to know changed, unlike a morphology
|
||||
/// slider it does track.
|
||||
pub fn adopt_refined(&mut self, refined: RefinedInstance) {
|
||||
if let Some(seg) = self.segmentation.as_mut() {
|
||||
seg.replace_instance(refined.index, refined.summary);
|
||||
}
|
||||
self.subjects = None;
|
||||
self.subject_key = 0;
|
||||
}
|
||||
|
||||
/// The mask layer's original detection index, for the refine button —
|
||||
/// `None` for a layer that is not a subject at all (a gradient has no
|
||||
/// instance to re-detect).
|
||||
pub fn subject_instance_index(&self, id: &str) -> Option<usize> {
|
||||
match &self.graph.masks().get(id)?.base().source {
|
||||
MaskSource::Subject { index, .. } => Some(*index as usize),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the subjects and take them on, blocking until both are done.
|
||||
///
|
||||
/// Test-only, and deliberately: a session-shaped blocking call is exactly
|
||||
/// the shape that put two thirds of a second on the UI thread in the first
|
||||
/// place, and leaving it public would invite the next caller to reach for
|
||||
/// it. A test has nothing else to be doing.
|
||||
#[cfg(test)]
|
||||
pub(super) fn segment(&mut self, options: &segmentation::Options) -> Result<(), String> {
|
||||
if let Some(found) = self.segmentation_job().run(options)? {
|
||||
self.adopt_segmentation(found);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn has_segmentation(&self) -> bool {
|
||||
self.segmentation.is_some()
|
||||
}
|
||||
|
||||
/// The subjects the model recognised, as `(label, confidence)`.
|
||||
///
|
||||
/// Confidence is shown rather than hidden because the detector is offered
|
||||
/// as a shortcut, not as an authority: a 0.42 "dog" is worth listing and
|
||||
/// worth flagging, and a list that presented it identically to a 0.95 one
|
||||
/// would make the tool look wrong when the guess was merely weak.
|
||||
pub fn detected_subjects(&self) -> Vec<(String, f32)> {
|
||||
self.segmentation
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
s.instances()
|
||||
.iter()
|
||||
.map(|i| (i.class_name.to_string(), i.score))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::develop::test_support::*;
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Segmentation off the UI thread
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/// The property the whole arrangement rests on.
|
||||
///
|
||||
/// If someone puts an `Rc`, a `Cell` or a raw pipeline handle into
|
||||
/// `SegmentationJob`, this stops compiling — which is the only warning
|
||||
/// there would be, since the call site in `masks_ui` would then fail with
|
||||
/// a lifetime error a long way from the cause.
|
||||
#[test]
|
||||
fn a_job_and_its_answer_can_cross_a_thread() {
|
||||
fn is_send<T: Send>() {}
|
||||
is_send::<SegmentationJob>();
|
||||
is_send::<Segmented>();
|
||||
is_send::<Abandon>();
|
||||
}
|
||||
|
||||
/// Two sessions over the same file are still two photographs as far as a
|
||||
/// late result is concerned, because opening one twice is opening it
|
||||
/// twice.
|
||||
#[test]
|
||||
fn every_session_has_its_own_identity() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
||||
let open = || {
|
||||
DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||||
.expect("session")
|
||||
};
|
||||
let (a, b) = (open(), open());
|
||||
assert_ne!(a.id(), b.id());
|
||||
assert_eq!(a.id(), a.id(), "and stable within one session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_carries_the_session_it_was_taken_from() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
||||
let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
assert_eq!(session.segmentation_job().session(), session.id());
|
||||
}
|
||||
|
||||
/// Abandoning before the run reaches the proxy must cost nothing at all —
|
||||
/// this is the case that fires when the user pages on while a job is still
|
||||
/// waiting for a thread.
|
||||
#[test]
|
||||
fn an_abandoned_job_does_no_work() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
||||
let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
|
||||
let job = session.segmentation_job();
|
||||
job.abandon().now();
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let out = job.run(&crate::segmentation::Options::default());
|
||||
assert!(
|
||||
matches!(out, Ok(None)),
|
||||
"abandoned is not an error and not an empty answer: {:?}",
|
||||
out.map(|o| o.is_some())
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() < std::time::Duration::from_millis(50),
|
||||
"it returned without loading the model"
|
||||
);
|
||||
}
|
||||
|
||||
/// A finished segmentation is adopted whole, and the session says so.
|
||||
#[test]
|
||||
fn adopting_a_result_gives_the_session_its_subjects() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let rgba: Vec<u8> = (0..100 * 100).flat_map(|_| [128, 128, 128, 255]).collect();
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(&ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
assert!(!session.has_segmentation());
|
||||
|
||||
let job = session.segmentation_job();
|
||||
let Ok(Some(found)) = job.run(&crate::segmentation::Options::default()) else {
|
||||
eprintln!("no model; skipping");
|
||||
return;
|
||||
};
|
||||
session.adopt_segmentation(found);
|
||||
assert!(session.has_segmentation());
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// A refine crop's mask, pasted back onto the proxy grid it replaces,
|
||||
/// must land exactly where the crop was — not shifted by the origin, not
|
||||
/// scaled onto the wrong footprint.
|
||||
#[test]
|
||||
fn a_refined_mask_pastes_back_at_the_crops_own_position() {
|
||||
// A crop entirely on (1.0), covering proxy pixels 2..6 in x and
|
||||
// 2..6 in y of an 8x8 proxy — everywhere inside that box must read
|
||||
// back as fully covered, everywhere outside as untouched.
|
||||
let crop = vec![1.0f32; 4 * 4];
|
||||
let mask = paste_into_proxy(&crop, 4, 4, (8, 8), (2.0, 2.0), (4.0, 4.0));
|
||||
|
||||
assert_eq!(mask[2 * 8 + 2], 255, "top-left corner of the box");
|
||||
assert_eq!(mask[5 * 8 + 5], 255, "bottom-right corner of the box");
|
||||
assert_eq!(mask[0], 0, "outside the box, untouched");
|
||||
assert_eq!(mask[7 * 8 + 7], 0, "outside the box, untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bilinear_sample_averages_its_four_neighbours() {
|
||||
// Two rows, black then white: the exact midpoint reads as grey.
|
||||
let mask = [0.0f32, 0.0, 1.0, 1.0];
|
||||
let v = bilinear_sample(&mask, 2, 2, 0.5, 0.5);
|
||||
assert!(
|
||||
(v - 0.5).abs() < 1e-6,
|
||||
"expected the midpoint grey, got {v}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,783 @@
|
||||
//! The develop session's identity, lifecycle and file metadata.
|
||||
//!
|
||||
//! `DevelopSession` itself, opening a photograph, and the small set of
|
||||
//! whole-session queries (settings, metadata) that do not belong to any of
|
||||
//! the more specific areas split out alongside this one.
|
||||
use dr_decode::RawImage;
|
||||
use dr_gpu::{
|
||||
AdjustPass, DemosaicedImage, Demosaicer, FocusPeakPass, FocusPeaking, GpuContext,
|
||||
HistogramPass, MaskPass, RawHistogram, RawHistogramPass,
|
||||
};
|
||||
use dr_pipeline::{CropRect, Edit, EditGraph, History, Preset, Scope};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::labels;
|
||||
use crate::segmentation::Segmentation;
|
||||
|
||||
use super::masks::MaskView;
|
||||
use super::segmentation::SessionId;
|
||||
|
||||
pub struct DevelopSession {
|
||||
/// This session's name, for work that outlives the frame it started on.
|
||||
pub(super) id: SessionId,
|
||||
/// TRACES: FR-CULL-10
|
||||
/// Confirmed faces in this photograph, normalised to the long edge.
|
||||
///
|
||||
/// Empty until [`DevelopSession::set_face_names`] is called, and empty for
|
||||
/// ever on a library with no face indexing — in which case segmentation
|
||||
/// behaves exactly as it did before, which is the point.
|
||||
pub(super) face_names: Vec<crate::identity::NormalisedNamedBox>,
|
||||
/// How this photograph is stored relative to how it is shown.
|
||||
///
|
||||
/// Kept because the segmentation proxy is rendered through a *neutral*
|
||||
/// graph and is therefore in sensor order, while faces were found on the
|
||||
/// upright thumbnail. On anything shot in portrait the two differ by a
|
||||
/// quarter turn, and matching them without undoing it finds nothing.
|
||||
pub(super) orientation: dr_types::Orientation,
|
||||
/// TRACES: FR-EXP-8
|
||||
/// What the file these pixels came from said about itself.
|
||||
///
|
||||
/// A session is one photograph, and this is that photograph's header: the
|
||||
/// body, the lens, the moment the shutter fired, the rights statement.
|
||||
/// Nothing in develop reads it. It is remembered so that an export made
|
||||
/// from the open image can disclose the same things an export of the same
|
||||
/// file from the grid does, and so `{date}` can mean the capture date on
|
||||
/// both paths rather than nothing on one of them.
|
||||
///
|
||||
/// **Why here rather than beside the frame on `export::Source::Rendered`.**
|
||||
/// Hanging it on the export request would work and would touch less of
|
||||
/// this file, but the header would then have to be held somewhere in the
|
||||
/// interface *alongside* the session and paired with it at export time —
|
||||
/// two cells to keep in step across the six places a photograph is opened,
|
||||
/// replaced or fails to open. The failure mode of getting that pairing
|
||||
/// wrong is not a missing tag: it is one photograph exported under
|
||||
/// another's byline and coordinates, silently. Kept here, the header
|
||||
/// arrives with the pixels it belongs to or not at all, and there is no
|
||||
/// pairing left to break.
|
||||
///
|
||||
/// It is the *decoded header* and not a `dr_export::SourceMetadata`, which
|
||||
/// matters: what an export may disclose is a decision taken per export
|
||||
/// from the settings, inside `dr-export` — see that crate's note on why
|
||||
/// source metadata is a parameter and not a field on `Frame`. This is only
|
||||
/// the memory of where the pixels came from; the allowlist that turns it
|
||||
/// into something writable stays the one function in `export.rs`.
|
||||
pub(super) source_meta: Option<dr_decode::Metadata>,
|
||||
/// Whether the lens in the header matched a profile in the database.
|
||||
///
|
||||
/// A separate flag rather than `graph.lens_profile().is_some()`, because
|
||||
/// the two answer different questions once the photographer starts work:
|
||||
/// the graph says what is *applied*, which a manual correction also
|
||||
/// satisfies, and this says whether a *measurement* was found. Only the
|
||||
/// second can honestly caption "no profile".
|
||||
pub(super) lens_profile_found: bool,
|
||||
/// Kept so the session can build GPU resources after construction.
|
||||
///
|
||||
/// The distance fields behind a subject mask are made when a layer is
|
||||
/// *shaped*, not when the image opens, and cloning a `GpuContext` is two
|
||||
/// `Arc` bumps.
|
||||
pub(super) ctx: GpuContext,
|
||||
pub(super) graph: EditGraph,
|
||||
/// TRACES: FR-DEV-5
|
||||
/// Undo, kept beside the graph rather than in the window.
|
||||
///
|
||||
/// Every mutator below records into it, so a caller cannot change the edit
|
||||
/// and forget to. That is the whole reason it lives here: the callbacks in
|
||||
/// `lib.rs` are generic by construction and there are a dozen of them, and
|
||||
/// a history the *call sites* had to remember would be one press of undo
|
||||
/// away from wrong every time a control is added.
|
||||
pub(super) history: History,
|
||||
/// TRACES: FR-DEV-5
|
||||
/// The named snapshots of this edit, as the sidecar had them plus what
|
||||
/// this sitting took, minus what it deleted.
|
||||
///
|
||||
/// Beside the history rather than inside it, because they answer a
|
||||
/// different question. The history is what was done in this sitting and
|
||||
/// is deliberately forgotten with it; a snapshot is a state the
|
||||
/// photographer *named*, which is the act of saying it should outlive
|
||||
/// the sitting. It is persisted as a version of the sidecar pointing at
|
||||
/// this one (`Version::snapshot_of`), which is what makes it survive a
|
||||
/// restart and reach the other device.
|
||||
pub(super) snapshots: Vec<dr_pipeline::Version>,
|
||||
/// The ids of snapshots deleted this sitting, so the save can remove
|
||||
/// them from the file without removing what another device added since
|
||||
/// — see `Sidecar::replace_snapshots`.
|
||||
pub(super) removed_snapshots: Vec<String>,
|
||||
/// TRACES: FR-DEV-7
|
||||
/// The snapshot the canvas is showing instead of the edit, while a
|
||||
/// comparison is held. Viewing state: nothing about the edit changes,
|
||||
/// and it goes down with the session.
|
||||
pub(super) compared_snapshot: Option<String>,
|
||||
pub(super) demosaiced: Arc<DemosaicedImage>,
|
||||
pub(super) adjust: AdjustPass,
|
||||
/// TRACES: FR-DSP-7
|
||||
/// Optional, because a session that cannot count its frames is still a
|
||||
/// session that can develop them. If the reduction fails to build — an
|
||||
/// old driver, a device without the storage-buffer atomics it needs — the
|
||||
/// photographer loses the histogram and keeps the photograph.
|
||||
pub(super) histogram: Option<HistogramPass>,
|
||||
/// TRACES: FR-CULL-3
|
||||
/// The raw-domain reduction, on the same terms as the display one above:
|
||||
/// optional, because a session that cannot count the sensor data is still
|
||||
/// a session that can develop it.
|
||||
pub(super) raw_histogram: Option<RawHistogramPass>,
|
||||
/// The raw reading, once taken.
|
||||
///
|
||||
/// **Cached, where the display histogram is recomputed every settled
|
||||
/// frame, and the difference is not an optimisation.** This measures the
|
||||
/// demosaiced source, which nothing downstream of the demosaic can change:
|
||||
/// no slider, no crop, no zoom, no output space moves a single count in
|
||||
/// it. Recomputing it per frame would be a dispatch and a device sync
|
||||
/// point spent to arrive back at the number already held — and on the
|
||||
/// culling pass FR-CULL-3 is written for, that is a cost paid three
|
||||
/// thousand times over.
|
||||
///
|
||||
/// `None` until first asked for, and it stays `None` on a file with no
|
||||
/// sensor data behind it. The session is one photograph and the demosaiced
|
||||
/// source is fixed for its life, so there is no invalidation to get wrong.
|
||||
pub(super) raw_counts: Option<RawHistogram>,
|
||||
/// 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.
|
||||
pub(super) 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.
|
||||
pub(super) peaking: Option<FocusPeaking>,
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The region map local masks select from, once it has been computed.
|
||||
///
|
||||
/// `None` until the photographer asks for it. Segmentation costs about
|
||||
/// half a second and most edits never need one, so running it on open
|
||||
/// would tax every photograph for a feature used on some of them.
|
||||
pub(super) segmentation: Option<Segmentation>,
|
||||
/// Rasterises the mask layers. Built lazily for the same reason.
|
||||
pub(super) masks: Option<MaskPass>,
|
||||
/// One signed distance field per active subject layer, on the GPU.
|
||||
pub(super) subjects: Option<dr_gpu::SubjectMasks>,
|
||||
/// What `subjects` was built from.
|
||||
///
|
||||
/// The fields are expensive — an exact distance transform over the proxy
|
||||
/// for each layer — and almost nothing changes them. Feather, falloff and
|
||||
/// simple growing are arithmetic the shader does on the field it already
|
||||
/// has, so this deliberately does *not* include them: dragging those
|
||||
/// sliders must not rebuild anything.
|
||||
pub(super) subject_key: u64,
|
||||
/// Which layers the develop panel is editing, if any.
|
||||
///
|
||||
/// This is what lets one panel serve both scopes: with layers selected,
|
||||
/// the sliders read and write *their* chains, and the photographer is
|
||||
/// adjusting one or more regions rather than the frame.
|
||||
///
|
||||
/// A plain click replaces this outright; a modifier-click toggles one id
|
||||
/// in or out, so several layers can be shaped by the same slider drag —
|
||||
/// "make these three subjects a stop darker" is one gesture rather than
|
||||
/// three. Order is insertion order and nothing reads it, only membership.
|
||||
pub(super) active_masks: Vec<String>,
|
||||
/// TRACES: FR-DEV-19a
|
||||
/// Which part of the selected layer the tools and the edge controls point
|
||||
/// at. Zero — the base — whenever a layer is selected afresh.
|
||||
///
|
||||
/// An index rather than an id, because it addresses a row the panel is
|
||||
/// already showing by position, and because a gesture that outlived the
|
||||
/// part it was aimed at would be a stroke landing somewhere nobody asked
|
||||
/// for. Clamped on the way in and re-checked on the way out.
|
||||
pub(super) active_part: usize,
|
||||
/// TRACES: FR-DEV-19b
|
||||
/// The layer and part a stroke in progress is going into.
|
||||
///
|
||||
/// Captured on press and held for the gesture: the panel's selection can
|
||||
/// change under a finger — a stray tap, a sync arriving — and a stroke
|
||||
/// that changed target half way through would leave half a mark in each.
|
||||
pub(super) painting: Option<(String, usize)>,
|
||||
/// TRACES: FR-DEV-19b
|
||||
/// The brush: radius as a fraction of the frame's shorter edge, hardness,
|
||||
/// and flow.
|
||||
///
|
||||
/// On the session rather than on a layer, because it belongs to the
|
||||
/// *tool*: somebody who sets a small eraser expects it to still be small
|
||||
/// the next time they erase, whichever mask they are working on.
|
||||
pub(super) brush: (f32, f32, f32),
|
||||
/// TRACES: FR-DEV-8
|
||||
/// Which repair the panel is describing, if any.
|
||||
///
|
||||
/// Interface state and not part of the edit, exactly as `active_masks` is:
|
||||
/// it changes no pixel, it is not in the sidecar, and it is not on the undo
|
||||
/// stack. One at a time rather than a set — a repair is eight numbers and
|
||||
/// there is no gesture that usefully moves several at once, where three
|
||||
/// masked layers really can share a slider drag.
|
||||
pub(super) selected_spot: Option<String>,
|
||||
/// Whether to draw the false-coloured region overlay.
|
||||
pub(super) show_overlay: bool,
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// How shown masks are drawn — one style for all of them.
|
||||
///
|
||||
/// Interface state, like `show_overlay` beside it and `active_masks` above
|
||||
/// — it changes no pixel of the photograph, it is not in the sidecar and
|
||||
/// it is not on the undo stack. It reaches the pipeline as an argument to
|
||||
/// the one composition that draws the canvas, which is what makes an
|
||||
/// export structurally unable to carry it (`EditGraph::compose_revealing`).
|
||||
pub(super) reveal_style: dr_pipeline::mask::RevealStyle,
|
||||
/// TRACES: FR-DEV-19c
|
||||
/// Per layer: whether its mask is shown, and in what colour.
|
||||
///
|
||||
/// Per layer rather than "the selected one", because the question a
|
||||
/// photographer asks of two masks is how they meet — where the sky's edge
|
||||
/// sits against the building's — and that needs both on screen at once,
|
||||
/// in colours that can be told apart. Keyed by id, and an id that is no
|
||||
/// longer in the stack is simply never asked for; `reveal` walks the
|
||||
/// stack, not this map.
|
||||
///
|
||||
/// Viewing state and not edit state, for the reason the style is: it does
|
||||
/// not travel in a sidecar, so a photograph reopened has every eye closed.
|
||||
pub(super) mask_views: std::collections::HashMap<String, MaskView>,
|
||||
/// Which attribute the panel is filtered to, or all of them.
|
||||
///
|
||||
/// `None` is "show everything" and is what a frontend that ignores
|
||||
/// attributes leaves it at — the tabs are the interface's idea, not the
|
||||
/// core's, and nothing breaks without them (ARCH §4.3a).
|
||||
pub(super) active_tab: Option<dr_pipeline::Attribute>,
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Which of the curve widget's subjects the panel is plotting.
|
||||
///
|
||||
/// The tone curve is four curves — one over tone and one per colour
|
||||
/// channel — and one square plot draws one of them at a time. The index
|
||||
/// is into the subjects the operation's parameters are faceted on, in the
|
||||
/// order it declares them, so nothing here knows that "red" exists.
|
||||
///
|
||||
/// **Interface state, not part of the edit.** It changes no pixel, so it
|
||||
/// is not a parameter, it is not in the graph, it is not in the sidecar
|
||||
/// and it is not on the undo stack — the same standing as which tab is
|
||||
/// open. One value rather than one per operation, for the same reason
|
||||
/// `curve_samples` is one polyline: the panel draws one curve.
|
||||
pub(super) curve_channel: usize,
|
||||
/// TRACES: FR-DSP-8
|
||||
/// The space the canvas is encoded into, for the display now showing it.
|
||||
///
|
||||
/// **Not part of the edit, and not interface state either.** It is a fact
|
||||
/// about the glass in front of the photographer: the same graph on the
|
||||
/// same file composes differently on a wide-gamut second monitor, and
|
||||
/// neither the sidecar nor the undo stack has any business knowing about
|
||||
/// it. That is also why it lives here rather than on the `EditGraph` —
|
||||
/// `compose_for` deliberately takes the space per call because "the same
|
||||
/// edit goes to the screen in the display's space and to a file in
|
||||
/// whatever the export asks for, and neither is more authoritative".
|
||||
///
|
||||
/// sRGB until the application says otherwise, which is the same answer
|
||||
/// `dr_plat::display`'s fallback gives and means a session constructed in
|
||||
/// a test behaves exactly as it did before this existed.
|
||||
/// TRACES: FR-DEV-3
|
||||
/// What the straightening auto-crop last wrote, and what it was derived
|
||||
/// from: `(applied, intended)`.
|
||||
///
|
||||
/// **The graph holds the corrected rectangle; this remembers the intent
|
||||
/// behind it.** `auto_crop_to_angle` pulls the crop inside the area an
|
||||
/// angle leaves defined, and that operation can only ever shrink. Applied
|
||||
/// to its own output it ratchets — straighten to 20 degrees, come back to
|
||||
/// 3, and the crop stays at the size 20 degrees demanded, which is not
|
||||
/// what turning the slider back means. So the correction is never
|
||||
/// accumulated: it is recomputed from the intent every time, and as the
|
||||
/// angle falls the crop grows back and stops exactly where the user put
|
||||
/// it. At zero degrees the safe area is the whole frame and the two are
|
||||
/// equal again.
|
||||
///
|
||||
/// **A pair rather than a single remembered rectangle, so it repairs
|
||||
/// itself.** Every other route to the crop — a handle dragged, a ratio
|
||||
/// chosen, a sidecar loaded, a paste, an undo — leaves the graph holding
|
||||
/// something other than `applied`, and that mismatch is exactly the signal
|
||||
/// that the remembered intent is stale. [`Self::intended_crop`] checks it
|
||||
/// rather than requiring each of those paths to remember to write here,
|
||||
/// which is the kind of bookkeeping that is correct until someone adds a
|
||||
/// seventh path.
|
||||
///
|
||||
/// Session-scoped. A sidecar records the crop that was *applied*, because
|
||||
/// that is the one that describes the photograph, so reopening starts from
|
||||
/// that rectangle as its own intent.
|
||||
pub(super) auto_crop: Option<(CropRect, CropRect)>,
|
||||
pub(super) display_space: dr_types::ColourSpace,
|
||||
}
|
||||
|
||||
impl DevelopSession {
|
||||
/// Demosaic an image and prepare its edit graph.
|
||||
///
|
||||
/// `orientation` is the file's EXIF orientation, not an edit: a sensor is
|
||||
/// scanned the same way whichever way the body was held, so this is what
|
||||
/// makes a portrait frame open upright. It is fixed for the life of the
|
||||
/// session and survives a reset.
|
||||
pub fn open(
|
||||
ctx: &GpuContext,
|
||||
raw: &RawImage,
|
||||
orientation: dr_types::Orientation,
|
||||
) -> Result<Self, String> {
|
||||
let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?;
|
||||
let demosaiced = demosaicer.run(raw).map_err(|e| e.to_string())?;
|
||||
Ok(Self::with_source(ctx, demosaiced, orientation))
|
||||
}
|
||||
|
||||
/// Prepare an edit graph over an already-processed RGB image.
|
||||
///
|
||||
/// The JPEG path. A JPEG is already demosaiced, so there is no sensor
|
||||
/// stage to run — but everything after it is identical, which is why this
|
||||
/// shares [`Self::with_source`] rather than duplicating the session.
|
||||
///
|
||||
/// Worth being honest about what this cannot recover: an 8-bit JPEG has
|
||||
/// clipped highlights and quantised shadows that no edit brings back, so
|
||||
/// exposure has far less latitude here than on sensor data. The controls
|
||||
/// are the same controls; the file simply carries less to work with.
|
||||
pub fn open_rgb(
|
||||
ctx: &GpuContext,
|
||||
rgba: &[u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
orientation: dr_types::Orientation,
|
||||
) -> Result<Self, String> {
|
||||
let source =
|
||||
DemosaicedImage::from_rgba8(ctx, rgba, width, height).map_err(|e| e.to_string())?;
|
||||
Ok(Self::with_source(ctx, source, orientation))
|
||||
}
|
||||
|
||||
pub(super) fn with_source(
|
||||
ctx: &GpuContext,
|
||||
demosaiced: DemosaicedImage,
|
||||
orientation: dr_types::Orientation,
|
||||
) -> Self {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.set_orientation(orientation);
|
||||
let history = History::new(&graph);
|
||||
Self {
|
||||
id: SessionId::next(),
|
||||
face_names: Vec::new(),
|
||||
orientation,
|
||||
// Filled by `crate::open_session`, which is the only place that
|
||||
// has both the bytes and the header read from them. A session
|
||||
// built straight from pixels — a test, `masks_ui`'s fixture —
|
||||
// honestly has no header, and says so.
|
||||
source_meta: None,
|
||||
// Nothing has been looked up, which is not the same as "looked up
|
||||
// and not found" — `lens_summary` distinguishes them.
|
||||
lens_profile_found: false,
|
||||
ctx: ctx.clone(),
|
||||
graph,
|
||||
history,
|
||||
snapshots: Vec::new(),
|
||||
removed_snapshots: Vec::new(),
|
||||
compared_snapshot: None,
|
||||
demosaiced: Arc::new(demosaiced),
|
||||
adjust: AdjustPass::new(ctx),
|
||||
histogram: HistogramPass::new(ctx)
|
||||
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
|
||||
.ok(),
|
||||
raw_histogram: RawHistogramPass::new(ctx)
|
||||
.inspect_err(|e| log::warn!("no raw histogram on this device: {e}"))
|
||||
.ok(),
|
||||
raw_counts: None,
|
||||
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,
|
||||
subject_key: 0,
|
||||
active_masks: Vec::new(),
|
||||
active_part: 0,
|
||||
painting: None,
|
||||
brush: (
|
||||
dr_pipeline::mask::DEFAULT_BRUSH_RADIUS,
|
||||
dr_pipeline::mask::DEFAULT_BRUSH_HARDNESS,
|
||||
dr_pipeline::mask::DEFAULT_BRUSH_FLOW,
|
||||
),
|
||||
selected_spot: None,
|
||||
show_overlay: false,
|
||||
reveal_style: dr_pipeline::mask::RevealStyle::Tint,
|
||||
mask_views: std::collections::HashMap::new(),
|
||||
active_tab: None,
|
||||
curve_channel: 0,
|
||||
display_space: dr_types::ColourSpace::Srgb,
|
||||
auto_crop: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-8
|
||||
/// Remember what the file this session was opened from said about itself.
|
||||
///
|
||||
/// Called by [`crate::open_session`] rather than by the constructors,
|
||||
/// because that is the one function that reads a photograph's bytes and
|
||||
/// its header together — every other way of making a session starts from
|
||||
/// pixels that never had a file behind them.
|
||||
pub fn set_source_metadata(&mut self, meta: dr_decode::Metadata) {
|
||||
// The lens profile is applied *here* rather than by the caller, and
|
||||
// that is the point of putting it in this method. This is the one
|
||||
// place a session is told which file it came from, so it is the one
|
||||
// place the lookup can be made unforgettable — the same shape
|
||||
// `FilmRebake` uses to stop a derived thing being quietly skipped.
|
||||
self.apply_lens_profile(&meta);
|
||||
self.source_meta = Some(meta);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Look this shot's lens up and hand the coefficients to the corrections.
|
||||
///
|
||||
/// Called with **every** header, including ones naming no lens: the
|
||||
/// clearing case matters as much as the setting one, because a session
|
||||
/// reused for a second photograph would otherwise correct it for the
|
||||
/// optics of the first.
|
||||
pub(super) fn apply_lens_profile(&mut self, meta: &dr_decode::Metadata) {
|
||||
let found = Self::profile_for(meta);
|
||||
self.lens_profile_found = found.is_some();
|
||||
self.graph.set_lens_profile(found);
|
||||
}
|
||||
|
||||
/// The profile for one shot, converted into the pipeline's own types.
|
||||
///
|
||||
/// **The conversion lives here because nowhere else can see both sides.**
|
||||
/// `dr-lens` carries the Lensfun database and `dr-pipeline` carries the
|
||||
/// maths, and the coefficient structs are deliberately duplicated so that
|
||||
/// the dependency between them does not exist (ARCH §6.5a). This function
|
||||
/// is the seam, and it is a `match` on three optionals.
|
||||
///
|
||||
/// Every field is taken independently. The database routinely knows a
|
||||
/// lens's distortion and not its vignetting, or covers only part of a
|
||||
/// zoom's range, and a partial profile is worth applying — discarding it
|
||||
/// because one field is missing would turn a good correction into none.
|
||||
pub(crate) fn profile_for(meta: &dr_decode::Metadata) -> Option<dr_pipeline::LensProfile> {
|
||||
// All three are needed to ask the question at all. A lens name alone
|
||||
// does not identify a correction: distortion is interpolated across a
|
||||
// zoom's focal range and vignetting depends strongly on aperture — a
|
||||
// fast prime can be two stops down in the corners wide open and clean
|
||||
// by f/8 — so a lookup missing either would return a profile measured
|
||||
// for a shot nobody took.
|
||||
let (lens, focal, aperture) = (meta.lens.as_deref()?, meta.focal_length?, meta.aperture?);
|
||||
|
||||
let shot = dr_lens::ShotInfo::new(lens, focal, aperture);
|
||||
let found = dr_lens::lookup(&shot)?;
|
||||
if found.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(dr_pipeline::LensProfile {
|
||||
distortion: found
|
||||
.distortion
|
||||
.map(|d| dr_pipeline::ops::distortion::PtLens {
|
||||
a: d.a,
|
||||
b: d.b,
|
||||
c: d.c,
|
||||
}),
|
||||
tca: found.tca.map(|t| dr_pipeline::Tca {
|
||||
red_scale: t.red_scale,
|
||||
blue_scale: t.blue_scale,
|
||||
}),
|
||||
vignetting: found.vignetting.map(|v| dr_pipeline::ops::vignetting::Pa {
|
||||
k1: v.k1,
|
||||
k2: v.k2,
|
||||
k3: v.k3,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// What to tell the photographer about the automatic lens correction.
|
||||
///
|
||||
/// `dr-lens` states the rule this exists to satisfy: an automatic
|
||||
/// correction that silently did nothing is worse than one the user can see
|
||||
/// is unavailable. Most lenses in most photographs will not be in the
|
||||
/// database — third-party glass often reports nothing, adapted manual
|
||||
/// lenses report nothing at all — so "no profile" is the ordinary case and
|
||||
/// has to read as a fact rather than as a failure.
|
||||
pub fn lens_summary(&self) -> String {
|
||||
let Some(meta) = self.source_meta.as_ref() else {
|
||||
return String::new();
|
||||
};
|
||||
let Some(lens) = meta
|
||||
.lens
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
else {
|
||||
// Not "no profile found": nothing was looked up, because the file
|
||||
// does not say what it was taken with. Naming the wrong reason
|
||||
// would send someone hunting for a profile that was never missing.
|
||||
return "Lens not recorded".into();
|
||||
};
|
||||
match (self.lens_profile_found, self.graph.lens_profile_applied()) {
|
||||
(true, true) => format!("{lens} · corrected"),
|
||||
// A profile exists and is switched off, which is neither of the
|
||||
// other two answers: the photographer turned it off, and a line
|
||||
// reading "no profile" would send them looking for one that is
|
||||
// sitting right there in the panel with its box unticked.
|
||||
(true, false) => format!("{lens} · profile off"),
|
||||
(false, _) => format!("{lens} · no profile"),
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-8
|
||||
/// The header this session was opened from, where there was one.
|
||||
///
|
||||
/// `None` is a real answer and not a failure: a JPEG with no EXIF block, a
|
||||
/// file opened from bytes whose header would not parse, a session built in
|
||||
/// a test. An export from such a session writes only what `dr-export` says
|
||||
/// about itself, and in particular invents no capture date.
|
||||
pub fn source_metadata(&self) -> Option<&dr_decode::Metadata> {
|
||||
self.source_meta.as_ref()
|
||||
}
|
||||
|
||||
/// The displayed size, for sizing the viewport.
|
||||
///
|
||||
/// The *framed* size, not the sensor's: cropping and quarter turns change
|
||||
/// the aspect ratio, and a viewport sized to the sensor would letterbox a
|
||||
/// cropped image against the wrong shape.
|
||||
pub fn source_size(&self) -> (u32, u32) {
|
||||
let (w, h) = self.demosaiced.size();
|
||||
self.graph.output_size(w, h)
|
||||
}
|
||||
|
||||
/// How many shader pipelines have been compiled. Surfaced so the status
|
||||
/// strip can show that slider movement is not recompiling.
|
||||
pub fn compiled_pipelines(&self) -> usize {
|
||||
self.adjust.cached_pipelines()
|
||||
}
|
||||
|
||||
pub fn is_neutral(&self) -> bool {
|
||||
self.graph.is_neutral()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-6
|
||||
/// Lift this session's edit onto the clipboard.
|
||||
///
|
||||
/// Captured at full scope — framing included — because the decision about
|
||||
/// what travels is made when the preset is *applied*. Copying, then
|
||||
/// changing one's mind about the crop, must not mean copying again.
|
||||
/// TRACES: FR-DEV-3 | FR-CAT-8
|
||||
/// The local adjustment stack, for writing this image's edit back.
|
||||
///
|
||||
/// Beside `copy_settings` rather than part of it: that returns a `Preset`,
|
||||
/// which travels *between* photographs, and a mask must not — it is drawn
|
||||
/// against one frame and describes nothing on another. The save path takes
|
||||
/// both; the paste path takes only the preset.
|
||||
pub fn masks(&self) -> &dr_pipeline::mask::MaskStack {
|
||||
self.graph.masks()
|
||||
}
|
||||
|
||||
pub fn copy_settings(&self) -> Preset {
|
||||
Preset::capture(&self.graph)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-6
|
||||
/// Replace this session's edit within `scope`.
|
||||
///
|
||||
/// The panel must be rebuilt from [`Self::rows`] afterwards: a paste moves
|
||||
/// values the sliders are showing, and nothing here pushes them.
|
||||
pub fn apply_settings(&mut self, preset: &Preset, scope: Scope) {
|
||||
preset.apply(&mut self.graph, scope);
|
||||
// A paste is undoable, and is the action most in need of it: it
|
||||
// replaces everything in scope at once, so getting it wrong costs more
|
||||
// than any single control can.
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::PASTE));
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-8
|
||||
/// Load a stored edit, as read from this image's sidecar.
|
||||
///
|
||||
/// A replacement rather than an overlay — [`Version::apply`] resets first —
|
||||
/// so a version that stores nothing opens the photograph at its defaults
|
||||
/// rather than leaving the previous image's exposure standing. The file's
|
||||
/// orientation survives it, since that was never an edit.
|
||||
pub fn apply_version(&mut self, version: &dr_pipeline::Version) {
|
||||
// TRACES: FR-DEV-3f
|
||||
// The film, which `apply` cleared and could not restore: a sidecar
|
||||
// names a stock, and turning a name into tables needs the profile
|
||||
// database that `dr-pipeline` deliberately does not link. So it is
|
||||
// re-baked here, after the parameters, because the bake reads the
|
||||
// film's own exposure sliders and they have just arrived.
|
||||
let rebake = version.apply(&mut self.graph);
|
||||
self.pay_film_debt(&rebake);
|
||||
// The stored edit becomes the floor rather than a step. It is not
|
||||
// something the user did in this sitting, and an undo that reached
|
||||
// behind it would discard a previous session's work in one press —
|
||||
// then persist that on the way out, since saving is automatic.
|
||||
self.history.reset(&self.graph);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::develop::test_support::*;
|
||||
|
||||
/// A lookup needs all three of lens, focal length and aperture.
|
||||
///
|
||||
/// Not pedantry about missing fields: distortion is interpolated across a
|
||||
/// zoom's focal range and vignetting depends strongly on aperture, so a
|
||||
/// lookup done without them would return coefficients measured for a shot
|
||||
/// nobody took and apply them with full confidence. Refusing is the honest
|
||||
/// answer, and the panel says so.
|
||||
#[test]
|
||||
fn a_lookup_needs_the_whole_shot_and_not_just_the_lens() {
|
||||
let complete = dr_decode::Metadata {
|
||||
lens: Some("Nikon AF-S 50mm f/1.8G".into()),
|
||||
focal_length: Some(50.0),
|
||||
aperture: Some(1.8),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for (name, meta) in [
|
||||
(
|
||||
"no lens",
|
||||
dr_decode::Metadata {
|
||||
lens: None,
|
||||
..complete.clone()
|
||||
},
|
||||
),
|
||||
(
|
||||
"no focal length",
|
||||
dr_decode::Metadata {
|
||||
focal_length: None,
|
||||
..complete.clone()
|
||||
},
|
||||
),
|
||||
(
|
||||
"no aperture",
|
||||
dr_decode::Metadata {
|
||||
aperture: None,
|
||||
..complete.clone()
|
||||
},
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
DevelopSession::profile_for(&meta).is_none(),
|
||||
"{name}: a partial header must not produce a confident profile"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Not recorded" and "no profile" are different facts.
|
||||
///
|
||||
/// Collapsing them would send someone hunting for a missing profile when
|
||||
/// the file simply never said what took the photograph — and `dr-lens`'s
|
||||
/// own rule is that the interface must be plain about which it is, because
|
||||
/// a correction that silently did nothing is worse than one visibly
|
||||
/// unavailable.
|
||||
#[test]
|
||||
fn the_lens_line_says_which_kind_of_nothing_it_found() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||||
|
||||
let session = |meta: dr_decode::Metadata| {
|
||||
let mut s = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
s.set_source_metadata(meta);
|
||||
s
|
||||
};
|
||||
|
||||
// A session that was never given a header at all.
|
||||
let bare = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
assert_eq!(bare.lens_summary(), "");
|
||||
|
||||
let unrecorded = session(dr_decode::Metadata::default());
|
||||
assert_eq!(unrecorded.lens_summary(), "Lens not recorded");
|
||||
|
||||
// A name no database will match. Deliberately absurd rather than a real
|
||||
// obscure lens, so the test cannot start passing for the wrong reason
|
||||
// if the bundled database grows.
|
||||
let unmatched = session(dr_decode::Metadata {
|
||||
lens: Some("Nonexistent 999mm f/0.5".into()),
|
||||
focal_length: Some(999.0),
|
||||
aperture: Some(0.5),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
unmatched.lens_summary(),
|
||||
"Nonexistent 999mm f/0.5 · no profile"
|
||||
);
|
||||
assert!(
|
||||
unmatched.graph.lens_profile().is_none(),
|
||||
"an unmatched lens must leave the corrections alone"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// A profile switched off is a third answer, and has to read as one.
|
||||
///
|
||||
/// "No profile" sends a photographer looking for a lens the database does
|
||||
/// not have. If the profile is sitting in the panel with its box unticked,
|
||||
/// that is a different sentence, and the line has to say which.
|
||||
///
|
||||
/// Depends on the bundled database holding a common lens, exactly as
|
||||
/// `dr_lens`'s own tests do — this is the only path that sets
|
||||
/// `lens_profile_found`, and standing a profile up by hand would test the
|
||||
/// formatting while skipping the lookup it is reporting on.
|
||||
#[test]
|
||||
fn the_lens_line_separates_a_declined_profile_from_a_missing_one() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
|
||||
const LENS: &str = "Canon EF 16-35mm f/2.8L USM";
|
||||
session.set_source_metadata(dr_decode::Metadata {
|
||||
lens: Some(LENS.into()),
|
||||
focal_length: Some(20.0),
|
||||
aperture: Some(2.8),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(session.lens_summary(), format!("{LENS} · corrected"));
|
||||
|
||||
session.graph.set_lens_profile_applied(false);
|
||||
assert_eq!(session.lens_summary(), format!("{LENS} · profile off"));
|
||||
|
||||
session.graph.set_lens_profile_applied(true);
|
||||
assert_eq!(session.lens_summary(), format!("{LENS} · corrected"));
|
||||
}
|
||||
|
||||
/// Opening a second photograph must not correct it for the first one's lens.
|
||||
///
|
||||
/// The clearing case, and the reason `apply_lens_profile` runs on every
|
||||
/// header rather than only on the ones that match something. A stale
|
||||
/// profile is invisible: the picture is simply wrong in a way that looks
|
||||
/// like the lens.
|
||||
#[test]
|
||||
fn a_second_photograph_does_not_inherit_the_first_lens_profile() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||||
let mut session =
|
||||
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
|
||||
// Stand in for a matched lens by applying a profile directly, so the
|
||||
// test does not depend on what the bundled database happens to hold.
|
||||
session
|
||||
.graph
|
||||
.set_lens_profile(Some(dr_pipeline::LensProfile {
|
||||
distortion: Some(dr_pipeline::ops::distortion::PtLens {
|
||||
a: 0.0,
|
||||
b: -0.02,
|
||||
c: 0.0,
|
||||
}),
|
||||
tca: None,
|
||||
vignetting: None,
|
||||
}));
|
||||
assert!(session.graph.lens_profile().is_some());
|
||||
|
||||
session.set_source_metadata(dr_decode::Metadata::default());
|
||||
|
||||
assert!(
|
||||
session.graph.lens_profile().is_none(),
|
||||
"a header naming no lens must clear the previous photograph's \
|
||||
correction, not leave it standing"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! The attribute tab strip (ARCH §4.3a, FR-DEV-3a): which tab is active and
|
||||
//! which capabilities it narrows the panel to.
|
||||
#[cfg(test)]
|
||||
use dr_gpu::GpuContext;
|
||||
use dr_pipeline::mask::MaskLayer;
|
||||
use dr_pipeline::OpCapability;
|
||||
|
||||
use crate::ParamRow;
|
||||
|
||||
use super::rows::rows_filtered;
|
||||
use super::session::DevelopSession;
|
||||
|
||||
impl DevelopSession {
|
||||
/// The controls the interface should show.
|
||||
///
|
||||
/// Built entirely from the capability list. The `kind` string chooses the
|
||||
/// widget; nothing switches on a parameter's identity.
|
||||
pub fn rows(&self) -> Vec<ParamRow> {
|
||||
let caps = self.scoped_capabilities();
|
||||
match self.active_tab {
|
||||
Some(attribute) => rows_filtered(
|
||||
&caps,
|
||||
|op| op.attributes.contains(&attribute),
|
||||
self.curve_channel,
|
||||
),
|
||||
None => rows_filtered(&caps, |_| true, self.curve_channel),
|
||||
}
|
||||
}
|
||||
|
||||
/// The capability list the panel is currently describing.
|
||||
///
|
||||
/// A selected mask layer takes over the panel, so every control the global
|
||||
/// chain offers is offered on a layer too — including operations added
|
||||
/// later, which need no work to become local.
|
||||
pub(super) fn scoped_capabilities(&self) -> Vec<OpCapability> {
|
||||
match self.active_layer() {
|
||||
Some(layer) => layer.capabilities(),
|
||||
None => self.graph.capabilities(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The attributes worth offering as tabs, in declaration order.
|
||||
///
|
||||
/// **Derived from the chain, never listed here.** The groups are whatever
|
||||
/// the operations say they are about, so a new operation joins the right
|
||||
/// tab by declaring its nature and this file goes on naming none of them
|
||||
/// (FR-DEV-3a). An attribute nothing carries is left out rather than
|
||||
/// offered as a tab that opens onto nothing.
|
||||
///
|
||||
/// Compose is excluded: its one operation prefers an on-canvas widget and
|
||||
/// is skipped by the row builder, so a Compose tab would be empty of rows
|
||||
/// while `ComposePanel` holds the real controls.
|
||||
pub fn tabs(&self) -> Vec<(dr_pipeline::Attribute, String)> {
|
||||
use dr_pipeline::Attribute;
|
||||
let caps = self.scoped_capabilities();
|
||||
Attribute::ALL
|
||||
.into_iter()
|
||||
.filter(|a| *a != Attribute::Compose)
|
||||
.filter(|a| {
|
||||
caps.iter().any(|c| {
|
||||
c.attributes.contains(a)
|
||||
&& !rows_filtered(&caps, |o| o.attributes.contains(a), 0).is_empty()
|
||||
})
|
||||
})
|
||||
.map(|a| (a, crate::labels::resolve(a.label().0)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Which tab is selected, as an index into [`Self::tabs`]. `-1` is "all".
|
||||
pub fn active_tab(&self) -> i32 {
|
||||
let Some(active) = self.active_tab else {
|
||||
return -1;
|
||||
};
|
||||
self.tabs()
|
||||
.iter()
|
||||
.position(|(a, _)| *a == active)
|
||||
.map_or(-1, |i| i as i32)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// Whether the film stock belongs in the group currently on screen.
|
||||
///
|
||||
/// The stock is not a parameter, so it is not a [`ParamRow`] and the tab
|
||||
/// filter that hides every other control never reached it: the picker was
|
||||
/// drawn above the rows in *every* group, so "Kodachrome" sat at the top of
|
||||
/// Light, of Colour and of Detail alike. Three places it does not belong,
|
||||
/// and the one it does was no more prominent than the rest.
|
||||
///
|
||||
/// Answered here rather than in the panel because it is a question about
|
||||
/// the operation — what is this control *about* — and the panel is not
|
||||
/// allowed to know. It asks the descriptor, so a stock that were ever
|
||||
/// re-declared as something other than an effect would move on its own.
|
||||
pub fn film_in_group(&self) -> bool {
|
||||
let Some(active) = self.active_tab else {
|
||||
// "All" shows everything, the stock included.
|
||||
return true;
|
||||
};
|
||||
self.graph
|
||||
.capabilities()
|
||||
.iter()
|
||||
.find(|c| c.id == dr_pipeline::ops::film_sim::ID)
|
||||
.is_some_and(|c| c.attributes.contains(&active))
|
||||
}
|
||||
|
||||
/// Select a tab by its index in [`Self::tabs`], or `-1` for all.
|
||||
pub fn set_active_tab(&mut self, index: i32) {
|
||||
self.active_tab = usize::try_from(index)
|
||||
.ok()
|
||||
.and_then(|i| self.tabs().get(i).map(|(a, _)| *a));
|
||||
}
|
||||
|
||||
/// The selection's representative layer, for anything that can only show
|
||||
/// one answer — which tab is open, what value a slider currently reads.
|
||||
///
|
||||
/// The first id selected, not "the" active layer: with more than one
|
||||
/// selected there is no single truth to show, and the panel has to pick
|
||||
/// something. Whichever layer this is, [`Self::set_param`] and
|
||||
/// [`Self::reset_op`] still write to every selected layer, not just this
|
||||
/// one — a slider shows one number and applies it everywhere selected.
|
||||
pub(super) fn active_layer(&self) -> Option<&MaskLayer> {
|
||||
let id = self.active_masks.first()?;
|
||||
self.graph.masks().get(id)
|
||||
}
|
||||
|
||||
/// Every selected layer, mutably — what a batched slider or reset walks.
|
||||
pub(super) fn active_layers_mut(&mut self) -> impl Iterator<Item = &mut MaskLayer> {
|
||||
let selected = self.active_masks.clone();
|
||||
self.graph
|
||||
.masks_mut()
|
||||
.layers_mut()
|
||||
.iter_mut()
|
||||
.filter(move |l| selected.contains(&l.id))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::develop::test_support::*;
|
||||
|
||||
/// Every attribute the chain actually carries must reach the tab strip.
|
||||
///
|
||||
/// The strip is generated, so an attribute with operations behind it and
|
||||
/// no tab in front of it is unreachable — the controls exist, are in the
|
||||
/// shader, and cannot be filtered to. That is exactly how `Optics` sat
|
||||
/// invisible for as long as it had no operations, and the failure looks
|
||||
/// identical from the outside whether the cause is an empty category or a
|
||||
/// broken filter.
|
||||
#[test]
|
||||
fn every_attribute_with_operations_gets_a_tab() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||||
let session = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
|
||||
let tabs: Vec<dr_pipeline::Attribute> =
|
||||
session.tabs().into_iter().map(|(a, _)| a).collect();
|
||||
|
||||
for attribute in dr_pipeline::Attribute::ALL {
|
||||
// Compose is deliberately absent: its one stage prefers an
|
||||
// on-canvas widget, so a Compose tab would open onto nothing while
|
||||
// `ComposePanel` holds the real controls.
|
||||
if attribute == dr_pipeline::Attribute::Compose {
|
||||
continue;
|
||||
}
|
||||
let carried = dr_pipeline::EditGraph::default_chain()
|
||||
.capabilities()
|
||||
.iter()
|
||||
.any(|c| c.attributes.contains(&attribute) && !c.params.is_empty());
|
||||
assert_eq!(
|
||||
tabs.contains(&attribute),
|
||||
carried,
|
||||
"{attribute:?}: carried by the chain = {carried}, has a tab = {}",
|
||||
tabs.contains(&attribute)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Group tabs (ARCH §4.3a, FR-DEV-3a)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
fn tabbed_session(ctx: &GpuContext) -> DevelopSession {
|
||||
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
||||
DevelopSession::open_rgb(ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||||
.expect("session")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tabs_come_from_the_chain_not_from_a_list() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let session = tabbed_session(&ctx);
|
||||
|
||||
let names: Vec<String> = session.tabs().into_iter().map(|(_, n)| n).collect();
|
||||
assert!(names.contains(&"Light".to_string()), "got {names:?}");
|
||||
assert!(names.contains(&"Colour".to_string()), "got {names:?}");
|
||||
// Nothing offers a group with no rows behind it.
|
||||
for (attribute, name) in session.tabs() {
|
||||
let mut probe = tabbed_session(&ctx);
|
||||
let index = probe
|
||||
.tabs()
|
||||
.iter()
|
||||
.position(|(a, _)| *a == attribute)
|
||||
.expect("just listed");
|
||||
probe.set_active_tab(index as i32);
|
||||
assert!(!probe.rows().is_empty(), "tab {name} opens onto nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choosing_a_tab_narrows_the_panel() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let mut session = tabbed_session(&ctx);
|
||||
|
||||
let all = session.rows().len();
|
||||
session.set_active_tab(0);
|
||||
let narrowed = session.rows().len();
|
||||
|
||||
assert!(narrowed > 0, "a tab must show something");
|
||||
assert!(
|
||||
narrowed < all,
|
||||
"and less than everything: {narrowed} of {all}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The trap: `op_index` counts over *every* capability, so a row that
|
||||
/// survived a filter must still route to the operation it came from. If it
|
||||
/// renumbered, a slider would drive a different operation once a tab was
|
||||
/// chosen.
|
||||
#[test]
|
||||
fn a_filtered_row_still_drives_its_own_operation() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let mut session = tabbed_session(&ctx);
|
||||
|
||||
// Find a colour row while unfiltered, and remember where it points.
|
||||
let colour = session
|
||||
.tabs()
|
||||
.iter()
|
||||
.position(|(_, n)| n == "Colour")
|
||||
.expect("the chain has colour operations");
|
||||
session.set_active_tab(colour as i32);
|
||||
|
||||
let row = session.rows().into_iter().next().expect("a row");
|
||||
let (op, param) = (row.op_index, row.param_index);
|
||||
|
||||
session.set_param(op, param, 0.5);
|
||||
let after = session
|
||||
.rows()
|
||||
.into_iter()
|
||||
.find(|r| r.op_index == op && r.param_index == param)
|
||||
.expect("the row survived");
|
||||
|
||||
assert!(
|
||||
(after.value - 0.5).abs() < 1e-5,
|
||||
"the value landed on the row that asked for it, not another"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_is_reachable_again() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let mut session = tabbed_session(&ctx);
|
||||
let all = session.rows().len();
|
||||
|
||||
session.set_active_tab(0);
|
||||
assert!(session.rows().len() < all);
|
||||
|
||||
session.set_active_tab(-1);
|
||||
assert_eq!(session.rows().len(), all, "-1 means everything");
|
||||
assert_eq!(session.active_tab(), -1);
|
||||
}
|
||||
|
||||
/// An out-of-range index is navigation nonsense, not an edit; it must not
|
||||
/// leave the panel showing nothing.
|
||||
#[test]
|
||||
fn a_nonsense_tab_falls_back_to_everything() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let mut session = tabbed_session(&ctx);
|
||||
let all = session.rows().len();
|
||||
|
||||
session.set_active_tab(99);
|
||||
assert_eq!(session.rows().len(), all);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
//! Setting the white balance from a point on the photograph.
|
||||
#[cfg(test)]
|
||||
use dr_decode::RawImage;
|
||||
use dr_pipeline::Edit;
|
||||
|
||||
use crate::labels;
|
||||
|
||||
use super::session::DevelopSession;
|
||||
|
||||
impl DevelopSession {
|
||||
/// TRACES: FR-DEV-3 | FR-DEV-5
|
||||
/// Set the white balance from a point on the photograph.
|
||||
///
|
||||
/// `x` and `y` are fractions of the *visible* image — the coordinates a
|
||||
/// click on the canvas arrives in — so a photographer inspecting a
|
||||
/// highlight at 4× samples the pixel they are actually looking at.
|
||||
///
|
||||
/// **Nothing here knows it is white balance.** The colour goes to
|
||||
/// [`dr_pipeline::neutral`], which finds the operation that asked to be
|
||||
/// driven by a pixel and inverts its declared response; this side supplies
|
||||
/// the pixel and the undo step and nothing else. That is FR-DEV-3a's line
|
||||
/// in its awkward case: a picker genuinely needs to know how far a hundred
|
||||
/// units of temperature move red against blue, and that number is declared
|
||||
/// in the node's own file, so the interface must not be the thing that
|
||||
/// holds a second copy of it.
|
||||
///
|
||||
/// **One step per sample**, and no step at all for a sample that could not
|
||||
/// be used — a point in the deep shadows has no balance in it to correct.
|
||||
/// `Edit::Action` never coalesces, so two clicks are two decisions
|
||||
/// however quickly they follow each other, which is what a photographer
|
||||
/// trying a wall and then a cloud expects to be able to undo one at a
|
||||
/// time.
|
||||
///
|
||||
/// Returns whether the photograph moved.
|
||||
pub fn sample_neutral(&mut self, x: f32, y: f32) -> bool {
|
||||
let Some(sample) = self.sample_as_shot(x, y) else {
|
||||
return false;
|
||||
};
|
||||
if !dr_pipeline::neutral::neutralise(&mut self.graph, sample) {
|
||||
return false;
|
||||
}
|
||||
self.history
|
||||
.record(&self.graph, Edit::Action(labels::step::SAMPLED_NEUTRAL));
|
||||
true
|
||||
}
|
||||
|
||||
/// The colour at a point in the space the white balance gains multiply:
|
||||
/// camera RGB with the camera's own balance on, linear, nothing else.
|
||||
///
|
||||
/// **Measured where the operation acts, not where the photographer
|
||||
/// looks.** The white balance node runs first in the chain, on camera
|
||||
/// RGB, before the body's base curve and its matrix; the canvas shows
|
||||
/// the pixel after all three. The probe used to be read off a display
|
||||
/// render with the adjustments stripped, and the solve then treated an
|
||||
/// sRGB triple as if the gains multiplied it directly. On a JPEG the two
|
||||
/// spaces coincide, so it worked; on a raw file from any real body the
|
||||
/// matrix mixes the channels, and a slightly blue wall on a Canon 6D
|
||||
/// came back tint −77 with the whole frame green. This reads the
|
||||
/// camera-space tap a merge stitches from — the sensor's numbers after
|
||||
/// the lens warp — and puts the as-shot balance on itself, which is
|
||||
/// exactly the value the operation's gains are about to multiply.
|
||||
///
|
||||
/// That also means nothing has to be stripped and restored: the tap
|
||||
/// runs no operations at all, and the display target is untouched, so
|
||||
/// a sample that found nothing usable leaves the canvas exactly as it
|
||||
/// was.
|
||||
///
|
||||
/// The framing is the edit's own, exactly as for [`Self::render_original`]
|
||||
/// and for the same reason: `x` and `y` are fractions of what is on
|
||||
/// screen, and a probe rendered without the crop and the zoom would be
|
||||
/// answering about a different part of the photograph.
|
||||
///
|
||||
/// **A patch, not a point.** The shader fetches the source at one
|
||||
/// position per output pixel — nearest, or four photosites blended — so
|
||||
/// a probe of the whole visible region rendered at 192px was not
|
||||
/// "averaging a neighbourhood into each pixel" as its comment claimed;
|
||||
/// it was one point sample of a noisy sensor, and two painted-white air
|
||||
/// conditioners on the same wall answered +37 and −50. Every eyedropper
|
||||
/// averages for exactly this reason: the photographer is pointing at a
|
||||
/// grey card, not at a photosite. So the tap is narrowed to the
|
||||
/// [`PATCH`] of the canvas around the click — a couple of percent of
|
||||
/// its width, square on screen — and rendered at [`PROBE_PX`] square
|
||||
/// with interpolation on, which puts a sample on every sensor pixel
|
||||
/// under the patch at any ordinary zoom. Those are averaged; a sample
|
||||
/// the tap marked void (outside the frame after the lens correction) or
|
||||
/// clipped is left out rather than allowed to pull the mean, and if
|
||||
/// fewer than half the patch survives there was nothing there to
|
||||
/// balance against. One small dispatch and a 64 KB readback on a click.
|
||||
pub(super) fn sample_as_shot(&mut self, x: f32, y: f32) -> Option<[f32; 3]> {
|
||||
/// Width of the patch as a fraction of what is on the canvas.
|
||||
const PATCH: f32 = 0.015;
|
||||
/// Side of the probe render, in pixels.
|
||||
const PROBE_PX: u32 = 64;
|
||||
|
||||
// Square on screen: the height fraction follows the aspect of the
|
||||
// visible region, which is the crop's shape times the view's.
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
let (cw, ch) = self.graph.output_size(sw, sh);
|
||||
let view = self.graph.framing().view();
|
||||
let aspect = (cw as f32 * view.width) / (ch as f32 * view.height).max(f32::EPSILON);
|
||||
let (pw, ph) = (PATCH, PATCH * aspect);
|
||||
let patch = dr_pipeline::CropRect {
|
||||
x: x.clamp(0.0, 1.0) - pw * 0.5,
|
||||
y: y.clamp(0.0, 1.0) - ph * 0.5,
|
||||
width: pw,
|
||||
height: ph,
|
||||
};
|
||||
|
||||
let shader = self.graph.compose_camera_probe(patch);
|
||||
let rendered = self
|
||||
.adjust
|
||||
.render_camera_linear(&self.demosaiced, &shader, PROBE_PX, PROBE_PX)
|
||||
.map(|_| ());
|
||||
let (rgba, _, _) = rendered
|
||||
.and_then(|()| self.adjust.read_camera_linear())
|
||||
.inspect_err(|e| log::warn!("could not read a neutral off the frame: {e}"))
|
||||
.ok()?;
|
||||
|
||||
let mut sum = [0.0f32; 3];
|
||||
let mut kept = 0usize;
|
||||
let mut seen = 0usize;
|
||||
for pixel in rgba.chunks_exact(4) {
|
||||
seen += 1;
|
||||
// The tap marks a pixel the lens correction pulled in from
|
||||
// outside the frame with alpha 0. There is nothing there to
|
||||
// balance against.
|
||||
if pixel[3] < 0.5 {
|
||||
continue;
|
||||
}
|
||||
// Nor in a clipped one. A blown sky reads as sensor white, and
|
||||
// sensor white with the as-shot balance on is strongly magenta —
|
||||
// a solve over it drives tint to its stop for a pixel that, on
|
||||
// the canvas, the shader has already desaturated to neutral. The
|
||||
// same threshold the shader fades from, so what is refused here
|
||||
// is what it would have hidden there.
|
||||
if pixel[..3].iter().any(|c| *c >= dr_pipeline::CLIP_ONSET) {
|
||||
continue;
|
||||
}
|
||||
for (acc, c) in sum.iter_mut().zip(pixel) {
|
||||
*acc += c;
|
||||
}
|
||||
kept += 1;
|
||||
}
|
||||
if kept == 0 || kept * 2 < seen {
|
||||
return None;
|
||||
}
|
||||
|
||||
// The tap is the sensor's numbers with the profile filled neutral;
|
||||
// the operation multiplies them *after* the camera's own balance, so
|
||||
// that goes on here and the solve sees what the gains will see.
|
||||
let wb = self.demosaiced.as_shot_wb();
|
||||
let n = kept as f32;
|
||||
Some([sum[0] / n * wb[0], sum[1] / n * wb[1], sum[2] / n * wb[2]])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::develop::test_support::*;
|
||||
|
||||
/// TRACES: FR-DEV-3 | FR-DEV-5
|
||||
/// Sampling something that is already neutral corrects nothing, and says
|
||||
/// so by leaving the stack alone.
|
||||
///
|
||||
/// The failure this guards is a picker that lands a ten-thousandth off
|
||||
/// zero: the photograph would come back marked modified, an undo step
|
||||
/// would appear for a correction of nothing, and the sidecar would gain a
|
||||
/// temperature the photographer never chose. The solve rounds to the
|
||||
/// precision the control is drawn at, which is what makes "no correction"
|
||||
/// representable at all.
|
||||
#[test]
|
||||
fn sampling_a_grey_that_is_already_grey_leaves_the_photograph_alone() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let (mut session, _) = grey_session(&ctx);
|
||||
let steps = session.history_rows().len();
|
||||
|
||||
assert!(
|
||||
session.sample_neutral(0.5, 0.5),
|
||||
"a flat grey frame is a usable sample"
|
||||
);
|
||||
assert!(
|
||||
session.is_neutral(),
|
||||
"there was nothing to correct, so nothing was corrected"
|
||||
);
|
||||
assert_eq!(
|
||||
session.history_rows().len(),
|
||||
steps,
|
||||
"and a correction of nothing is not a step"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The whole point of the picker, measured where the photographer sees
|
||||
/// it: a cast grey on a *raw* frame, sampled, renders grey.
|
||||
///
|
||||
/// On a raw frame and not a JPEG, because that is where it was wrong. The
|
||||
/// white balance gains multiply camera RGB, before the body's matrix
|
||||
/// turns it into sRGB; the probe was read *after* the matrix, and the
|
||||
/// solve treated the two as the same space. On a body whose matrix mixes
|
||||
/// the channels as much as a Canon's does, a slightly blue wall came back
|
||||
/// tint −77 and the whole frame went green. A JPEG carries an identity
|
||||
/// matrix, so the same test on one passed while the picker was broken.
|
||||
#[test]
|
||||
fn sampling_a_cast_grey_on_a_raw_frame_renders_it_grey() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
|
||||
// A Canon EOS 6D's D65 matrix (rows summing to one, as
|
||||
// `neutral_stays_neutral_through_the_colour_matrix` requires) and a
|
||||
// typical as-shot balance for it.
|
||||
let cam_to_srgb = [
|
||||
1.9125, -1.0587, 0.1461, //
|
||||
-0.2249, 1.6466, -0.4217, //
|
||||
0.0099, -0.5093, 1.4994,
|
||||
];
|
||||
let as_shot = [1.9, 1.0, 1.7];
|
||||
// What the wall should look like once the camera's own balance is on:
|
||||
// a warm cast, a little over half a stop between red and blue.
|
||||
let balanced = [0.30f32, 0.25, 0.20];
|
||||
let sensor: Vec<u16> = (0..3)
|
||||
.map(|c| (balanced[c] / as_shot[c] * 65535.0).round() as u16)
|
||||
.collect();
|
||||
let size = 64u32;
|
||||
let raw = RawImage {
|
||||
width: size,
|
||||
height: size,
|
||||
data: sensor.repeat((size * size) as usize),
|
||||
cfa_pattern: dr_decode::CfaPattern::Rggb,
|
||||
black_level: [0; 4],
|
||||
white_level: 65535,
|
||||
wb_coeffs: [as_shot[0], as_shot[1], as_shot[2], 0.0],
|
||||
color_matrix: Some(cam_to_srgb),
|
||||
base_curve: dr_decode::BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 3,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: dr_decode::CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
};
|
||||
let mut session =
|
||||
DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session");
|
||||
|
||||
let at = ((size / 2) * size + size / 2) as usize * 4;
|
||||
let before = read_back(&ctx, &session.render(size, size).expect("render"));
|
||||
let cast = |px: &[u8]| px.iter().max().unwrap() - px.iter().min().unwrap();
|
||||
assert!(
|
||||
cast(&before[at..at + 3]) > 20,
|
||||
"the premise: the wall renders with a cast, {:?}",
|
||||
&before[at..at + 3]
|
||||
);
|
||||
|
||||
assert!(
|
||||
session.sample_neutral(0.5, 0.5),
|
||||
"a mid-grey is a usable sample"
|
||||
);
|
||||
|
||||
let after = read_back(&ctx, &session.render(size, size).expect("render"));
|
||||
let px = &after[at..at + 3];
|
||||
assert!(
|
||||
cast(px) <= 3,
|
||||
"the sampled point should render neutral, got {px:?} with {:?}",
|
||||
session
|
||||
.rows()
|
||||
.iter()
|
||||
.filter(|r| r.value != r.default_value)
|
||||
.map(|r| (r.param_label.to_string(), r.value))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The picker reads a patch, not a photosite.
|
||||
///
|
||||
/// A frame whose pixels alternate warm and cool grey, averaging to a
|
||||
/// neutral: a point sample lands on one or the other and swings the
|
||||
/// controls hard one way, which is what two white boxes on the same wall
|
||||
/// answering +37 and −50 looked like. Averaged, there is nothing to
|
||||
/// correct, and the graph says so.
|
||||
#[test]
|
||||
fn sampling_averages_a_patch_rather_than_reading_one_photosite() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
// Large enough that the patch — a couple of percent of the frame —
|
||||
// holds many sensor pixels; on a 64px frame it would hold one, and
|
||||
// the test would be asserting about interpolation instead.
|
||||
let size = 1536u32;
|
||||
let warm = [0.30f32, 0.25, 0.20];
|
||||
let cool = [0.20f32, 0.25, 0.30];
|
||||
let mut data = Vec::with_capacity((size * size * 3) as usize);
|
||||
for i in 0..(size * size) as usize {
|
||||
let p = if i % 2 == 0 { warm } else { cool };
|
||||
data.extend(p.iter().map(|c| (c * 65535.0).round() as u16));
|
||||
}
|
||||
let raw = RawImage {
|
||||
width: size,
|
||||
height: size,
|
||||
data,
|
||||
cfa_pattern: dr_decode::CfaPattern::Rggb,
|
||||
black_level: [0; 4],
|
||||
white_level: 65535,
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 0.0],
|
||||
color_matrix: None,
|
||||
base_curve: dr_decode::BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 3,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: dr_decode::CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
};
|
||||
let mut session =
|
||||
DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session");
|
||||
|
||||
assert!(
|
||||
session.sample_neutral(0.5, 0.5),
|
||||
"a mid-grey patch is usable"
|
||||
);
|
||||
let moved: Vec<_> = session
|
||||
.rows()
|
||||
.iter()
|
||||
.filter(|r| r.value != r.default_value)
|
||||
.map(|r| (r.param_label.to_string(), r.value))
|
||||
.collect();
|
||||
assert!(
|
||||
moved.iter().all(|(_, v)| v.abs() <= 2.0),
|
||||
"the patch averages neutral, so nothing should move far: {moved:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// A blown highlight is refused, the way black is.
|
||||
///
|
||||
/// Sensor white is not a colour: every channel stopped counting, so the
|
||||
/// ratio between them is the as-shot multipliers and nothing about the
|
||||
/// scene. Sampling the overcast sky on a Canon 6D frame drove tint to
|
||||
/// -100 and temperature to -15 for a patch the canvas showed as pure
|
||||
/// white, which is the picker being wrong rather than the point being a
|
||||
/// poor choice. Refused, nothing moves and no step is taken.
|
||||
#[test]
|
||||
fn sampling_a_blown_highlight_moves_nothing() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
let size = 16u32;
|
||||
let raw = RawImage {
|
||||
width: size,
|
||||
height: size,
|
||||
data: vec![65535; (size * size * 3) as usize],
|
||||
cfa_pattern: dr_decode::CfaPattern::Rggb,
|
||||
black_level: [0; 4],
|
||||
white_level: 65535,
|
||||
wb_coeffs: [1.9, 1.0, 1.7, 0.0],
|
||||
color_matrix: None,
|
||||
base_curve: dr_decode::BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 3,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: dr_decode::CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: size,
|
||||
height: size,
|
||||
},
|
||||
};
|
||||
let mut session =
|
||||
DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session");
|
||||
let steps = session.history_rows().len();
|
||||
|
||||
assert!(
|
||||
!session.sample_neutral(0.5, 0.5),
|
||||
"a clipped photosite has no balance in it"
|
||||
);
|
||||
assert!(session.is_neutral(), "and so nothing was corrected");
|
||||
assert_eq!(session.history_rows().len(), steps);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user