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
Generated
+1
View File
@@ -1768,6 +1768,7 @@ dependencies = [
"dr-types",
"dr-xmp",
"env_logger",
"half",
"i-slint-backend-testing",
"jni 0.22.4",
"log",
+42 -20
View File
@@ -180,8 +180,8 @@ pub struct EditGraph {
/// as [`crate::learned_denoise`], captured, stored and undone with the
/// rest (FR-DEV-3c).
denoise_applied: bool,
/// How much of the removed noise's brightness to put back, 0–100.
denoise_grain: f32,
/// How strongly to denoise, 0–100; what is not taken goes back as grain.
denoise_strength: f32,
}
/// TRACES: FR-DEV-3f
@@ -239,8 +239,8 @@ impl EditGraph {
lens_profile: None,
lens_profile_applied: true,
denoise_available: false,
denoise_applied: false,
denoise_grain: 0.0,
denoise_applied: true,
denoise_strength: 100.0,
}
}
@@ -429,9 +429,9 @@ impl EditGraph {
}
/// TRACES: FR-DEV-3g
/// The grain to keep, 0–1.
/// The grain to keep, 0–1: what the strength does not take.
pub fn denoise_grain(&self) -> f32 {
self.denoise_grain / 100.0
(100.0 - self.denoise_strength) / 100.0
}
/// TRACES: FR-DEV-3
@@ -643,10 +643,12 @@ impl EditGraph {
}
});
switch
// The learned denoise first: it decides what every control below
// is applied to, so it heads the panel (docs/dev/denoise.md §7).
denoise
.into_iter()
.chain(switch)
.chain(warps)
.chain(denoise)
.chain(ops)
.chain(std::iter::once(framing))
.collect()
@@ -742,7 +744,7 @@ impl EditGraph {
denoise_available: _,
// Edits, in the state through `capabilities` like the lens switch.
denoise_applied: _,
denoise_grain: _,
denoise_strength: _,
masks,
film,
spots,
@@ -817,8 +819,12 @@ impl EditGraph {
if op == crate::learned_denoise::ID {
match param {
p if p == crate::learned_denoise::APPLY => self.denoise_applied = value != 0.0,
p if p == crate::learned_denoise::STRENGTH => {
self.denoise_strength = value.clamp(0.0, 100.0)
}
// 0.21.0's grain, the strength's inverse (see `GRAIN`).
p if p == crate::learned_denoise::GRAIN => {
self.denoise_grain = value.clamp(0.0, 100.0)
self.denoise_strength = 100.0 - value.clamp(0.0, 100.0)
}
_ => log::warn!("unknown parameter {param} on {op}; ignoring"),
}
@@ -886,7 +892,8 @@ impl EditGraph {
p if p == crate::learned_denoise::APPLY => {
Some(if self.denoise_applied { 1.0 } else { 0.0 })
}
p if p == crate::learned_denoise::GRAIN => Some(self.denoise_grain),
p if p == crate::learned_denoise::STRENGTH => Some(self.denoise_strength),
p if p == crate::learned_denoise::GRAIN => Some(100.0 - self.denoise_strength),
_ => None,
};
}
@@ -938,8 +945,8 @@ impl EditGraph {
self.set_lens_profile_applied(true);
// The learned denoise returns to off; whether it is available is the
// file's and stays.
self.denoise_applied = false;
self.denoise_grain = 0.0;
self.denoise_applied = true;
self.denoise_strength = 100.0;
}
/// Set the crop rectangle. Clamped to keep it inside the frame.
@@ -2098,23 +2105,38 @@ mod tests {
.into_iter()
.find(|c| c.id == learned_denoise::ID)
.expect("offered");
assert!(!cap.active, "off until asked for");
g.set_param(learned_denoise::ID, learned_denoise::APPLY, 1.0);
g.set_param(learned_denoise::ID, learned_denoise::GRAIN, 30.0);
assert!(g.denoise_applied());
assert!(cap.active, "on by default");
assert_eq!(g.denoise_grain(), 0.0, "at full strength");
assert_eq!(cap.id, g.capabilities()[0].id, "and first in the panel");
g.set_param(learned_denoise::ID, learned_denoise::APPLY, 0.0);
g.set_param(learned_denoise::ID, learned_denoise::STRENGTH, 70.0);
assert!(!g.denoise_applied());
assert!((g.denoise_grain() - 0.3).abs() < 1e-6);
g.reset();
assert!(!g.denoise_applied());
assert!(g.denoise_applied(), "reset is back to on");
assert_eq!(g.denoise_grain(), 0.0);
}
#[test]
fn an_edit_saved_with_grain_keeps_its_look() {
// TRACES: FR-DEV-3g
// 0.21.0 stored the grain kept rather than the strength.
use crate::learned_denoise;
let mut g = EditGraph::default_chain();
g.set_param(learned_denoise::ID, learned_denoise::GRAIN, 25.0);
assert_eq!(
g.param(learned_denoise::ID, learned_denoise::STRENGTH),
Some(75.0)
);
assert!((g.denoise_grain() - 0.25).abs() < 1e-6);
}
#[test]
fn the_learned_denoise_travels_in_the_state() {
use crate::learned_denoise;
let mut g = EditGraph::default_chain();
g.set_denoise_available(true);
g.set_param(learned_denoise::ID, learned_denoise::APPLY, 1.0);
g.set_param(learned_denoise::ID, learned_denoise::GRAIN, 40.0);
g.set_param(learned_denoise::ID, learned_denoise::STRENGTH, 60.0);
let state = g.state();
let mut h = EditGraph::default_chain();
h.set_denoise_available(true);
+16 -7
View File
@@ -18,23 +18,32 @@ use crate::{OpId, ParamId};
pub const ID: OpId = OpId("learned_denoise");
pub const APPLY: ParamId = ParamId("apply");
/// TRACES: FR-DEV-3g
/// How strongly to denoise, 0–100: 100 is the network's result as it is, and
/// lower puts the removed noise's brightness back as grain.
pub const STRENGTH: ParamId = ParamId("strength");
/// What 0.21.0 stored instead of [`STRENGTH`]: the grain kept, its inverse.
/// Still read, so an edit saved by that release keeps its look; never
/// written, and not offered as a control.
pub const GRAIN: ParamId = ParamId("grain");
/// Off by default: it costs seconds per photograph and replaces the
/// demosaic, which is the photographer's call. Grain 0 is the network's
/// result as it is.
/// On by default, at full strength: every Bayer raw is developed from the
/// learned demosaic, and the switch and the slider are there to take it back
/// or ease it off. It costs seconds per photograph the first time, while
/// the classical demosaic shows; the result is cached, so a photograph
/// reopened or exported does not pay again (docs/dev/denoise.md §7).
pub(crate) static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
id: ID,
label: LocalizedKey("op.learned_denoise"),
params: vec![
ParamDescriptor::switch("apply", "param.learned_denoise.apply"),
ParamDescriptor::switch_on("apply", "param.learned_denoise.apply"),
ParamDescriptor::scalar(
"grain",
"param.learned_denoise.grain",
"strength",
"param.learned_denoise.strength",
0.0,
100.0,
0.0,
100.0,
Unit::Percent,
Scale::Linear,
0,
+26 -1
View File
@@ -418,6 +418,31 @@ measured while the GPU sat power-capped at an 810 MHz memory clock; uncapped is
about four times faster. The Rust path reproduces the training repository's output to 2.5e-4 at
worst; TensorRT fp16 is 75 dB from f32.
**Not yet:** the result is not cached across sessions (§7.1) — reopening recomputes; the tripod real
**Not yet:** ~~the result is not cached across sessions (§7.1) — reopening recomputes;~~ done after
0.21.0, §12; the tripod real
pairs of §6.1; X-Trans (§9); the hand-written WGSL path, for which `export.py` already writes the
weights blob and a manifest a shader can follow.
## 12. On by default, with a strength, and cached (after 0.21.0)
The photographer asked for the learned demosaic to be how a raw is developed, not an option found
under Detail. So:
- **On by default, at full strength, on every device.** The switch is `switch_on`, so an untouched
photograph writes nothing and is developed from the network everywhere; turning it off is the
edit. Which hardware runs it is the inference engine's choice (inference.md), not this setting's:
the default does not depend on what a device is believed to manage.
- **Strength replaces Keep grain.** 0–100, default 100, and grain = 100 − strength, so it is the
same luminance-only blend of §7.2 and moving it is one GPU pass, never a re-run. An edit saved by
0.21.0 stored `grain`; it is still read, as its inverse, and never written.
- **First in the panel**, above the lens corrections: it decides what every control below is
applied to. Its attribute is still Detail, so it also stays where the Detail tab shows it.
- **Cached on disk** (§7.1): the network's output for a file, as half floats (about 120 MB for
20 MP — no compressor to link on Android), keyed on a SHA-256 of the file's bytes and the model
file's name and size, oldest first past a 5 GB budget, beside the inference engine's cache under
the data root. The strength is applied afterwards and is not in the key. A reopened photograph,
and an export of one already developed, read it back instead of recomputing.
What it costs: every raw opened runs the network once, with the classical demosaic shown until the
result lands, and a first export of an unopened raw runs it too. Every raw renders differently from
0.21.0 unless switched off.
File diff suppressed because one or more lines are too long
+1
View File
@@ -42,6 +42,7 @@ dr-ingest.workspace = true
# The sameness probe of a catalog duplicate (FR-CAT-11a): SHA-256 over the
# ends of each copy, the digest the import already uses for whole files.
sha2 = "0.10"
half = "2.7"
dr-film.workspace = true
# The lens profile database, here for the same reason dr-film is: dr-pipeline
# knows the maths of lens correction and deliberately has no dependency with
+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));
+243
View File
@@ -0,0 +1,243 @@
//! TRACES: FR-DEV-3g
//! The learned denoise's results, kept on disk (docs/dev/denoise.md §7.1).
//!
//! The network takes seconds per photograph and is on by default, so a
//! photograph reopened, or exported after it was developed, must not pay
//! again. A result is the network's output as it is — linear camera RGB at
//! the crop's size — written as half floats: about 120 MB for 20 MP, and no
//! compressor to link on Android. The strength slider is applied afterwards
//! and is not part of the key, so moving it never invalidates anything.
//!
//! **Keyed on the file's bytes and the model**: a SHA-256 of what was
//! decoded, and the model file's name and size. Anything that changes the
//! input or the network changes the key; the edit does not.
//!
//! **Bounded by a budget**, oldest first: a hit refreshes an entry's time, a
//! write evicts what no longer fits. Disposable — a peer of the inference
//! engine's cache, never synced — so an entry that cannot be read is simply
//! recomputed.
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
/// Bytes the cache may hold before the oldest entries go: about forty 20 MP
/// photographs.
pub const BUDGET: u64 = 5 * 1024 * 1024 * 1024;
const MAGIC: &[u8; 8] = b"DRDN1\0\0\0";
const HEADER: usize = 8 + 4 + 4 + 1;
/// A cached result: the RGB samples, their size, and where the noise
/// figures came from.
pub struct Entry {
pub rgb: Vec<f32>,
pub width: u32,
pub height: u32,
pub source: dr_denoise::Source,
}
/// The cache's directory: beside the inference engine's, under the data
/// root, which is writable on every platform.
pub fn dir() -> PathBuf {
crate::library::inference_cache_dir()
.parent()
.map(|p| p.join("denoise-cache"))
.unwrap_or_else(|| PathBuf::from("denoise-cache"))
}
/// The key for a file's bytes under a model.
pub fn key(bytes: &[u8], model: &Path) -> String {
let mut h = Sha256::new();
h.update(bytes);
if let Some(name) = model.file_name() {
h.update(name.to_string_lossy().as_bytes());
}
let size = std::fs::metadata(model).map(|m| m.len()).unwrap_or(0);
h.update(size.to_le_bytes());
let digest = h.finalize();
digest.iter().map(|b| format!("{b:02x}")).collect()
}
fn path_in(dir: &Path, key: &str) -> PathBuf {
dir.join(format!("{key}.drdn"))
}
fn source_code(s: dr_denoise::Source) -> u8 {
match s {
dr_denoise::Source::Table => 0,
dr_denoise::Source::DngProfile => 1,
dr_denoise::Source::Measured => 2,
}
}
fn source_from(code: u8) -> Option<dr_denoise::Source> {
Some(match code {
0 => dr_denoise::Source::Table,
1 => dr_denoise::Source::DngProfile,
2 => dr_denoise::Source::Measured,
_ => return None,
})
}
/// The entry for `key`, if one is held and reads back whole. A hit
/// refreshes its time, so what is in use outlives what is not.
pub fn load(dir: &Path, key: &str) -> Option<Entry> {
let path = path_in(dir, key);
let mut file = std::fs::File::open(&path).ok()?;
let mut head = [0u8; HEADER];
file.read_exact(&mut head).ok()?;
if &head[..8] != MAGIC {
return None;
}
let width = u32::from_le_bytes(head[8..12].try_into().ok()?);
let height = u32::from_le_bytes(head[12..16].try_into().ok()?);
let source = source_from(head[16])?;
let samples = (width as usize)
.checked_mul(height as usize)?
.checked_mul(3)?;
let mut raw = vec![0u8; samples.checked_mul(2)?];
file.read_exact(&mut raw).ok()?;
let rgb = raw
.chunks_exact(2)
.map(|b| half::f16::from_le_bytes([b[0], b[1]]).to_f32())
.collect();
let _ = file.set_modified(std::time::SystemTime::now());
Some(Entry {
rgb,
width,
height,
source,
})
}
/// Keep `entry` under `key`, then evict to `budget`. Written beside its name
/// and renamed, so a reader never sees half a file. Failure only costs a
/// recompute next time, so it is logged and swallowed.
pub fn store(dir: &Path, key: &str, entry: &Entry, budget: u64) {
let result = (|| -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let path = path_in(dir, key);
let partial = path.with_extension("part");
let mut out = std::io::BufWriter::new(std::fs::File::create(&partial)?);
out.write_all(MAGIC)?;
out.write_all(&entry.width.to_le_bytes())?;
out.write_all(&entry.height.to_le_bytes())?;
out.write_all(&[source_code(entry.source)])?;
for v in &entry.rgb {
out.write_all(&half::f16::from_f32(*v).to_le_bytes())?;
}
out.into_inner().map_err(|e| e.into_error())?.sync_all()?;
std::fs::rename(&partial, &path)
})();
if let Err(e) = result {
log::warn!("denoise cache: not kept ({e})");
return;
}
evict(dir, budget);
}
/// Remove the oldest entries until what is left fits `budget`.
pub fn evict(dir: &Path, budget: u64) {
let Ok(read) = std::fs::read_dir(dir) else {
return;
};
let mut entries: Vec<(std::time::SystemTime, u64, PathBuf)> = read
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "drdn"))
.filter_map(|e| {
let m = e.metadata().ok()?;
Some((m.modified().ok()?, m.len(), e.path()))
})
.collect();
let mut total: u64 = entries.iter().map(|(_, len, _)| len).sum();
entries.sort_by_key(|(t, _, _)| *t);
for (_, len, path) in entries {
if total <= budget {
break;
}
if std::fs::remove_file(&path).is_ok() {
total -= len;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let d =
std::env::temp_dir().join(format!("dr-denoise-cache-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
fn entry(w: u32, h: u32) -> Entry {
Entry {
rgb: (0..w * h * 3).map(|i| i as f32 / 100.0).collect(),
width: w,
height: h,
source: dr_denoise::Source::DngProfile,
}
}
#[test]
fn a_result_comes_back_as_it_went_in_to_half_precision() {
let dir = scratch("roundtrip");
let e = entry(4, 3);
store(&dir, "k", &e, BUDGET);
let back = load(&dir, "k").expect("a hit");
assert_eq!((back.width, back.height), (4, 3));
assert_eq!(back.source, dr_denoise::Source::DngProfile);
for (a, b) in e.rgb.iter().zip(&back.rgb) {
assert!((a - b).abs() <= a.abs() * 1e-3 + 1e-4, "{a} {b}");
}
assert!(load(&dir, "other").is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_key_follows_the_bytes_and_the_model() {
let dir = scratch("key");
std::fs::create_dir_all(&dir).unwrap();
let model = dir.join("m.onnx");
std::fs::write(&model, b"weights").unwrap();
let a = key(b"photo", &model);
assert_eq!(a, key(b"photo", &model));
assert_ne!(a, key(b"photo2", &model));
std::fs::write(&model, b"other weights").unwrap();
assert_ne!(a, key(b"photo", &model));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_oldest_entries_go_first_past_the_budget() {
let dir = scratch("evict");
let e = entry(10, 10);
store(&dir, "old", &e, BUDGET);
let one = std::fs::metadata(path_in(&dir, "old")).unwrap().len();
std::thread::sleep(std::time::Duration::from_millis(20));
store(&dir, "mid", &e, BUDGET);
std::thread::sleep(std::time::Duration::from_millis(20));
// A hit makes the oldest the most recent.
load(&dir, "old").unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
store(&dir, "new", &e, 2 * one);
assert!(load(&dir, "mid").is_none(), "the least recently used went");
assert!(load(&dir, "old").is_some() && load(&dir, "new").is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_damaged_entry_is_a_miss() {
let dir = scratch("damaged");
store(&dir, "k", &entry(4, 4), BUDGET);
let p = path_in(&dir, "k");
let bytes = std::fs::read(&p).unwrap();
std::fs::write(&p, &bytes[..bytes.len() / 2]).unwrap();
assert!(load(&dir, "k").is_none());
let _ = std::fs::remove_dir_all(&dir);
}
}
+1
View File
@@ -18,6 +18,7 @@
mod curves;
mod denoise;
mod denoise_cache;
mod framing;
mod history;
mod mask_ops;
+3 -1
View File
@@ -223,7 +223,9 @@ fn catalogued(key: &str) -> Option<&'static str> {
// panel under its own name — see `rows_filtered`.
"param.lens_profile.apply" => "Apply",
"param.learned_denoise.apply" => "Apply",
"param.learned_denoise.grain" => "Keep grain",
// How strongly: 100 % is the network's result, and less puts the
// removed noise's brightness back as grain.
"param.learned_denoise.strength" => "Strength",
"param.camera_profile.apply" => "Use Profile",
// The LookTable's strength.
"param.camera_profile.look" => "Look Amount",