Let an operation read the pixel next to it, and settle where sharpening belongs
The fused pass hands a fragment a colour and no coordinate. That is what buys one dispatch for a whole edit, and it is also a wall: sharpening, noise reduction, clarity, texture, dehaze and spot removal are each defined by what the neighbours are doing, and FR-DEV-3 and FR-DEV-8 ask for all six. None of them could be written at any price. So there is now a detail stage. An operation implements `Operation` for its parameters exactly as before — the panel, the sidecar, the history and the presets all work unchanged — and additionally returns `Affects::Detail` and a `DetailStage` yielding one pass per dispatch. `Affects` grows the third variant `docs/requirements.md:250` designed and nothing had cut. Where the stage sits is a colour-science decision, not an arrangement of convenience. It runs after every point operation and every mask layer, so an amount chosen against a tone curve survives the curve moving; in linear sRGB after the camera matrix, because camera RGB has no luminance to sharpen against; and before the output transform and the clip, because FR-DEV-2 allows one quantisation and a highlight clipped before a convolution grows a dark ring. The fused pass therefore ends one of two ways, and when a detail stage follows it hands on unclipped f16 and the last detail pass encodes. At render resolution rather than on the source, which is the whole of FR-DSP-1: a pass before the framing prologue would cost 24 MP to draw a 2 MP preview. `RenderScale` is what makes that survivable — a radius is stored as a fraction of the frame's shorter edge, exactly as a mask feather already is, or as a count of source pixels, and converted per render. It also reports when a radius is smaller than a proxy pixel rather than drawing a plausible lie; zooming to 1:1 makes the preview exact with no second path. `Invalidation` gives FR-DEV-3d something to mean. Moving a detail parameter leaves the colour key alone, so `AdjustPass` keeps the linear intermediate and skips the fused dispatch: dragging a sharpening slider costs a convolution. Moving exposure does re-run the detail passes, because they read what the colour pass wrote, and there is no arrangement of keys that avoids it while keeping sharpening after tone. Validated by a separable box blur that is not a develop operation, behind the `detail-probe` feature and absent from a shipping build. An abstraction with no consumer is a guess; a box blur's answer is known in closed form, so the tests assert every byte of the ramp rather than that the edge got softer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -118,6 +118,28 @@ impl EditGraph {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// The default chain with the detail stage's test consumer appended.
|
||||
///
|
||||
/// **Not a shipping path.** `detail_probe` is a separable box blur that
|
||||
/// exists so the neighbourhood stage has something to run (see
|
||||
/// [`crate::detail::probe`]); it is not declared in `ops/`, has no place
|
||||
/// in the pipeline order, and is compiled only for tests and behind the
|
||||
/// `detail-probe` feature.
|
||||
///
|
||||
/// It is a constructor rather than a fixture inside one test module
|
||||
/// because `dr-gpu` needs the same graph: proving the stage works means
|
||||
/// dispatching it, and dispatching it means composing both halves of the
|
||||
/// shader from one graph exactly as the interface will.
|
||||
#[cfg(any(test, feature = "detail-probe"))]
|
||||
pub fn with_detail_probe() -> Self {
|
||||
let mut graph = Self::default_chain();
|
||||
graph
|
||||
.ops
|
||||
.push(Box::new(crate::detail::probe::BoxBlur::new()));
|
||||
graph
|
||||
}
|
||||
|
||||
/// The local adjustment stack.
|
||||
pub fn masks(&self) -> &MaskStack {
|
||||
&self.masks
|
||||
@@ -342,6 +364,130 @@ impl EditGraph {
|
||||
pub fn compose_for(&self, output: dr_types::ColourSpace) -> ComposedShader {
|
||||
compose_full(&self.ops, &self.framing, output, &self.masks)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-1
|
||||
/// How this render relates to the file it stands for.
|
||||
///
|
||||
/// `source` is the demosaiced image's size and `render` the size being
|
||||
/// drawn now. The result describes *the region on screen*, with the crop
|
||||
/// and the zoom already folded in: cropping to half the frame while the
|
||||
/// viewport stays the same size genuinely does show twice the detail, and
|
||||
/// zooming to 1:1 genuinely does make the preview exact. Both fall out of
|
||||
/// the arithmetic rather than needing a special case.
|
||||
///
|
||||
/// Only the detail stage needs this. Every point operation is scale-free
|
||||
/// — a multiply is a multiply at any resolution — which is why nothing in
|
||||
/// the pipeline had to know its own size until a kernel arrived.
|
||||
pub fn render_scale(&self, source: (u32, u32), render: (u32, u32)) -> crate::detail::RenderScale {
|
||||
let (fw, fh) = self.framing.output_size(source.0, source.1);
|
||||
let view = self.framing.view();
|
||||
// The *viewed* part of the framed image, at source resolution. Zoom
|
||||
// shrinks the view rect while the render target keeps its size, so
|
||||
// this is what shrinks and the ratio is what climbs.
|
||||
let full = (
|
||||
((fw as f32 * view.width).round() as u32).max(1),
|
||||
((fh as f32 * view.height).round() as u32).max(1),
|
||||
);
|
||||
crate::detail::RenderScale::new(render, full)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3 | FR-DSP-1
|
||||
/// Generate the detail stage for this edit at one resolution, to sRGB.
|
||||
///
|
||||
/// Empty for every edit with no active neighbourhood operation, which is
|
||||
/// almost all of them — and in that case [`Self::compose`] emits the
|
||||
/// single encoded dispatch it always has.
|
||||
pub fn compose_detail(&self, scale: crate::detail::RenderScale) -> crate::detail::ComposedDetail {
|
||||
self.compose_detail_for(scale, dr_types::ColourSpace::Srgb)
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-2
|
||||
/// The detail stage, encoded into a chosen output space.
|
||||
///
|
||||
/// The space belongs here as well as on [`Self::compose_for`] because when
|
||||
/// a detail stage exists it is the *last* pass that performs the output
|
||||
/// transform — the fused pass stops at linear working values. Composing
|
||||
/// the two halves for different spaces would encode the edit twice, or
|
||||
/// not at all.
|
||||
pub fn compose_detail_for(
|
||||
&self,
|
||||
scale: crate::detail::RenderScale,
|
||||
output: dr_types::ColourSpace,
|
||||
) -> crate::detail::ComposedDetail {
|
||||
crate::detail::compose_detail(&self.ops, scale, output)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3d
|
||||
/// The per-stage cache keys for the current edit.
|
||||
///
|
||||
/// See [`crate::Invalidation`] for what the keys mean and what may be
|
||||
/// cached against them. In short: geometry covers the framing, colour
|
||||
/// covers every fused operation and every mask layer, and detail covers
|
||||
/// the neighbourhood operations — so moving one slider moves exactly one
|
||||
/// key, and a consumer can tell which stages it has to redo.
|
||||
pub fn invalidation(&self) -> crate::Invalidation {
|
||||
use crate::operation::{hash_bytes, hash_op, mix, Affects, FNV_OFFSET};
|
||||
|
||||
// Geometry: the framing. Its own structure key covers the shape of the
|
||||
// coordinate map; the parameters cover the magnitudes, which the
|
||||
// structure key deliberately omits because they do not recompile a
|
||||
// shader. Both matter to a cached *result*, so both are here.
|
||||
let mut geometry = mix(FNV_OFFSET, self.framing.structure_key());
|
||||
for p in self.framing.descriptor().params {
|
||||
geometry = hash_bytes(geometry, p.id.0.as_bytes());
|
||||
geometry = mix(
|
||||
geometry,
|
||||
u64::from(crate::operation::canonical_bits(self.framing.param(p.id))),
|
||||
);
|
||||
}
|
||||
// The view rect is not a parameter and not in the structure key — it
|
||||
// is not an edit (see `Framing::view`). It is still an input to every
|
||||
// rendered pixel, so a cache that ignored it would show the wrong part
|
||||
// of the photograph after a scroll.
|
||||
let view = self.framing.view();
|
||||
for v in [view.x, view.y, view.width, view.height] {
|
||||
geometry = mix(geometry, u64::from(crate::operation::canonical_bits(v)));
|
||||
}
|
||||
|
||||
let mut colour = FNV_OFFSET;
|
||||
let mut detail = FNV_OFFSET;
|
||||
for op in &self.ops {
|
||||
let target = if op.affects() == Affects::Detail {
|
||||
&mut detail
|
||||
} else {
|
||||
&mut colour
|
||||
};
|
||||
*target = hash_op(*target, op.as_ref());
|
||||
}
|
||||
|
||||
// The mask layers belong to the colour stage: their chains are fused
|
||||
// into the same dispatch, and a layer's *shape* decides which pixels
|
||||
// that dispatch treats differently. Both halves are folded in.
|
||||
for layer in self.masks.layers() {
|
||||
colour = hash_bytes(colour, layer.id.as_bytes());
|
||||
// The source through its `Debug`, deliberately. A gradient's
|
||||
// centre, a region's id list and a subject's signature are all
|
||||
// part of where the layer applies, and matching on the variants
|
||||
// here would be a second copy of `MaskSource`'s shape that falls
|
||||
// out of step the first time a variant gains a field — silently,
|
||||
// and showing as a mask that stops updating. `Debug` cannot fall
|
||||
// out of step, because it is derived from the definition itself.
|
||||
colour = hash_bytes(colour, format!("{:?}", layer.source).as_bytes());
|
||||
colour = mix(colour, u64::from(layer.enabled));
|
||||
colour = mix(colour, u64::from(layer.invert));
|
||||
colour = hash_bytes(colour, layer.falloff.name().as_bytes());
|
||||
for v in [layer.opacity, layer.feather, layer.morph_radius] {
|
||||
colour = mix(colour, u64::from(crate::operation::canonical_bits(v)));
|
||||
}
|
||||
for (op_id, param_id, value) in layer.params() {
|
||||
colour = hash_bytes(colour, op_id.as_bytes());
|
||||
colour = hash_bytes(colour, param_id.as_bytes());
|
||||
colour = mix(colour, u64::from(crate::operation::canonical_bits(value)));
|
||||
}
|
||||
}
|
||||
|
||||
crate::Invalidation::new(geometry, colour, detail)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EditGraph {
|
||||
@@ -645,4 +791,220 @@ mod tests {
|
||||
);
|
||||
assert_ne!(before, g.compose().structure_hash);
|
||||
}
|
||||
|
||||
// ---- invalidation scoping (FR-DEV-3d) --------------------------------
|
||||
|
||||
use crate::descriptor::OpId;
|
||||
use crate::operation::Affects;
|
||||
|
||||
const PROBE: OpId = OpId("detail_probe");
|
||||
const PROBE_RADIUS: ParamId = ParamId("radius");
|
||||
|
||||
#[test]
|
||||
fn moving_a_detail_parameter_leaves_every_earlier_stage_alone() {
|
||||
// FR-DEV-3d's headline, and the thing `Affects::Detail` was added to
|
||||
// make true: dragging a sharpening slider must not re-run the
|
||||
// demosaic, the framing, or the fused colour pass. The demosaic is not
|
||||
// a key here at all — no parameter in this graph can reach it — and
|
||||
// the other two must come out unchanged.
|
||||
let mut g = EditGraph::with_detail_probe();
|
||||
let before = g.invalidation();
|
||||
|
||||
g.set_param(PROBE, PROBE_RADIUS, 0.05);
|
||||
let after = g.invalidation();
|
||||
|
||||
assert_ne!(
|
||||
before.of(Affects::Detail),
|
||||
after.of(Affects::Detail),
|
||||
"the detail stage's own key must move"
|
||||
);
|
||||
assert_eq!(
|
||||
before.through(Affects::Colour),
|
||||
after.through(Affects::Colour),
|
||||
"the fused colour pass's result is still valid, so its cached \
|
||||
linear intermediate must be reusable"
|
||||
);
|
||||
assert_eq!(
|
||||
before.through(Affects::Geometry),
|
||||
after.through(Affects::Geometry)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_colour_parameter_leaves_geometry_alone_and_redoes_detail() {
|
||||
// The other direction, and the half that is easy to get wrong by
|
||||
// wishing. Exposure does not touch the framing — FR-DEV-3d says so in
|
||||
// as many words. It *does* invalidate the detail stage's output,
|
||||
// because the detail stage reads what the colour pass wrote, and
|
||||
// pretending otherwise would show a sharpened version of the previous
|
||||
// exposure. The stage's own parameters are still untouched, which is
|
||||
// what `of` reports and `through` does not.
|
||||
let mut g = EditGraph::with_detail_probe();
|
||||
g.set_param(PROBE, PROBE_RADIUS, 0.05);
|
||||
let before = g.invalidation();
|
||||
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
||||
let after = g.invalidation();
|
||||
|
||||
assert_eq!(
|
||||
before.through(Affects::Geometry),
|
||||
after.through(Affects::Geometry),
|
||||
"adjusting exposure shall not re-tile geometry (FR-DEV-3d)"
|
||||
);
|
||||
assert_ne!(before.of(Affects::Colour), after.of(Affects::Colour));
|
||||
assert_eq!(
|
||||
before.of(Affects::Detail),
|
||||
after.of(Affects::Detail),
|
||||
"the sharpening settings did not change"
|
||||
);
|
||||
assert_ne!(
|
||||
before.through(Affects::Detail),
|
||||
after.through(Affects::Detail),
|
||||
"but its input did, so its cached output is stale"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cropping_invalidates_everything_downstream_of_it() {
|
||||
// Geometry is upstream of both other stages: it decides which source
|
||||
// pixel every colour is read from, and — because the detail stage runs
|
||||
// at render resolution — how many render pixels a kernel spans.
|
||||
let mut g = EditGraph::with_detail_probe();
|
||||
g.set_param(PROBE, PROBE_RADIUS, 0.05);
|
||||
let before = g.invalidation();
|
||||
|
||||
g.set_crop(CropRect {
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
let after = g.invalidation();
|
||||
|
||||
assert_ne!(before.of(Affects::Geometry), after.of(Affects::Geometry));
|
||||
assert_ne!(
|
||||
before.through(Affects::Colour),
|
||||
after.through(Affects::Colour)
|
||||
);
|
||||
assert_ne!(
|
||||
before.through(Affects::Detail),
|
||||
after.through(Affects::Detail)
|
||||
);
|
||||
// Scoped, though: neither later stage's *own* settings moved.
|
||||
assert_eq!(before.of(Affects::Colour), after.of(Affects::Colour));
|
||||
assert_eq!(before.of(Affects::Detail), after.of(Affects::Detail));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_the_view_invalidates_the_render_without_being_an_edit() {
|
||||
// The view rect is not an edit — it is excluded from the sidecar, the
|
||||
// structure hash and `is_active` — but it absolutely is an input to
|
||||
// every pixel. A key that ignored it would leave the previous part of
|
||||
// the photograph on screen after a pan, which looks like a repaint bug
|
||||
// and is a cache bug.
|
||||
let mut g = EditGraph::default_chain();
|
||||
let before = g.invalidation();
|
||||
g.framing_mut().set_view(CropRect {
|
||||
x: 0.25,
|
||||
y: 0.25,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
assert_ne!(
|
||||
before.of(Affects::Geometry),
|
||||
g.invalidation().of(Affects::Geometry)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returning_a_slider_to_where_it_was_returns_the_key() {
|
||||
// A cache key that drifted with the *path* rather than the state would
|
||||
// never hit after an undo, which is the moment it is most wanted.
|
||||
let mut g = EditGraph::with_detail_probe();
|
||||
let origin = g.invalidation();
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.5);
|
||||
g.set_param(PROBE, PROBE_RADIUS, 0.05);
|
||||
assert_ne!(origin, g.invalidation());
|
||||
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 0.0);
|
||||
g.set_param(PROBE, PROBE_RADIUS, 0.0);
|
||||
assert_eq!(origin, g.invalidation(), "the state is what is hashed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_adjustment_belongs_to_the_colour_stage() {
|
||||
// A mask layer's chain is fused into the same dispatch as the global
|
||||
// one, so changing it is a colour change and nothing more. Its
|
||||
// *shape* counts too: which pixels the dispatch treats differently is
|
||||
// as much a part of the result as by how much.
|
||||
use crate::mask::{MaskLayer, MaskSource};
|
||||
let mut g = EditGraph::with_detail_probe();
|
||||
let before = g.invalidation();
|
||||
|
||||
g.masks_mut().push(MaskLayer::new(
|
||||
"l1",
|
||||
MaskSource::Linear {
|
||||
centre: (0.5, 0.5),
|
||||
angle: 0.0,
|
||||
width: 0.2,
|
||||
},
|
||||
));
|
||||
let with_layer = g.invalidation();
|
||||
assert_ne!(before.of(Affects::Colour), with_layer.of(Affects::Colour));
|
||||
assert_eq!(
|
||||
before.of(Affects::Geometry),
|
||||
with_layer.of(Affects::Geometry)
|
||||
);
|
||||
assert_eq!(before.of(Affects::Detail), with_layer.of(Affects::Detail));
|
||||
|
||||
// Moving the gradient is a different mask, so a different result.
|
||||
if let Some(layer) = g.masks_mut().get_mut("l1") {
|
||||
layer.source = MaskSource::Linear {
|
||||
centre: (0.2, 0.7),
|
||||
angle: 0.4,
|
||||
width: 0.2,
|
||||
};
|
||||
}
|
||||
assert_ne!(
|
||||
with_layer.of(Affects::Colour),
|
||||
g.invalidation().of(Affects::Colour)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_render_scale_folds_in_the_crop_and_the_zoom() {
|
||||
// What a detail operation is handed, and the reason it does not need
|
||||
// to know that a crop or a zoom happened: both arrive already folded
|
||||
// into one ratio.
|
||||
let mut g = EditGraph::default_chain();
|
||||
let source = (6000, 4000);
|
||||
|
||||
// Fit: a 1500px panel over a 6000px frame is a quarter scale.
|
||||
let fit = g.render_scale(source, (1500, 1000));
|
||||
assert!((fit.ratio() - 0.25).abs() < 1e-3);
|
||||
|
||||
// Zoomed to 1:1 — the view rect shrinks to what the panel can hold,
|
||||
// the render target keeps its size, and the preview becomes exact.
|
||||
g.framing_mut().set_view(CropRect {
|
||||
x: 0.25,
|
||||
y: 0.25,
|
||||
width: 0.25,
|
||||
height: 0.25,
|
||||
});
|
||||
let one_to_one = g.render_scale(source, (1500, 1000));
|
||||
assert!((one_to_one.ratio() - 1.0).abs() < 1e-3);
|
||||
assert!(one_to_one.resolves(1.0));
|
||||
|
||||
// A crop shows fewer source pixels in the same panel, which is more
|
||||
// render pixels each — a sharpening radius genuinely does grow.
|
||||
let mut cropped = EditGraph::default_chain();
|
||||
cropped.set_crop(CropRect {
|
||||
x: 0.25,
|
||||
y: 0.25,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
let after = cropped.render_scale(source, (1500, 1000));
|
||||
assert!(after.ratio() > fit.ratio());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user