Files
DarkRoom/ui/dr-ui/src/develop/denoise.rs
T
dtourolle 2e7f14dafe 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.
2026-10-03 22:16:36 -04:00

474 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! TRACES: FR-DEV-3g
//! The learned denoise in a develop session (docs/dev/denoise.md §7).
//!
//! The classical demosaic shows at once; when the photograph asks for the
//! learned one, it is computed off the UI thread from the mosaic the
//! session kept, and swapped in when it lands. Grain is a blend of the two
//! results, made once per slider value by [`dr_gpu::GrainBlend`] and handed
//! to the render as its source — the adjust pass never knows.
//!
//! `demosaiced` stays the classical result for the session's life: the raw
//! histogram, the white balance picker, masks and segmentation measure the
//! sensor data, and only [`DevelopSession::developed_source`] — what the
//! render draws — changes.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use dr_decode::RawImage;
use dr_gpu::{DemosaicedImage, GrainBlend};
use super::session::DevelopSession;
/// What the session holds for the learned denoise.
#[derive(Default)]
pub(crate) struct DenoiseState {
/// The mosaic as decoded, kept only for a photograph that can take the
/// learned stage. The job repairs a copy.
mosaic: Option<Arc<RawImage>>,
/// The file's `NoiseProfile` and ISO, read from the header at open.
profile: Option<Vec<(f32, f32)>>,
iso: Option<u32>,
/// The network's result, once it has landed.
result: Option<Arc<DemosaicedImage>>,
/// The last grain blend made, and the grain it was made at.
blended: Option<(f32, Arc<DemosaicedImage>)>,
blend: Option<GrainBlend>,
job: Option<Job>,
/// Why the last attempt failed; not retried until the switch is
/// toggled, so a photograph that cannot be denoised does not loop.
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 {
rx: mpsc::Receiver<Msg>,
cancel: Arc<AtomicBool>,
}
enum Msg {
Progress(usize, usize),
Done(Result<Finished, String>),
}
struct Finished {
rgb: Vec<f32>,
width: u32,
height: u32,
source: dr_denoise::Source,
rung: String,
seconds: f64,
}
/// What a poll found, for the develop view's status line and redraw.
#[derive(Debug, PartialEq)]
pub enum DenoiseStatus {
/// Nothing running and nothing new.
Idle,
/// Tiles done of tiles.
Running(usize, usize),
/// The result landed this poll: redraw.
Landed,
Failed(String),
}
impl DevelopSession {
/// Keep the mosaic for the learned denoise, if it can take this frame.
pub(super) fn keep_mosaic(&mut self, raw: RawImage) {
if dr_denoise::eligible(&raw) {
self.denoise.mosaic = Some(Arc::new(raw));
}
self.graph
.set_denoise_available(self.denoise.mosaic.is_some());
}
/// The header's part: the DNG's measured noise and the ISO.
pub fn prepare_denoise(&mut self, bytes: &[u8], meta: &dr_decode::Metadata) {
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));
}
}
/// Bring what is computed in line with what the edit asks for: start the
/// network when it is wanted and has not run, stop it when it is not.
/// Cheap when nothing changed; the develop view calls it after every
/// change to the edit, whatever made it — a slider, undo, a version.
pub fn reconcile_denoise(&mut self) {
let wanted = self.graph.denoise_applied() && self.denoise.mosaic.is_some();
if !wanted {
if let Some(job) = self.denoise.job.take() {
job.cancel.store(true, Ordering::Relaxed);
}
// Toggling off is how a failure is retried.
self.denoise.failed = None;
return;
}
if self.denoise.result.is_some()
|| self.denoise.job.is_some()
|| self.denoise.failed.is_some()
{
return;
}
let Some(model) = crate::library::denoise_model() else {
self.denoise.failed = Some("the denoise model is not installed".into());
return;
};
let (tx, rx) = mpsc::channel();
let cancel = Arc::new(AtomicBool::new(false));
let work = Work {
ctx: self.ctx.clone(),
raw: self.denoise.mosaic.clone().expect("checked above"),
profile: self.denoise.profile.clone(),
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| {
let _ = tx.send(Msg::Progress(done, total));
});
let _ = tx.send(Msg::Done(result));
});
self.denoise.job = Some(Job { rx, cancel });
}
/// Collect what the job sent since the last poll.
pub fn poll_denoise(&mut self) -> DenoiseStatus {
let Some(job) = &self.denoise.job else {
return DenoiseStatus::Idle;
};
let mut last = None;
let mut done = None;
while let Ok(msg) = job.rx.try_recv() {
match msg {
Msg::Progress(d, t) => last = Some((d, t)),
Msg::Done(r) => done = Some(r),
}
}
match done {
Some(Ok(f)) => {
self.denoise.job = None;
match self.land(f) {
Ok(()) => DenoiseStatus::Landed,
Err(e) => self.fail(e),
}
}
Some(Err(e)) => {
self.denoise.job = None;
self.fail(e)
}
None => last.map_or(DenoiseStatus::Running(0, 1), |(d, t)| {
DenoiseStatus::Running(d, t)
}),
}
}
/// Compute the result now, on this thread, if the edit wants it and it
/// is not here: for an export, which must not write the classical
/// picture of a photograph that asks for the learned one (§7.1).
pub fn denoise_blocking(&mut self) -> Result<(), String> {
if !self.graph.denoise_applied() || self.denoise.result.is_some() {
return Ok(());
}
let Some(raw) = self.denoise.mosaic.clone() else {
return Ok(());
};
// Already under way: wait for it rather than start again.
if let Some(job) = self.denoise.job.take() {
for msg in job.rx.iter() {
if let Msg::Done(result) = msg {
return result.and_then(|f| self.land(f));
}
}
}
let model = crate::library::denoise_model().ok_or("the denoise model is not installed")?;
let work = Work {
ctx: self.ctx.clone(),
raw,
profile: self.denoise.profile.clone(),
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)
}
/// What the render draws: the learned result with the asked-for grain
/// where there is one, else the classical demosaic.
pub(super) fn developed_source(&mut self) -> Arc<DemosaicedImage> {
if !self.graph.denoise_applied() {
return self.demosaiced.clone();
}
let Some(result) = self.denoise.result.clone() else {
return self.demosaiced.clone();
};
let grain = self.graph.denoise_grain();
if grain <= 0.0 {
return result;
}
if let Some((g, image)) = &self.denoise.blended {
if (*g - grain).abs() < 1e-4 {
return image.clone();
}
}
let blend = self
.denoise
.blend
.get_or_insert_with(|| GrainBlend::new(&self.ctx));
match blend.blend(&result, &self.demosaiced, grain) {
Ok(image) => {
self.denoise.blended = Some((grain, image.clone()));
image
}
Err(e) => {
log::warn!("grain blend failed, showing the denoised result without grain: {e}");
result
}
}
}
/// For the panel: where the noise figures came from, once computed.
pub fn denoise_source(&self) -> Option<dr_denoise::Source> {
self.denoise.source
}
pub fn denoise_failure(&self) -> Option<&str> {
self.denoise.failed.as_deref()
}
fn land(&mut self, f: Finished) -> Result<(), String> {
let image =
DemosaicedImage::from_rgb_f32(&self.ctx, &self.demosaiced, f.width, f.height, &f.rgb)
.map_err(|e| e.to_string())?;
log::info!(
"learned denoise: {}×{} on {} in {:.1} s, noise {}",
f.width,
f.height,
f.rung,
f.seconds,
f.source.label()
);
self.denoise.result = Some(Arc::new(image));
self.denoise.blended = None;
self.denoise.source = Some(f.source);
Ok(())
}
fn fail(&mut self, e: String) -> DenoiseStatus {
log::warn!("learned denoise failed: {e}");
self.denoise.failed = Some(e.clone());
DenoiseStatus::Failed(e)
}
}
/// Everything the job needs, owned, so it can leave the UI thread.
struct Work {
ctx: dr_gpu::GpuContext,
raw: Arc<RawImage>,
profile: Option<Vec<(f32, f32)>>,
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();
dr_gpu::Demosaicer::new(&self.ctx)
.and_then(|d| d.repair_hot_pixels(&mut raw))
.map_err(|e| e.to_string())?;
let noise = dr_denoise::noise::for_frame_with(&raw, self.profile.as_deref(), self.iso)
.ok_or("this photograph gives no way to measure its noise")?;
let mut net =
dr_denoise::onnx::OnnxNet::from_path(&self.model).map_err(|e| e.to_string())?;
let rung = net
.rung()
.map(|r| r.label().to_string())
.unwrap_or_default();
let cancel = self.cancel;
let rgb = dr_denoise::denoise(&raw, &noise, &mut net, &mut |done, total| {
progress(done, total);
!cancel.load(Ordering::Relaxed)
})
.map_err(|e| e.to_string())?
.ok_or("stopped")?;
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)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::develop::test_support::headless;
use dr_pipeline::learned_denoise;
fn bayer(w: u32, h: u32) -> RawImage {
RawImage {
width: w,
height: h,
data: vec![800; (w * h) as usize],
cfa_pattern: dr_decode::CfaPattern::Rggb,
black_level: [0; 4],
white_level: 4095,
wb_coeffs: [2.0, 1.0, 1.5, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
samples_per_pixel: 1,
profile: None,
profile_tables: None,
baseline_exposure: 0.0,
make: String::new(),
model: String::new(),
crop: dr_decode::CropRect {
x: 0,
y: 0,
width: w,
height: h,
},
}
}
fn landed(session: &mut DevelopSession) {
let (w, h) = (64, 64);
session
.land(Finished {
rgb: vec![0.2; (w * h * 3) as usize],
width: w,
height: h,
source: dr_denoise::Source::Measured,
rung: "test".into(),
seconds: 0.0,
})
.expect("upload");
}
#[test]
fn a_bayer_raw_offers_the_switch_and_an_rgb_image_does_not() {
let Some(ctx) = headless() else { return };
let raw =
DevelopSession::open_owned(&ctx, bayer(64, 64), dr_types::Orientation::NORMAL).unwrap();
assert!(raw
.graph
.capabilities()
.iter()
.any(|c| c.id == learned_denoise::ID));
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect();
let jpeg =
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL).unwrap();
assert!(!jpeg
.graph
.capabilities()
.iter()
.any(|c| c.id == learned_denoise::ID));
}
#[test]
fn the_render_draws_what_the_edit_asks_for() {
let Some(ctx) = headless() else { return };
let mut s =
DevelopSession::open_owned(&ctx, bayer(64, 64), dr_types::Orientation::NORMAL).unwrap();
let classical = s.demosaiced.clone();
landed(&mut s);
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));
s.graph
.set_param(learned_denoise::ID, learned_denoise::APPLY, 1.0);
// Less strength: a blend, made once per value and reused until it
// moves.
s.graph
.set_param(learned_denoise::ID, learned_denoise::STRENGTH, 60.0);
let blended = s.developed_source();
assert!(!same(&blended, &result) && !same(&blended, &classical));
assert!(
same(&s.developed_source(), &blended),
"the same grain must not blend again"
);
s.graph
.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));
}
#[test]
fn switching_off_stops_and_forgets_a_failure() {
let Some(ctx) = headless() else { return };
let mut s =
DevelopSession::open_owned(&ctx, bayer(64, 64), dr_types::Orientation::NORMAL).unwrap();
s.denoise.failed = Some("no model".into());
s.graph
.set_param(learned_denoise::ID, learned_denoise::APPLY, 1.0);
s.reconcile_denoise();
assert!(
s.denoise.job.is_none(),
"a failure is not retried while the switch stays on"
);
s.graph
.set_param(learned_denoise::ID, learned_denoise::APPLY, 0.0);
s.reconcile_denoise();
assert!(
s.denoise.failed.is_none(),
"toggling off is how a failure is retried"
);
}
}