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
+18 -3
View File
@@ -338,7 +338,7 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
// sibling when the probe chose that rung and ignores it otherwise. The
// segmenter's and XFeat's forms are compiled into the binary instead,
// beside their f32 graphs.
const BUNDLED: [(&std::ffi::CStr, &str); 19] = [
const BUNDLED: [(&std::ffi::CStr, &str); 23] = [
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
(
c"models/scrfd_500m_640.a16w8.onnx",
@@ -372,8 +372,23 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
// The panorama border filler (FR-MRG-4); MIT, 28 MB.
(c"models/migan-512.onnx", "migan-512.onnx"),
(c"models/migan-512.a16w16.onnx", "migan-512.a16w16.onnx"),
(c"models/mosaic-1408.onnx", "mosaic-1408.onnx"),
(c"models/mosaic-1408.a16w16.onnx", "mosaic-1408.a16w16.onnx"),
// The learned demosaic and denoise, one network per method
// (FR-DEV-3g), each with the 16-bit form the Hexagon runs.
(c"models/mosaic-fast-1408.onnx", "mosaic-fast-1408.onnx"),
(
c"models/mosaic-fast-1408.a16w16.onnx",
"mosaic-fast-1408.a16w16.onnx",
),
(c"models/mosaic-medium-1408.onnx", "mosaic-medium-1408.onnx"),
(
c"models/mosaic-medium-1408.a16w16.onnx",
"mosaic-medium-1408.a16w16.onnx",
),
(c"models/mosaic-best-1408.onnx", "mosaic-best-1408.onnx"),
(
c"models/mosaic-best-1408.a16w16.onnx",
"mosaic-best-1408.a16w16.onnx",
),
];
let dir = dr_ui::shared_face_models_dir();
+16 -6
View File
@@ -2,11 +2,11 @@
//!
//! ```sh
//! DARKROOM_ORT_DIR=~/.local/share/darkroom/runtime \
//! cargo run --release -p dr-denoise --features native --example denoise_raw -- IMG.CR2 out
//! cargo run --release -p dr-denoise --features native --example denoise_raw -- IMG.CR2 out [fast|medium|best]
//! ```
//!
//! Decode, the app's hot-pixel pass, the frame's noise from its best source,
//! then the shipped network under the inference engine on whatever rung this
//! then one of the shipped networks (`best` unless named) under the inference engine on whatever rung this
//! machine probes to. Writes `out.npy` — the active area, `h×w×3` f32 linear
//! camera RGB — for comparison with the training repo's own path
//! (`tools/compare_rust.py` in darkroom-denoise). `DARKROOM_ORT_DIR` points
@@ -23,11 +23,21 @@ fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
let mut args = std::env::args().skip(1);
let (Some(input), Some(out)) = (args.next(), args.next()) else {
eprintln!("usage: denoise_raw RAW OUT_PREFIX");
eprintln!("usage: denoise_raw RAW OUT_PREFIX [fast|medium|best]");
std::process::exit(2);
};
let model =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../models/denoise/mosaic-1408.onnx");
let shipped = match args.next().as_deref() {
None | Some("best") => dr_denoise::BEST,
Some("medium") => dr_denoise::MEDIUM,
Some("fast") => dr_denoise::FAST,
Some(other) => {
eprintln!("no network called {other}: fast, medium or best");
std::process::exit(2);
}
};
let model = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../models/denoise")
.join(shipped.file);
let cache = std::env::var_os("DR_ENGINE_CACHE")
.map(PathBuf::from)
.unwrap_or_else(|| std::env::temp_dir().join("dr-denoise-engines"));
@@ -91,7 +101,7 @@ fn main() {
noise.col
);
let mut net = OnnxNet::from_path(&model).expect("model");
let mut net = OnnxNet::from_path(&model, shipped.halo).expect("model");
println!(
"rung {}",
net.rung().map(|r| r.label()).unwrap_or("?")
+27
View File
@@ -27,6 +27,33 @@ use dr_decode::RawImage;
pub use noise::{NoiseModel, Source};
pub use tile::{TileNet, HALO};
/// TRACES: FR-DEV-3g
/// A network the app ships in `models/denoise/`: its file, and the context
/// it needs past a tile's kept centre (docs/dev/denoise.md §13).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Shipped {
pub file: &'static str,
pub halo: usize,
}
/// The smallest student: 0.9 M parameters, 11 GMAC a megapixel.
pub const FAST: Shipped = Shipped {
file: "mosaic-fast-1408.onnx",
halo: HALO,
};
/// A student of the mixture with the first release's shape: 3.2 M
/// parameters, 48 GMAC a megapixel.
pub const MEDIUM: Shipped = Shipped {
file: "mosaic-medium-1408.onnx",
halo: HALO,
};
/// The mixture: a flat expert, an edge expert and the gate that blends them.
/// It reaches further than either, so it keeps a smaller centre of each tile.
pub const BEST: Shipped = Shipped {
file: "mosaic-best-1408.onnx",
halo: 256,
};
#[derive(Debug, thiserror::Error)]
pub enum DenoiseError {
#[error("the network cannot take this photograph: {0}")]
+9 -1
View File
@@ -21,15 +21,19 @@ pub const TILE: usize = 1408;
pub struct OnnxNet {
model: Model,
tile: usize,
halo: usize,
}
impl OnnxNet {
pub fn from_path(path: &std::path::Path) -> Result<Self, DenoiseError> {
/// The network at `path`, which needs `halo` photosites of context
/// ([`crate::Shipped::halo`]).
pub fn from_path(path: &std::path::Path, halo: usize) -> Result<Self, DenoiseError> {
let (path, form) = dr_inference_engine::resolve_model(Role::Denoiser, path);
let bytes = std::fs::read(&path)?;
Ok(OnnxNet {
model: dr_inference_engine::open(Role::Denoiser, form, &bytes)?,
tile: TILE,
halo,
})
}
@@ -44,6 +48,10 @@ impl TileNet for OnnxNet {
self.tile
}
fn halo(&self) -> usize {
self.halo
}
fn run(
&mut self,
mosaic: Vec<f32>,
+18 -11
View File
@@ -15,7 +15,9 @@
use dr_decode::CfaPattern;
/// Photosites of context beyond a tile's kept centre, on every side.
/// Photosites of context beyond a tile's kept centre, on every side, for a
/// single network; a mixture reaches further and says so through
/// [`TileNet::halo`].
pub const HALO: usize = 192;
/// A fixed-shape network: `mosaic` and `sigma`, `n×n` RGGB, in; `3×n×n`
@@ -28,6 +30,11 @@ pub const HALO: usize = 192;
pub trait TileNet {
/// The edge `n` of the square tile the network takes.
fn tile(&self) -> usize;
/// Photosites of context it needs past a tile's kept centre: at least
/// its receptive field. [`HALO`] unless the network says otherwise.
fn halo(&self) -> usize {
HALO
}
fn run(
&mut self,
mosaic: Vec<f32>,
@@ -78,13 +85,13 @@ pub fn run_tiled(
let (dy, dx) = rggb_offset(pattern).ok_or_else(|| {
crate::DenoiseError::Unsupported(format!("{pattern:?} is not a Bayer pattern"))
})?;
let n = net.tile();
if n <= 2 * HALO || !(n - 2 * HALO).is_multiple_of(2) {
let (n, halo) = (net.tile(), net.halo());
if n <= 2 * halo || !(n - 2 * halo).is_multiple_of(2) {
return Err(crate::DenoiseError::Model(format!(
"tile {n} leaves no even centre past a {HALO} halo"
"tile {n} leaves no even centre past a {halo} halo"
)));
}
let core = n - 2 * HALO;
let core = n - 2 * halo;
// In unified coordinates the frame spans u ∈ [dy, dy + h), v ∈ [dx, dx + w).
let (uh, uw) = (h + dy, w + dx);
let (ty, tx) = (uh.div_ceil(core), uw.div_ceil(core));
@@ -108,11 +115,11 @@ pub fn run_tiled(
scope.spawn(move || {
for (i, (mrow, srow)) in m.chunks_mut(n).zip(s.chunks_mut(n)).enumerate() {
let r = chunk * rows_per + i;
// Unified row u = u0 + r − HALO; frame row y = u − dy, reflected.
let u = u0 as isize + r as isize - HALO as isize;
// Unified row u = u0 + r − halo; frame row y = u − dy, reflected.
let u = u0 as isize + r as isize - halo as isize;
let y = reflect(u - dy as isize, h);
for c in 0..n {
let v = v0 as isize + c as isize - HALO as isize;
let v = v0 as isize + c as isize - halo as isize;
let x = reflect(v - dx as isize, w);
let val = at(y, x);
mrow[c] = val;
@@ -169,10 +176,10 @@ pub fn run_tiled(
scope.spawn(move || {
for (i, row) in block.chunks_mut(w * 3).enumerate() {
let y = y_lo + chunk * per + i;
// Tile row of frame row y: u = y + dy = u0 + r − HALO.
let r = y + dy + HALO - u0;
// Tile row of frame row y: u = y + dy = u0 + r − halo.
let r = y + dy + halo - u0;
for x in x_lo..x_hi {
let c = x + dx + HALO - v0;
let c = x + dx + halo - v0;
for ch in 0..3 {
row[x * 3 + ch] = rgb[ch * n * n + r * n + c];
}
+96 -15
View File
@@ -176,10 +176,10 @@ pub struct EditGraph {
/// lens profile, so not in the state; it only decides whether the
/// switch below is offered.
denoise_available: bool,
/// Whether the learned denoise replaces the demosaic. An edit: published
/// as [`crate::learned_denoise`], captured, stored and undone with the
/// rest (FR-DEV-3c).
denoise_applied: bool,
/// Which demosaic develops the photograph: a network, or the classical
/// one. An edit: published as [`crate::learned_denoise`], captured,
/// stored and undone with the rest (FR-DEV-3c).
denoise_method: crate::learned_denoise::Method,
/// How strongly to denoise, 0–100; what is not taken goes back as grain.
denoise_strength: f32,
}
@@ -239,7 +239,7 @@ impl EditGraph {
lens_profile: None,
lens_profile_applied: true,
denoise_available: false,
denoise_applied: true,
denoise_method: crate::learned_denoise::Method::DEFAULT,
denoise_strength: 100.0,
}
}
@@ -425,7 +425,13 @@ impl EditGraph {
/// photograph that cannot take it is harmless and does nothing, as a
/// lens switch with no profile does.
pub fn denoise_applied(&self) -> bool {
self.denoise_applied
self.denoise_method.learned()
}
/// TRACES: FR-DEV-3g
/// Which demosaic is asked for.
pub fn denoise_method(&self) -> crate::learned_denoise::Method {
self.denoise_method
}
/// TRACES: FR-DEV-3g
@@ -625,7 +631,7 @@ impl EditGraph {
OpCapability {
id: desc.id,
label: desc.label,
active: self.denoise_applied,
active: self.denoise_applied(),
params: desc
.params
.iter()
@@ -743,7 +749,7 @@ impl EditGraph {
// Derived from the file, like the profile above.
denoise_available: _,
// Edits, in the state through `capabilities` like the lens switch.
denoise_applied: _,
denoise_method: _,
denoise_strength: _,
masks,
film,
@@ -818,7 +824,19 @@ impl EditGraph {
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
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::METHOD => {
self.denoise_method = crate::learned_denoise::Method::from_index(value)
}
// 0.21 and 0.22's switch (see `APPLY`): off is the classical
// demosaic, on is a network — the one already chosen, if any.
p if p == crate::learned_denoise::APPLY => {
use crate::learned_denoise::Method;
if value == 0.0 {
self.denoise_method = Method::Bilinear;
} else if !self.denoise_method.learned() {
self.denoise_method = Method::DEFAULT;
}
}
p if p == crate::learned_denoise::STRENGTH => {
self.denoise_strength = value.clamp(0.0, 100.0)
}
@@ -889,8 +907,9 @@ impl EditGraph {
pub fn param(&self, op: OpId, param: ParamId) -> Option<f32> {
if op == crate::learned_denoise::ID {
return match param {
p if p == crate::learned_denoise::METHOD => Some(self.denoise_method.index()),
p if p == crate::learned_denoise::APPLY => {
Some(if self.denoise_applied { 1.0 } else { 0.0 })
Some(if self.denoise_applied() { 1.0 } else { 0.0 })
}
p if p == crate::learned_denoise::STRENGTH => Some(self.denoise_strength),
p if p == crate::learned_denoise::GRAIN => Some(100.0 - self.denoise_strength),
@@ -943,9 +962,9 @@ impl EditGraph {
// a reset does not change which lens took the photograph. What returns
// to default is the answer to whether to use it, which is on.
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 = true;
// The learned denoise returns to its default network; whether it is
// available is the file's and stays.
self.denoise_method = crate::learned_denoise::Method::DEFAULT;
self.denoise_strength = 100.0;
}
@@ -2106,9 +2125,14 @@ mod tests {
.find(|c| c.id == learned_denoise::ID)
.expect("offered");
assert!(cap.active, "on by default");
assert_eq!(g.denoise_method(), learned_denoise::Method::Best);
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::METHOD,
learned_denoise::Method::Bilinear.index(),
);
g.set_param(learned_denoise::ID, learned_denoise::STRENGTH, 70.0);
assert!(!g.denoise_applied());
assert!((g.denoise_grain() - 0.3).abs() < 1e-6);
@@ -2117,6 +2141,58 @@ mod tests {
assert_eq!(g.denoise_grain(), 0.0);
}
#[test]
fn an_untouched_raw_writes_nothing_and_develops_through_the_best() {
// TRACES: FR-DEV-3g
use crate::learned_denoise::{self, Method};
let mut g = EditGraph::default_chain();
g.set_denoise_available(true);
assert_eq!(g.denoise_method(), Method::Best);
let stored = |g: &EditGraph| {
crate::Preset::capture_params(g)
.params()
.keys()
.any(|(op, _)| op == learned_denoise::ID.0)
};
assert!(!stored(&g), "the default is not written");
g.set_param(
learned_denoise::ID,
learned_denoise::METHOD,
Method::Fast.index(),
);
assert!(stored(&g), "a choice is");
assert!(
!crate::Preset::capture_params(&g).params().contains_key(&(
learned_denoise::ID.0.into(),
learned_denoise::APPLY.0.into()
)),
"and the old switch never is"
);
}
#[test]
fn an_edit_saved_with_the_switch_keeps_its_look() {
// TRACES: FR-DEV-3g
// 0.21 and 0.22 stored on or off; off is the classical demosaic, and
// on keeps a network already chosen.
use crate::learned_denoise::{self, Method};
let mut g = EditGraph::default_chain();
g.set_param(learned_denoise::ID, learned_denoise::APPLY, 0.0);
assert_eq!(g.denoise_method(), Method::Bilinear);
g.set_param(learned_denoise::ID, learned_denoise::APPLY, 1.0);
assert_eq!(g.denoise_method(), Method::DEFAULT);
g.set_param(
learned_denoise::ID,
learned_denoise::METHOD,
Method::Fast.index(),
);
g.set_param(learned_denoise::ID, learned_denoise::APPLY, 1.0);
assert_eq!(g.denoise_method(), Method::Fast);
// A number from a newer build with more methods is the default.
g.set_param(learned_denoise::ID, learned_denoise::METHOD, 9.0);
assert_eq!(g.denoise_method(), Method::DEFAULT);
}
#[test]
fn an_edit_saved_with_grain_keeps_its_look() {
// TRACES: FR-DEV-3g
@@ -2137,11 +2213,16 @@ mod tests {
let mut g = EditGraph::default_chain();
g.set_denoise_available(true);
g.set_param(learned_denoise::ID, learned_denoise::STRENGTH, 60.0);
g.set_param(
learned_denoise::ID,
learned_denoise::METHOD,
learned_denoise::Method::Medium.index(),
);
let state = g.state();
let mut h = EditGraph::default_chain();
h.set_denoise_available(true);
let _ = h.set_state(&state);
assert!(h.denoise_applied());
assert_eq!(h.denoise_method(), learned_denoise::Method::Medium);
assert!((h.denoise_grain() - 0.4).abs() < 1e-6);
}
}
+65 -6
View File
@@ -1,6 +1,6 @@
//! TRACES: FR-DEV-3g
//! The learned denoise's settings: whether to use it, and how much grain to
//! keep.
//! The learned denoise's settings: which network develops the photograph,
//! if any, and how much grain to keep.
//!
//! Not an [`crate::operation::Operation`]: the learned stage replaces the
//! demosaic and runs once per photograph, off the render path
@@ -17,6 +17,12 @@ use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, ParamDescriptor,
use crate::{OpId, ParamId};
pub const ID: OpId = OpId("learned_denoise");
/// TRACES: FR-DEV-3g
/// Which demosaic develops the photograph, a [`Method`] by index.
pub const METHOD: ParamId = ParamId("method");
/// What 0.21 and 0.22 stored instead of [`METHOD`]: on or off. Still read —
/// off is [`Method::Bilinear`], on is the default network — so an edit saved
/// by those releases keeps its look; never written, and not offered.
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
@@ -27,9 +33,52 @@ pub const STRENGTH: ParamId = ParamId("strength");
/// written, and not offered as a control.
pub const GRAIN: ParamId = ParamId("grain");
/// 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
/// TRACES: FR-DEV-3g
/// The demosaics a photograph can be developed with, in the order the
/// sidecar numbers them. Three networks that trade time for quality — the
/// same training, distilled into smaller students (docs/dev/denoise.md §13)
/// — and the classical demosaic, which is no network at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Method {
/// The classical demosaic: the noise stays.
Bilinear,
/// The smallest student: a quarter of the medium network's work.
Fast,
/// One network the size of the first release's.
Medium,
/// Two experts, one for flat areas and one for edges, and a gate.
Best,
}
impl Method {
pub const ALL: [Method; 4] = [Method::Bilinear, Method::Fast, Method::Medium, Method::Best];
pub const DEFAULT: Method = Method::Best;
/// The sidecar's number for it.
pub fn index(self) -> f32 {
Self::ALL.iter().position(|m| *m == self).unwrap_or(0) as f32
}
/// The method a stored number names; out of range is the default, as
/// from a newer build with more of them.
pub fn from_index(value: f32) -> Method {
let i = value.round();
if i >= 0.0 && (i as usize) < Self::ALL.len() {
Self::ALL[i as usize]
} else {
Self::DEFAULT
}
}
/// Whether a network runs at all.
pub fn learned(self) -> bool {
self != Method::Bilinear
}
}
/// The best network by default, at full strength: every Bayer raw is
/// developed from the learned demosaic, and the choice and the slider are
/// there to take it back, trade it for time, 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(|| {
@@ -37,7 +86,17 @@ pub(crate) static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
id: ID,
label: LocalizedKey("op.learned_denoise"),
params: vec![
ParamDescriptor::switch_on("apply", "param.learned_denoise.apply"),
ParamDescriptor::choice(
"method",
"param.learned_denoise.method",
vec![
LocalizedKey("param.learned_denoise.method.bilinear"),
LocalizedKey("param.learned_denoise.method.fast"),
LocalizedKey("param.learned_denoise.method.medium"),
LocalizedKey("param.learned_denoise.method.best"),
],
)
.with_default(Method::DEFAULT.index()),
ParamDescriptor::scalar(
"strength",
"param.learned_denoise.strength",
+69
View File
@@ -457,3 +457,72 @@ under Detail. So:
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.
## 13. Three networks and a method (after 0.22.0)
The photographer asked for a choice between quality and time. `Method` replaces the Apply switch:
`Bilinear`, `Fast`, `Medium`, `Best`, by index in that order, default `Best`. An untouched raw
writes nothing and develops through `Best`. `apply` is still read and never written: 0 is
`Bilinear`, 1 keeps a network already chosen or is the default. A number past the list, from a newer
build, reads as the default. A build before this one ignores `method` and develops through its own
network, which is the most an older peer can do.
**The networks** (darkroom-denoise, every one trained on the same data and noise as §11, plus 1,201
further frames cropped from the library and 6,000 drawn scenes — polygons, lines of one to four
photosites, text, gratings — rendered at 4× through a random affine and smooth displacement, so
edges fall off the photosite grid):
| Method | File | Network | Parameters | GMAC / MP | Halo |
|---|---|---|---|---|---|
| Best | `mosaic-best-1408.onnx` | two U-Nets of §11's shape (a flat expert from `m2`, an edge expert from the ×100 edge-weighted run) and a 128 k-parameter gate that blends them per photosite | 6.4 M | 110 | 256 |
| Medium | `mosaic-medium-1408.onnx` | §11's U-Net, distilled from Best (75 % its output, 25 % the truth) | 3.2 M | 48 | 192 |
| Fast | `mosaic-fast-1408.onnx` | widths 16-32-64-128, blocks 1-1-1-2, distilled the same way | 0.93 M | 11 | 192 |
The gate learned on its own to trust the edge expert at 0.77–0.88 on edges and not at all on flat
areas. The mixture's receptive field is the experts' plus the gate's, so it keeps the centre of a
1408 tile past a 256 halo, where the single networks keep 1024 past 192. `dr_denoise::Shipped`
carries each file's halo, and `TileNet::halo` hands it to the tiler.
**Quality.** PSNR after the display transform on 1,842 held-out crops, and the width of a hard
edge on the drawn chart at ISO 6400 (truth 0.80 photosites; lower is sharper):
| | ISO 400 | 1600 | 6400 | 25600 | Edge width |
|---|---|---|---|---|---|
| §11's network | 40.61 | 39.77 | 38.43 | 36.67 | 1.77 |
| Best | 40.69 | 39.84 | 38.47 | 36.71 | 0.82 |
| Medium | 40.59 | 39.75 | 38.40 | 36.65 | 1.30 |
| Fast | 39.90 | 39.06 | 37.54 | 35.33 | 1.84 |
| Bilinear | 36.15 | 33.26 | 28.72 | 23.83 | 2.15 |
On photographs the three are close; on hard edges Best is half as wide as §11's network and
Medium most of the way there. Fast costs a dB at high ISO and edges as soft as §11's.
**Speed**, a whole 20 MP 6D frame, the network alone, TensorRT fp16 on the laptop's RTX 3050
(uncapped: memory at 5 GHz), engine already built: Best 2.48 s, Medium 0.79 s, Fast 0.57 s. Decode
and the hot-pixel pass add about 0.5 s. The first build of each TensorRT engine takes 80 s (Fast) to
190 s (Best), in the background at first launch, cached after.
**Before the network, two passes changed since §11.**
- *A noise-aware repair* (`dr_denoise::repair`) after the app's hot-pixel pass: a photosite more
than 8σ beyond every same-colour neighbour *and* every adjacent photosite, and more than twice
each adjacent one, is clamped to the brightest of its same-colour neighbours; a dead one, to the
darkest. The ratio test is what spares a point of light, whose neighbours are lit too. The networks
were trained behind the same pass (the Python and Rust agree: 935 repairs on an ISO 25600 frame).
- *The tiler feeds the network without waiting*: tiles are gathered on every core by a producer
thread one tile ahead, and the output is written back in parallel from the runtime's own buffer.
0.14 s of tiler for a frame, which is what keeps Fast under a second.
**The Hexagon.** Each network has an `.a16w16.onnx` sibling made by `tools/quantise-models.sh
--ranges`, the ranges from darkroom-3e's gate (96 training tiles, a third at noise ×2 and ×4). On
the 6D gate A16W16 loses 0.00 dB for all three; with the noise scaled ×0.5–×4 at most 0.11 dB.
A16W8 holds the gate (≤ 0.27 dB) but loses 0.63 dB on Medium at ×4, so A16W16 stays the form.
**Cache.** Each network keys its own results (§7.1 keys on the model's file name), and the file is
hashed once at open, so changing the method never re-reads it. Choosing `Bilinear` keeps the
network's result in memory for the way back; changing to another network drops it, and coming back
reads the cache.
**Packaging.** All six files in the APK (`BUNDLED`, 23 entries, +44.6 MB, ~41 MB compressed); the
three f32 networks in the Arch package and the Windows installer, which stage `models/denoise` by
directory.
File diff suppressed because one or more lines are too long
+9 -4
View File
@@ -134,11 +134,16 @@ declined, and the InsightFace grant of D13).
| File | Source | Trained on | Used by |
|---|---|---|---|
| `denoise/mosaic-1408.onnx` | trained from scratch in the `darkroom-denoise` repository (2026-10-03, run `m2`, 60 000 steps) | 427 of the maintainer's own base-ISO Canon EOS 6D raws, with the 6D's measured noise added | the learned demosaic and denoise (FR-DEV-3g) |
| `denoise/mosaic-best-1408.onnx` | trained in the `darkroom-denoise` repository (2026-10-04, run `final`, 30 000 steps, from the experts of runs `m2` and `edges-100`) | 1,701 of the maintainer's own base-ISO raws and 6,000 synthetic scenes the repository draws itself, with the Canon EOS 6D's measured noise added | the learned demosaic and denoise, Best (FR-DEV-3g) |
| `denoise/mosaic-medium-1408.onnx` | distilled from `final` in the same repository (2026-10-04, run `student-m`, 20 000 steps, from `m2`) | the same | Medium |
| `denoise/mosaic-fast-1408.onnx` | distilled from `final` (2026-10-04, run `student-s`, 30 000 steps, from scratch) | the same | Fast |
A U-Net of plain 3×3 convolutions, ReLU, strided and transposed
U-Nets of plain 3×3 convolutions, ReLU, strided and transposed
convolutions and additive skips — no third-party architecture code or
weights — at a fixed `1×1×1408×1408` for `mosaic` and `sigma`, exported by
`python -m denoise.export` in `darkroom-denoise`. Trained only on
weights — and, for Best, two of them blended per photosite by a small gate
network of the same parts. Each at a fixed `1×1×1408×1408` for `mosaic`
and `sigma`, exported by `python -m denoise.export` in `darkroom-denoise`;
the `.a16w16.onnx` siblings are the same networks quantised for the
Hexagon by `tools/quantise-models.sh`. Trained only on
photographs the maintainer owns, so the weights carry no grant but the
project's own: GPL-3.0-or-later, like the code (denoise.md §10).
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11 -8
View File
@@ -124,12 +124,15 @@ package() {
fi
install -Dm644 "${_src}" "${pkgdir}/usr/share/darkroom/models/migan-512.onnx"
# The learned demosaic and denoise (the project's own weights, GPL —
# models/LICENCE.md). Same pointer check, same directory.
_src="models/denoise/mosaic-1408.onnx"
if [[ "$(stat -c%s "${_src}")" -lt 100000 ]]; then
echo "error: the denoise model is an LFS pointer — run: git lfs pull" >&2
return 1
fi
install -Dm644 "${_src}" "${pkgdir}/usr/share/darkroom/models/mosaic-1408.onnx"
# The learned demosaic and denoise, one network per method (the project's
# own weights, GPL — models/LICENCE.md). Same pointer check, same
# directory.
for _net in fast medium best; do
_src="models/denoise/mosaic-${_net}-1408.onnx"
if [[ "$(stat -c%s "${_src}")" -lt 100000 ]]; then
echo "error: the ${_net} denoise model is an LFS pointer — run: git lfs pull" >&2
return 1
fi
install -Dm644 "${_src}" "${pkgdir}/usr/share/darkroom/models/mosaic-${_net}-1408.onnx"
done
}
+5 -1
View File
@@ -41,7 +41,11 @@ TABLE = {
"migan-512": dict(dir="inpaint", form="a16w16", feed="migan"),
"xfeat-1024": dict(dir="keypoints", form="int8", feed="xfeat", rewrites=["unfold", "resize"]),
"xfeat-768": dict(dir="keypoints", form="int8", feed="xfeat", rewrites=["unfold", "resize"]),
"mosaic-1408": dict(dir="denoise", form="a16w16", feed=None, rewrites=["bayer"]),
"mosaic-fast-1408": dict(dir="denoise", form="a16w16", feed=None, rewrites=["bayer"]),
"mosaic-medium-1408": dict(dir="denoise", form="a16w16", feed=None, rewrites=["bayer"]),
# Exported with its packs as SpaceToDepth already; the rewrite finds
# nothing to do.
"mosaic-best-1408": dict(dir="denoise", form="a16w16", feed=None, rewrites=["bayer"]),
}
+1 -1
View File
@@ -2,7 +2,7 @@
# Produce the Hexagon's form of each model (docs/dev/inference.md §1.5, §5).
#
# ./tools/quantise-models.sh PHOTO_DIR [MODEL ...]
# ./tools/quantise-models.sh --ranges RANGES.json mosaic-1408
# ./tools/quantise-models.sh --ranges RANGES.json mosaic-medium-1408
#
# Writes `<stem>.<form>.onnx` beside each canonical file under models/: a QDQ
# graph from QNN's own quantisation config, per-channel weights, in the form
+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)]