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:
Generated
+1
@@ -1768,6 +1768,7 @@ dependencies = [
|
||||
"dr-types",
|
||||
"dr-xmp",
|
||||
"env_logger",
|
||||
"half",
|
||||
"i-slint-backend-testing",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
+15
-15
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
mod curves;
|
||||
mod denoise;
|
||||
mod denoise_cache;
|
||||
mod framing;
|
||||
mod history;
|
||||
mod mask_ops;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user