Develop every raw through the AI denoise by default, with a strength, cached

The learned demosaic was an option under Detail, off by default. It is
now how a Bayer raw is developed: on by default at full strength on
every device — which hardware runs it is the inference engine's choice
— and first in the Adjust panel, since it decides what every control
below is applied to.

Strength (0-100, default 100) replaces Keep grain: grain = 100 -
strength, the same luminance-only blend, so moving it is one GPU pass
and never a re-run. 0.21.0's sidecars stored grain; it is still read,
as the inverse, and never written.

With it on for every photograph, the result is now kept on disk
(denoise.md §7.1, §12): the network's output as half floats, keyed on
a SHA-256 of the file's bytes and the model, oldest first past a 5 GB
budget, beside the inference engine's cache. A reopened photograph and
an export of one already developed read it back instead of running the
network again; a damaged entry is a miss.
This commit is contained in:
2026-10-03 22:16:36 -04:00
parent ff0effbfe1
commit 2e7f14dafe
10 changed files with 397 additions and 51 deletions
+49 -7
View File
@@ -41,6 +41,9 @@ pub(crate) struct DenoiseState {
failed: Option<String>,
/// Where the noise figures came from, for the panel.
source: Option<dr_denoise::Source>,
/// The result's key in the on-disk cache: the file's bytes and the model
/// (`denoise_cache::key`). `None` where there is no model to key on.
cache_key: Option<String>,
}
struct Job {
@@ -89,6 +92,11 @@ impl DevelopSession {
if self.denoise.mosaic.is_some() {
self.denoise.profile = dr_decode::noise_profile(bytes);
self.denoise.iso = meta.iso;
// TRACES: FR-DEV-3g
// The bytes are only here now, so the cache key is made now: a
// reopened or exported photograph finds its result on disk.
self.denoise.cache_key = crate::library::denoise_model()
.map(|model| super::denoise_cache::key(bytes, &model));
}
}
@@ -125,6 +133,7 @@ impl DevelopSession {
iso: self.denoise.iso,
model,
cancel: cancel.clone(),
cache_key: self.denoise.cache_key.clone(),
};
crate::executors::spawn(crate::executors::Executor::Decode, "denoise", move || {
let result = work.run(&mut |done, total| {
@@ -192,6 +201,7 @@ impl DevelopSession {
iso: self.denoise.iso,
model,
cancel: Arc::new(AtomicBool::new(false)),
cache_key: self.denoise.cache_key.clone(),
};
let finished = work.run(&mut |_, _| {})?;
self.land(finished)
@@ -273,11 +283,30 @@ struct Work {
iso: Option<u32>,
model: std::path::PathBuf,
cancel: Arc<AtomicBool>,
cache_key: Option<String>,
}
impl Work {
fn run(self, progress: &mut dyn FnMut(usize, usize)) -> Result<Finished, String> {
let started = std::time::Instant::now();
// TRACES: FR-DEV-3g
// A result computed before — this photograph opened earlier, or
// developed and now exported — is read back rather than recomputed.
let cache_dir = super::denoise_cache::dir();
if let Some(hit) = self
.cache_key
.as_deref()
.and_then(|key| super::denoise_cache::load(&cache_dir, key))
{
return Ok(Finished {
rgb: hit.rgb,
width: hit.width,
height: hit.height,
source: hit.source,
rung: "the cache".into(),
seconds: started.elapsed().as_secs_f64(),
});
}
// The app's own hot-pixel pass, on a copy: the classical source was
// repaired by the same pass inside `Demosaicer::run`.
let mut raw = (*self.raw).clone();
@@ -299,14 +328,24 @@ impl Work {
})
.map_err(|e| e.to_string())?
.ok_or("stopped")?;
Ok(Finished {
let finished = Finished {
rgb,
width: raw.crop.width,
height: raw.crop.height,
source: noise.source,
rung,
seconds: started.elapsed().as_secs_f64(),
})
};
if let Some(key) = self.cache_key.as_deref() {
let entry = super::denoise_cache::Entry {
rgb: finished.rgb.clone(),
width: finished.width,
height: finished.height,
source: finished.source,
};
super::denoise_cache::store(&cache_dir, key, &entry, super::denoise_cache::BUDGET);
}
Ok(finished)
}
}
@@ -385,15 +424,18 @@ mod tests {
let result = s.denoise.result.clone().unwrap();
let same = |a: &Arc<DemosaicedImage>, b: &Arc<DemosaicedImage>| Arc::ptr_eq(a, b);
// On by default, at full strength: the network's result as it is.
assert!(same(&s.developed_source(), &result));
// Off: the classical demosaic, result or no result.
s.graph
.set_param(learned_denoise::ID, learned_denoise::APPLY, 0.0);
assert!(same(&s.developed_source(), &classical));
// On, no grain: the network's result as it is.
s.graph
.set_param(learned_denoise::ID, learned_denoise::APPLY, 1.0);
assert!(same(&s.developed_source(), &result));
// Grain: a blend, made once per value and reused until it moves.
// Less strength: a blend, made once per value and reused until it
// moves.
s.graph
.set_param(learned_denoise::ID, learned_denoise::GRAIN, 40.0);
.set_param(learned_denoise::ID, learned_denoise::STRENGTH, 60.0);
let blended = s.developed_source();
assert!(!same(&blended, &result) && !same(&blended, &classical));
assert!(
@@ -401,7 +443,7 @@ mod tests {
"the same grain must not blend again"
);
s.graph
.set_param(learned_denoise::ID, learned_denoise::GRAIN, 60.0);
.set_param(learned_denoise::ID, learned_denoise::STRENGTH, 40.0);
assert!(!same(&s.developed_source(), &blended));
// The sensor's own reading stays the classical one throughout.
assert!(same(&s.demosaiced, &classical));