Offer three denoise networks and a method to choose between them

AI Denoise's Apply switch becomes Method: Bilinear, Fast, Medium, Best,
default Best, so an untouched raw writes nothing and develops through the
mixture. `apply` is still read and never written: 0 is Bilinear, 1 keeps
a network already chosen.

- Best is the mixture of a flat and an edge expert with a learned gate;
  Medium and Fast are students distilled from it. 2.48 s, 0.79 s and
  0.57 s for a 20 MP frame on TensorRT fp16.
- Each network carries its own tile border (256 for the mixture, 192 for
  the students) through `dr_denoise::Shipped` and `TileNet::halo`.
- The file is hashed once at open and each network keys its own cached
  result; Bilinear keeps the result in memory for the way back.
- Each has an .a16w16 sibling for the Hexagon: 0.00 dB on the 6D gate,
  at most 0.11 dB with the noise scaled x0.5 to x4.
- APK BUNDLED 19 -> 23; the PKGBUILD installs all three.
This commit is contained in:
2026-10-04 08:02:25 -04:00
parent 14f08a565f
commit 06422a07db
26 changed files with 540 additions and 141 deletions
+107 -43
View File
@@ -18,6 +18,7 @@ use std::sync::Arc;
use dr_decode::RawImage;
use dr_gpu::{DemosaicedImage, GrainBlend};
use dr_pipeline::learned_denoise::Method;
use super::session::DevelopSession;
@@ -41,9 +42,12 @@ 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>,
/// The file's bytes, hashed for the on-disk cache, which keys each
/// network's result on them and the model (`denoise_cache::FileHash`).
file_hash: Option<super::denoise_cache::FileHash>,
/// The method the result, the job and the failure above are for. A
/// different one asked for discards them.
method: Option<Method>,
}
struct Job {
@@ -93,10 +97,10 @@ impl DevelopSession {
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));
// The bytes are only here now, so they are hashed now: a
// reopened or exported photograph finds its result on disk,
// under whichever method it asks for.
self.denoise.file_hash = Some(super::denoise_cache::FileHash::of(bytes));
}
}
@@ -105,6 +109,7 @@ impl DevelopSession {
/// 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) {
self.forget_other_method();
let wanted = self.graph.denoise_applied() && self.denoise.mosaic.is_some();
if !wanted {
if let Some(job) = self.denoise.job.take() {
@@ -120,21 +125,11 @@ impl DevelopSession {
{
return;
}
let Some(model) = crate::library::denoise_model() else {
self.denoise.failed = Some("the denoise model is not installed".into());
let Some(work) = self.work(Arc::new(AtomicBool::new(false))) else {
return;
};
let cancel = work.cancel.clone();
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));
@@ -144,6 +139,51 @@ impl DevelopSession {
self.denoise.job = Some(Job { rx, cancel });
}
/// Drop what was computed, or is being computed, for a network the edit
/// no longer asks for — a choice in the panel, an undo, a version.
/// Each network's result stays in the on-disk cache, so going back to
/// one is a read, not a run. The classical demosaic asks for no network
/// and drops nothing: the result is kept for the way back.
fn forget_other_method(&mut self) {
let asked = self.graph.denoise_method();
if !asked.learned() || self.denoise.method == Some(asked) {
return;
}
if self.denoise.method.is_none() {
// The first network asked for: nothing computed is another's.
self.denoise.method = Some(asked);
return;
}
if let Some(job) = self.denoise.job.take() {
job.cancel.store(true, Ordering::Relaxed);
}
self.denoise.result = None;
self.denoise.blended = None;
self.denoise.failed = None;
self.denoise.source = None;
self.denoise.method = Some(asked);
}
/// The job for the method the edit asks for, or `None` — with the reason
/// kept as the failure — where its network is not installed.
fn work(&mut self, cancel: Arc<AtomicBool>) -> Option<Work> {
let raw = self.denoise.mosaic.clone()?;
let Some((model, net)) = crate::library::denoise_model(self.graph.denoise_method()) else {
self.denoise.failed = Some("the denoise model is not installed".into());
return None;
};
Some(Work {
ctx: self.ctx.clone(),
raw,
profile: self.denoise.profile.clone(),
iso: self.denoise.iso,
cache_key: self.denoise.file_hash.as_ref().map(|h| h.key(&model)),
model,
halo: net.halo,
cancel,
})
}
/// Collect what the job sent since the last poll.
pub fn poll_denoise(&mut self) -> DenoiseStatus {
let Some(job) = &self.denoise.job else {
@@ -182,9 +222,10 @@ impl DevelopSession {
if !self.graph.denoise_applied() || self.denoise.result.is_some() {
return Ok(());
}
let Some(raw) = self.denoise.mosaic.clone() else {
self.forget_other_method();
if self.denoise.mosaic.is_none() {
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() {
@@ -193,15 +234,8 @@ impl DevelopSession {
}
}
}
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 Some(work) = self.work(Arc::new(AtomicBool::new(false))) else {
return Err(self.denoise.failed.clone().unwrap_or_default());
};
let finished = work.run(&mut |_, _| {})?;
self.land(finished)
@@ -282,6 +316,8 @@ struct Work {
profile: Option<Vec<(f32, f32)>>,
iso: Option<u32>,
model: std::path::PathBuf,
/// The context `model` needs past a tile's kept centre.
halo: usize,
cancel: Arc<AtomicBool>,
cache_key: Option<String>,
}
@@ -315,8 +351,8 @@ impl Work {
.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 mut net = dr_denoise::onnx::OnnxNet::from_path(&self.model, self.halo)
.map_err(|e| e.to_string())?;
let rung = net
.rung()
.map(|r| r.label().to_string())
@@ -426,12 +462,18 @@ mod tests {
// 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);
// Bilinear: the classical demosaic, result or no result.
s.graph.set_param(
learned_denoise::ID,
learned_denoise::METHOD,
Method::Bilinear.index(),
);
assert!(same(&s.developed_source(), &classical));
s.graph
.set_param(learned_denoise::ID, learned_denoise::APPLY, 1.0);
s.graph.set_param(
learned_denoise::ID,
learned_denoise::METHOD,
Method::DEFAULT.index(),
);
// Less strength: a blend, made once per value and reused until it
// moves.
s.graph
@@ -455,19 +497,41 @@ mod tests {
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"
"a failure is not retried while the method stays"
);
s.graph.set_param(
learned_denoise::ID,
learned_denoise::METHOD,
Method::Bilinear.index(),
);
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"
"choosing bilinear is how a failure is retried"
);
}
#[test]
fn another_network_discards_the_result_and_bilinear_keeps_it() {
let Some(ctx) = headless() else { return };
let mut s =
DevelopSession::open_owned(&ctx, bayer(64, 64), dr_types::Orientation::NORMAL).unwrap();
s.denoise.method = Some(Method::DEFAULT);
landed(&mut s);
let to = |s: &mut DevelopSession, m: Method| {
s.graph
.set_param(learned_denoise::ID, learned_denoise::METHOD, m.index());
s.forget_other_method();
};
to(&mut s, Method::Bilinear);
assert!(s.denoise.result.is_some(), "kept for the way back");
to(&mut s, Method::DEFAULT);
assert!(s.denoise.result.is_some());
to(&mut s, Method::Fast);
assert!(s.denoise.result.is_none(), "another network's picture");
assert_eq!(s.denoise.method, Some(Method::Fast));
}
}
+26 -14
View File
@@ -47,17 +47,29 @@ pub fn dir() -> PathBuf {
.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());
/// A file's bytes, hashed once at open: each method's network keys its
/// result from this, so changing the method does not read the file again.
#[derive(Clone)]
pub struct FileHash(Sha256);
impl FileHash {
pub fn of(bytes: &[u8]) -> Self {
let mut h = Sha256::new();
h.update(bytes);
FileHash(h)
}
/// The key for these bytes under a model.
pub fn key(&self, model: &Path) -> String {
let mut h = self.0.clone();
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()
}
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 {
@@ -204,11 +216,11 @@ mod tests {
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));
let a = FileHash::of(b"photo").key(&model);
assert_eq!(a, FileHash::of(b"photo").key(&model));
assert_ne!(a, FileHash::of(b"photo2").key(&model));
std::fs::write(&model, b"other weights").unwrap();
assert_ne!(a, key(b"photo", &model));
assert_ne!(a, FileHash::of(b"photo").key(&model));
let _ = std::fs::remove_dir_all(&dir);
}
+3 -1
View File
@@ -34,8 +34,10 @@ pub fn init(runtime_dirs: Vec<PathBuf>) {
(Role::EyeClassifier, crate::library::EYE_MODEL),
(Role::EyeClassifier, crate::library::SUNGLASSES_MODEL),
(Role::Inpainter, crate::library::INPAINT_MODEL),
(Role::Denoiser, crate::library::DENOISE_MODEL),
]);
wanted.extend(
[dr_denoise::FAST, dr_denoise::MEDIUM, dr_denoise::BEST].map(|n| (Role::Denoiser, n.file)),
);
let models: Vec<(Role, PathBuf)> = wanted
.into_iter()
.filter_map(|(role, name)| Some((role, crate::library::shared_model(name)?)))
+6
View File
@@ -223,6 +223,12 @@ 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",
// Which demosaic: the classical one, or a network by how long it takes.
"param.learned_denoise.method" => "Method",
"param.learned_denoise.method.bilinear" => "Bilinear",
"param.learned_denoise.method.fast" => "Fast",
"param.learned_denoise.method.medium" => "Medium",
"param.learned_denoise.method.best" => "Best",
// How strongly: 100 % is the network's result, and less puts the
// removed noise's brightness back as grain.
"param.learned_denoise.strength" => "Strength",
+20 -5
View File
@@ -390,13 +390,28 @@ pub fn inpaint_model() -> Option<PathBuf> {
}
/// TRACES: FR-DEV-3g
/// The learned demosaic and denoise, as shipped in `models/denoise/`.
pub const DENOISE_MODEL: &str = "mosaic-1408.onnx";
/// The network a denoise method runs, as shipped in `models/denoise/`;
/// `None` for the classical demosaic, which runs none.
pub fn denoise_network(
method: dr_pipeline::learned_denoise::Method,
) -> Option<dr_denoise::Shipped> {
use dr_pipeline::learned_denoise::Method;
match method {
Method::Bilinear => None,
Method::Fast => Some(dr_denoise::FAST),
Method::Medium => Some(dr_denoise::MEDIUM),
Method::Best => Some(dr_denoise::BEST),
}
}
/// TRACES: FR-DEV-3g
/// Where the denoise model is, by the border filler's search.
pub fn denoise_model() -> Option<PathBuf> {
shared_model(DENOISE_MODEL)
/// Where a method's network is, by the border filler's search, and the
/// context it needs.
pub fn denoise_model(
method: dr_pipeline::learned_denoise::Method,
) -> Option<(PathBuf, dr_denoise::Shipped)> {
let net = denoise_network(method)?;
Some((shared_model(net.file)?, net))
}
#[cfg(test)]