Compare commits
36
Commits
v0.20.0
...
release/0.21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9dfe66a70 | ||
|
|
ff0effbfe1 | ||
|
|
1a03cb52b4 | ||
|
|
ff4b30fbaa | ||
|
|
c73743394f | ||
|
|
872e35670c | ||
|
|
b562d7b1af | ||
|
|
3689b06c35 | ||
|
|
64ea44aefe | ||
|
|
32a4da0e94 | ||
|
|
33779a70bd | ||
|
|
185e134ead | ||
|
|
7ae1e27810 | ||
|
|
a4ff7ec2b9 | ||
|
|
b69fb3e191 | ||
|
|
7a09b640d7 | ||
|
|
8294e6b59f | ||
|
|
e12783da9f | ||
|
|
013596e1bd | ||
|
|
1e8594724e | ||
|
|
0730ef1016 | ||
|
|
db7593f3dc | ||
|
|
38d414912c | ||
|
|
b58873ef57 | ||
|
|
ababd628ed | ||
|
|
4eb7cf77f5 | ||
|
|
960014803a | ||
|
|
dd43f498fb | ||
|
|
ad6bb892f3 | ||
|
|
8ea3c3181a | ||
|
|
d8304d7c82 | ||
|
|
20b7bd7663 | ||
|
|
6b0d29cc15 | ||
|
|
eb91fa02c2 | ||
|
|
d4248bc0dd | ||
|
|
1f266a4478 |
@@ -490,7 +490,7 @@ jobs:
|
|||||||
# As many files as package.sh stages: everything but the READMEs in
|
# As many files as package.sh stages: everything but the READMEs in
|
||||||
# the directories it copies. A literal here went stale the first
|
# the directories it copies. A literal here went stale the first
|
||||||
# time a model was added.
|
# time a model was added.
|
||||||
WANT=$(find models/face models/scene models/inpaint -maxdepth 1 -type f ! -name README.md | wc -l)
|
WANT=$(find models/face models/scene models/inpaint models/denoise -maxdepth 1 -type f ! -name README.md | wc -l)
|
||||||
GOT=$(ls "$INST/models" | wc -l)
|
GOT=$(ls "$INST/models" | wc -l)
|
||||||
[ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT model files, installed $GOT"; exit 1; }
|
[ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT model files, installed $GOT"; exit 1; }
|
||||||
# The manual, and every picture it shows, counted the same way.
|
# The manual, and every picture it shows, counted the same way.
|
||||||
|
|||||||
Generated
+43
-25
@@ -1265,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-android"
|
name = "darkroom-android"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_logger",
|
"android_logger",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1278,7 +1278,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darkroom-desktop"
|
name = "darkroom-desktop"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1454,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-bench"
|
name = "dr-bench"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dr-catalog",
|
"dr-catalog",
|
||||||
@@ -1471,7 +1471,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-catalog"
|
name = "dr-catalog"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-face",
|
"dr-face",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1486,7 +1486,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-decode"
|
name = "dr-decode"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1498,9 +1498,26 @@ dependencies = [
|
|||||||
"zune-jpeg 0.4.21",
|
"zune-jpeg 0.4.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dr-denoise"
|
||||||
|
version = "0.21.0"
|
||||||
|
dependencies = [
|
||||||
|
"dr-decode",
|
||||||
|
"dr-gpu",
|
||||||
|
"dr-inference-engine",
|
||||||
|
"env_logger",
|
||||||
|
"log",
|
||||||
|
"ndarray",
|
||||||
|
"ort",
|
||||||
|
"pollster",
|
||||||
|
"serde",
|
||||||
|
"serde_norway",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-export"
|
name = "dr-export"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-gpu",
|
"dr-gpu",
|
||||||
@@ -1519,7 +1536,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-face"
|
name = "dr-face"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1532,7 +1549,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-film"
|
name = "dr-film"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1541,7 +1558,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-gpu"
|
name = "dr-gpu"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1559,7 +1576,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-inference-engine"
|
name = "dr-inference-engine"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"libloading",
|
"libloading",
|
||||||
@@ -1574,7 +1591,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ingest"
|
name = "dr-ingest"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1586,7 +1603,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-lens"
|
name = "dr-lens"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lensfun",
|
"lensfun",
|
||||||
"log",
|
"log",
|
||||||
@@ -1594,7 +1611,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pano"
|
name = "dr-pano"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
@@ -1608,7 +1625,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-pipeline"
|
name = "dr-pipeline"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -1617,7 +1634,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-plat"
|
name = "dr-plat"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android-native-keyring-store",
|
"android-native-keyring-store",
|
||||||
"dr-types",
|
"dr-types",
|
||||||
@@ -1633,7 +1650,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-preset-xmp"
|
name = "dr-preset-xmp"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-pipeline",
|
"dr-pipeline",
|
||||||
"log",
|
"log",
|
||||||
@@ -1643,7 +1660,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-segment"
|
name = "dr-segment"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-inference-engine",
|
"dr-inference-engine",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@@ -1656,7 +1673,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync"
|
name = "dr-sync"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-plat",
|
"dr-plat",
|
||||||
@@ -1670,7 +1687,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-folder"
|
name = "dr-sync-folder"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-sync",
|
"dr-sync",
|
||||||
@@ -1682,7 +1699,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-sync-nextcloud"
|
name = "dr-sync-nextcloud"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
@@ -1704,7 +1721,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-thumbs"
|
name = "dr-thumbs"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"jpeg-encoder",
|
"jpeg-encoder",
|
||||||
@@ -1716,7 +1733,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-types"
|
name = "dr-types"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1725,12 +1742,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-ui"
|
name = "dr-ui"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"dr-catalog",
|
"dr-catalog",
|
||||||
"dr-decode",
|
"dr-decode",
|
||||||
|
"dr-denoise",
|
||||||
"dr-export",
|
"dr-export",
|
||||||
"dr-face",
|
"dr-face",
|
||||||
"dr-film",
|
"dr-film",
|
||||||
@@ -1773,7 +1791,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dr-xmp"
|
name = "dr-xmp"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dr-types",
|
"dr-types",
|
||||||
"log",
|
"log",
|
||||||
@@ -7107,7 +7125,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "traceability"
|
name = "traceability"
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
|
|||||||
+15
-1
@@ -5,6 +5,7 @@ members = [
|
|||||||
"core/dr-catalog",
|
"core/dr-catalog",
|
||||||
"core/dr-thumbs",
|
"core/dr-thumbs",
|
||||||
"core/dr-decode",
|
"core/dr-decode",
|
||||||
|
"core/dr-denoise",
|
||||||
"core/dr-export",
|
"core/dr-export",
|
||||||
"core/dr-face",
|
"core/dr-face",
|
||||||
"core/dr-film",
|
"core/dr-film",
|
||||||
@@ -32,7 +33,7 @@ members = [
|
|||||||
exclude = ["third_party"]
|
exclude = ["third_party"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.20.0"
|
version = "0.21.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.92"
|
rust-version = "1.92"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
@@ -44,6 +45,7 @@ dr-types = { path = "core/dr-types" }
|
|||||||
dr-catalog = { path = "core/dr-catalog" }
|
dr-catalog = { path = "core/dr-catalog" }
|
||||||
dr-thumbs = { path = "core/dr-thumbs" }
|
dr-thumbs = { path = "core/dr-thumbs" }
|
||||||
dr-decode = { path = "core/dr-decode" }
|
dr-decode = { path = "core/dr-decode" }
|
||||||
|
dr-denoise = { path = "core/dr-denoise" }
|
||||||
dr-export = { path = "core/dr-export" }
|
dr-export = { path = "core/dr-export" }
|
||||||
# Stated explicitly for the same reason as `dr-segment` below: no dependant
|
# Stated explicitly for the same reason as `dr-segment` below: no dependant
|
||||||
# should drag in an ONNX runtime by accident. Members opt in with
|
# should drag in an ONNX runtime by accident. Members opt in with
|
||||||
@@ -276,6 +278,18 @@ opt-level = 0
|
|||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|
||||||
|
# A release build that can say where it panicked: line tables, so a crash
|
||||||
|
# record's backtrace (`dr_plat::crash`) reads `file.rs:123` rather than bare
|
||||||
|
# addresses. The macOS build uses it (docs/dev/macos.md) — no one here can
|
||||||
|
# reproduce a Mac bug, so its reports carry what a debugger would have — at
|
||||||
|
# the price of a larger binary and no slower code. On macOS the tables land
|
||||||
|
# in a `.dSYM` beside the executable (rustc's default `packed`), and the
|
||||||
|
# bundle must carry that directory next to the binary for the backtrace to
|
||||||
|
# find it.
|
||||||
|
[profile.diagnostic]
|
||||||
|
inherits = "release"
|
||||||
|
debug = "line-tables-only"
|
||||||
|
|
||||||
# Three upstream crates carry a local patch: wgpu-hal and Slint's Skia
|
# Three upstream crates carry a local patch: wgpu-hal and Slint's Skia
|
||||||
# renderer so that the Android build can draw with wgpu on a rotated display
|
# renderer so that the Android build can draw with wgpu on a rotated display
|
||||||
# (technical-debt.md TD-1), and rawler so that a linear DNG wider than 16 700
|
# (technical-debt.md TD-1), and rawler so that a linear DNG wider than 16 700
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ controls, its place in the chain and its tests.
|
|||||||
|
|
||||||
## Where it stands
|
## Where it stands
|
||||||
|
|
||||||
**0.20.0**, thirty-four tagged releases in. 193 numbered requirements in
|
**0.21.0**, thirty-five tagged releases in. 193 numbered requirements in
|
||||||
scope, 85% of them claimed by code and [traced to it](docs/dev/traceability.md);
|
scope, 85% of them claimed by code and [traced to it](docs/dev/traceability.md);
|
||||||
the rest are written down rather than merely absent.
|
the rest are written down rather than merely absent.
|
||||||
|
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
|
|||||||
// The int8 forms beside the three detectors are what the Hexagon runs
|
// The int8 forms beside the three detectors are what the Hexagon runs
|
||||||
// (docs/dev/inference.md §5); the engine loads the sibling when the probe
|
// (docs/dev/inference.md §5); the engine loads the sibling when the probe
|
||||||
// chose that rung and ignores it otherwise.
|
// chose that rung and ignores it otherwise.
|
||||||
const BUNDLED: [(&std::ffi::CStr, &str); 14] = [
|
const BUNDLED: [(&std::ffi::CStr, &str); 15] = [
|
||||||
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
|
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
|
||||||
(
|
(
|
||||||
c"models/scrfd_500m_640.int8.onnx",
|
c"models/scrfd_500m_640.int8.onnx",
|
||||||
@@ -360,6 +360,7 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
|
|||||||
(c"models/categories.txt", "categories.txt"),
|
(c"models/categories.txt", "categories.txt"),
|
||||||
// The panorama border filler (FR-MRG-4); MIT, 28 MB.
|
// The panorama border filler (FR-MRG-4); MIT, 28 MB.
|
||||||
(c"models/migan-512.onnx", "migan-512.onnx"),
|
(c"models/migan-512.onnx", "migan-512.onnx"),
|
||||||
|
(c"models/mosaic-1408.onnx", "mosaic-1408.onnx"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let dir = dr_ui::shared_face_models_dir();
|
let dir = dr_ui::shared_face_models_dir();
|
||||||
|
|||||||
@@ -15,6 +15,22 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use dr_plat::diagnostics::Installed;
|
use dr_plat::diagnostics::Installed;
|
||||||
|
|
||||||
|
/// What the log keeps when `RUST_LOG` does not say.
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
const DEFAULT_LOG: &str =
|
||||||
|
"info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn,rawler=warn";
|
||||||
|
|
||||||
|
/// The same, and `debug` from this application's own crates and from ONNX
|
||||||
|
/// Runtime, whose `debug` is how many nodes each provider took
|
||||||
|
/// (docs/dev/macos.md). Nobody here runs a Mac: every macOS build is in
|
||||||
|
/// the hands of someone who can send us a log and cannot attach a debugger,
|
||||||
|
/// so the log is written as if for a debug build. `dr_` is a prefix, and
|
||||||
|
/// `env_logger` matches directives by prefix, so it names every `dr-*`
|
||||||
|
/// crate — present and future — without naming a dependency.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
const DEFAULT_LOG: &str = "info,dr_=debug,darkroom_desktop=debug,onnxruntime=debug,\
|
||||||
|
wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn,rawler=warn";
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
// TRACES: FR-PLAT-WIN-3
|
// TRACES: FR-PLAT-WIN-3
|
||||||
// Before the logger, the crash hook and everything else: this exists so a
|
// Before the logger, the crash hook and everything else: this exists so a
|
||||||
@@ -33,10 +49,9 @@ fn main() -> anyhow::Result<()> {
|
|||||||
// (NFR-OPS-1). `filter()` is asked afterwards because the environment may
|
// (NFR-OPS-1). `filter()` is asked afterwards because the environment may
|
||||||
// have overridden the default below, and the file must not be quieter than
|
// have overridden the default below, and the file must not be quieter than
|
||||||
// the terminal.
|
// the terminal.
|
||||||
let console = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(
|
let console =
|
||||||
"info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn,rawler=warn",
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(DEFAULT_LOG))
|
||||||
))
|
.build();
|
||||||
.build();
|
|
||||||
let level = console.filter();
|
let level = console.filter();
|
||||||
let logging = dr_plat::diagnostics::install(Box::new(console), level);
|
let logging = dr_plat::diagnostics::install(Box::new(console), level);
|
||||||
|
|
||||||
@@ -109,5 +124,21 @@ fn runtime_dirs() -> Vec<PathBuf> {
|
|||||||
PathBuf::from("/usr/lib/darkroom"),
|
PathBuf::from("/usr/lib/darkroom"),
|
||||||
PathBuf::from("/usr/lib"),
|
PathBuf::from("/usr/lib"),
|
||||||
]);
|
]);
|
||||||
|
// An app bundle keeps its libraries in `Contents/Frameworks`, beside
|
||||||
|
// the `Contents/MacOS` the executable is in; then Homebrew's
|
||||||
|
// `onnxruntime`, Apple silicon's prefix before Intel's. Homebrew's build
|
||||||
|
// may lack CoreML, which the probe finds out for itself.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
|
if let Some(bin) = exe.parent() {
|
||||||
|
dirs.push(bin.join("../Frameworks"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirs.extend([
|
||||||
|
PathBuf::from("/opt/homebrew/lib"),
|
||||||
|
PathBuf::from("/usr/local/lib"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
dirs
|
dirs
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ fn main() {
|
|||||||
println!("black {:?}", raw.black_level);
|
println!("black {:?}", raw.black_level);
|
||||||
println!("white {}", raw.white_level);
|
println!("white {}", raw.white_level);
|
||||||
println!("wb_coeffs {:?}", raw.wb_coeffs);
|
println!("wb_coeffs {:?}", raw.wb_coeffs);
|
||||||
|
match dr_decode::noise_profile(&bytes) {
|
||||||
|
Some(p) => println!("noise profile {p:?} ((S, O) per plane)"),
|
||||||
|
None => println!("noise profile none"),
|
||||||
|
}
|
||||||
|
|
||||||
match raw.color_matrix {
|
match raw.color_matrix {
|
||||||
Some(m) => {
|
Some(m) => {
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ pub struct Dcp {
|
|||||||
/// `ProfileToneCurve`, as stored: input/output pairs. Carried so a copy
|
/// `ProfileToneCurve`, as stored: input/output pairs. Carried so a copy
|
||||||
/// keeps it, never applied — tone is the view transform's (D19, D20).
|
/// keeps it, never applied — tone is the view transform's (D19, D20).
|
||||||
pub tone_curve: Option<Vec<f32>>,
|
pub tone_curve: Option<Vec<f32>>,
|
||||||
|
/// `BaselineExposureOffset`, in stops: the profile's correction to the
|
||||||
|
/// file's `BaselineExposure` (camera-profiles.md §11). A profile copied
|
||||||
|
/// out of a DNG carries that DNG's baseline here, so a raw with no
|
||||||
|
/// baseline of its own lands at the same brightness.
|
||||||
|
pub baseline_exposure_offset: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Dcp {
|
impl Dcp {
|
||||||
@@ -120,6 +125,7 @@ impl Dcp {
|
|||||||
.get_entry(DngTag::ProfileToneCurve)
|
.get_entry(DngTag::ProfileToneCurve)
|
||||||
.and_then(|e| floats(&e.value))
|
.and_then(|e| floats(&e.value))
|
||||||
.filter(|v| v.len() >= 4 && v.len() % 2 == 0),
|
.filter(|v| v.len() >= 4 && v.len() % 2 == 0),
|
||||||
|
baseline_exposure_offset: stops(ifd, DngTag::BaselineExposureOffset),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +224,10 @@ impl Dcp {
|
|||||||
origin,
|
origin,
|
||||||
hue_sat: hue_sat.filter(|t| !t.is_identity()),
|
hue_sat: hue_sat.filter(|t| !t.is_identity()),
|
||||||
look: self.look.clone().filter(|t| !t.is_identity()),
|
look: self.look.clone().filter(|t| !t.is_identity()),
|
||||||
|
tone_curve: self
|
||||||
|
.tone_curve
|
||||||
|
.as_deref()
|
||||||
|
.and_then(dr_types::tone::resample_tone_curve),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +315,15 @@ impl Dcp {
|
|||||||
if let Some(curve) = &self.tone_curve {
|
if let Some(curve) = &self.tone_curve {
|
||||||
dir.add_value(DngTag::ProfileToneCurve, Value::Float(curve.clone()));
|
dir.add_value(DngTag::ProfileToneCurve, Value::Float(curve.clone()));
|
||||||
}
|
}
|
||||||
|
if self.baseline_exposure_offset != 0.0 {
|
||||||
|
dir.add_value(
|
||||||
|
DngTag::BaselineExposureOffset,
|
||||||
|
Value::SRational(vec![SRational::new(
|
||||||
|
(self.baseline_exposure_offset * 100.0).round() as i32,
|
||||||
|
100,
|
||||||
|
)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
writer.build(dir).map_err(|e| e.to_string())?;
|
writer.build(dir).map_err(|e| e.to_string())?;
|
||||||
let mut bytes = cursor.into_inner();
|
let mut bytes = cursor.into_inner();
|
||||||
// The writer stamps TIFF's 42 in its own byte order; a profile is the
|
// The writer stamps TIFF's 42 in its own byte order; a profile is the
|
||||||
@@ -340,6 +359,15 @@ fn string(ifd: &IFD, tag: DngTag) -> Option<String> {
|
|||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A single rational tag in stops, zero where absent or unreadable — the
|
||||||
|
/// DNG specification's default for both exposure tags.
|
||||||
|
fn stops(ifd: &IFD, tag: DngTag) -> f32 {
|
||||||
|
ifd.get_entry(tag)
|
||||||
|
.and_then(|e| e.value.get_f32(0).ok().flatten())
|
||||||
|
.filter(|v| v.is_finite())
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
fn floats(value: &Value) -> Option<Vec<f32>> {
|
fn floats(value: &Value) -> Option<Vec<f32>> {
|
||||||
(0..value.count())
|
(0..value.count())
|
||||||
.map(|i| value.get_f32(i).ok().flatten())
|
.map(|i| value.get_f32(i).ok().flatten())
|
||||||
@@ -507,7 +535,12 @@ pub fn embedded_in(bytes: &[u8]) -> Option<Dcp> {
|
|||||||
.ifd(rawler::decoders::WellKnownIFD::Root)
|
.ifd(rawler::decoders::WellKnownIFD::Root)
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()?;
|
.flatten()?;
|
||||||
Dcp::from_ifd(&root)
|
// The copy carries the file's baseline as its offset, so a raw from the
|
||||||
|
// same body that has no baseline of its own — a CR2 — gets the total the
|
||||||
|
// DNG renders at (camera-profiles.md §11).
|
||||||
|
let mut profile = Dcp::from_ifd(&root)?;
|
||||||
|
profile.baseline_exposure_offset += stops(&root, DngTag::BaselineExposure);
|
||||||
|
Some(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3e
|
/// TRACES: FR-DEV-3e
|
||||||
@@ -518,6 +551,9 @@ pub struct Resolved {
|
|||||||
pub profile: Option<CameraProfile>,
|
pub profile: Option<CameraProfile>,
|
||||||
pub tables: Option<Arc<ProfileTables>>,
|
pub tables: Option<Arc<ProfileTables>>,
|
||||||
pub embedded: Option<Arc<Dcp>>,
|
pub embedded: Option<Arc<Dcp>>,
|
||||||
|
/// Stops to add at render: the file's `BaselineExposure` plus the
|
||||||
|
/// chosen profile's `BaselineExposureOffset` (camera-profiles.md §11).
|
||||||
|
pub baseline_exposure: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3e
|
/// TRACES: FR-DEV-3e
|
||||||
@@ -532,6 +568,7 @@ pub fn resolve(
|
|||||||
model: &str,
|
model: &str,
|
||||||
) -> Resolved {
|
) -> Resolved {
|
||||||
let embedded = root.and_then(Dcp::from_ifd).map(Arc::new);
|
let embedded = root.and_then(Dcp::from_ifd).map(Arc::new);
|
||||||
|
let file_baseline = root.map_or(0.0, |r| stops(r, DngTag::BaselineExposure));
|
||||||
if let Some(dcp) = &embedded {
|
if let Some(dcp) = &embedded {
|
||||||
let tables = matrices
|
let tables = matrices
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -541,6 +578,7 @@ pub fn resolve(
|
|||||||
return Resolved {
|
return Resolved {
|
||||||
profile: matrices,
|
profile: matrices,
|
||||||
tables,
|
tables,
|
||||||
|
baseline_exposure: file_baseline + dcp.baseline_exposure_offset,
|
||||||
embedded,
|
embedded,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -553,6 +591,7 @@ pub fn resolve(
|
|||||||
tables: (!tables.is_empty()).then(|| Arc::new(tables)),
|
tables: (!tables.is_empty()).then(|| Arc::new(tables)),
|
||||||
profile: Some(own),
|
profile: Some(own),
|
||||||
embedded: None,
|
embedded: None,
|
||||||
|
baseline_exposure: file_baseline + dcp.baseline_exposure_offset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -560,6 +599,7 @@ pub fn resolve(
|
|||||||
profile: matrices,
|
profile: matrices,
|
||||||
tables: None,
|
tables: None,
|
||||||
embedded: None,
|
embedded: None,
|
||||||
|
baseline_exposure: file_baseline,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,6 +649,7 @@ mod tests {
|
|||||||
],
|
],
|
||||||
look: Some(table(4, 2, 3, [0.0, 1.2, 0.95])),
|
look: Some(table(4, 2, 3, [0.0, 1.2, 0.95])),
|
||||||
tone_curve: Some(vec![0.0, 0.0, 0.5, 0.6, 1.0, 1.0]),
|
tone_curve: Some(vec![0.0, 0.0, 0.5, 0.6, 1.0, 1.0]),
|
||||||
|
baseline_exposure_offset: 0.25,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,6 +668,7 @@ mod tests {
|
|||||||
assert_eq!(back.unique_camera_model, original.unique_camera_model);
|
assert_eq!(back.unique_camera_model, original.unique_camera_model);
|
||||||
assert_eq!(back.illuminants, original.illuminants);
|
assert_eq!(back.illuminants, original.illuminants);
|
||||||
assert_eq!(back.tone_curve, original.tone_curve);
|
assert_eq!(back.tone_curve, original.tone_curve);
|
||||||
|
assert_eq!(back.baseline_exposure_offset, 0.25);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
back.forward_matrix, original.forward_matrix,
|
back.forward_matrix, original.forward_matrix,
|
||||||
"four decimals, as the file has"
|
"four decimals, as the file has"
|
||||||
@@ -746,6 +788,11 @@ mod tests {
|
|||||||
assert_eq!(tables.origin, ProfileOrigin::Embedded);
|
assert_eq!(tables.origin, ProfileOrigin::Embedded);
|
||||||
assert_eq!(tables.name, "Adobe Standard");
|
assert_eq!(tables.name, "Adobe Standard");
|
||||||
assert!(tables.hue_sat.is_some() && tables.look.is_some());
|
assert!(tables.hue_sat.is_some() && tables.look.is_some());
|
||||||
|
assert!(
|
||||||
|
tables.tone_curve.is_none(),
|
||||||
|
"Adobe Standard has no curve of its own"
|
||||||
|
);
|
||||||
|
assert_eq!(raw.baseline_exposure, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -147,6 +147,12 @@ pub struct RawImage {
|
|||||||
/// renders a decoded file renders it through the same profile without
|
/// renders a decoded file renders it through the same profile without
|
||||||
/// having to be told — see camera-profiles.md §3.
|
/// having to be told — see camera-profiles.md §3.
|
||||||
pub profile_tables: Option<std::sync::Arc<dr_types::ProfileTables>>,
|
pub profile_tables: Option<std::sync::Arc<dr_types::ProfileTables>>,
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// Stops the render adds before anything else: the file's
|
||||||
|
/// `BaselineExposure` plus the profile's `BaselineExposureOffset`
|
||||||
|
/// (camera-profiles.md §11). Applied by the GPU side as a gain on the
|
||||||
|
/// camera matrix; `color_matrix` itself stays the file's.
|
||||||
|
pub baseline_exposure: f32,
|
||||||
/// The body, as rawler cleans the names: what `Make`/`Model` say, and
|
/// The body, as rawler cleans the names: what `Make`/`Model` say, and
|
||||||
/// what a `.dcp`'s `UniqueCameraModel` is matched against.
|
/// what a `.dcp`'s `UniqueCameraModel` is matched against.
|
||||||
pub make: String,
|
pub make: String,
|
||||||
@@ -544,6 +550,17 @@ pub(crate) fn parse_exif_offset(s: &str) -> Option<i32> {
|
|||||||
Some(sign * (h * 60 + m))
|
Some(sign * (h * 60 + m))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3g
|
||||||
|
/// The DNG `NoiseProfile` of a file, if it carries one: `(S, O)` per CFA
|
||||||
|
/// colour plane, variance `S·x + O` in black-to-white normalised units. See
|
||||||
|
/// [`profile::read_noise_profile`]. Reads the header, not the image.
|
||||||
|
pub fn noise_profile(bytes: &[u8]) -> Option<Vec<(f32, f32)>> {
|
||||||
|
use rawler::rawsource::RawSource;
|
||||||
|
let source = RawSource::new_from_slice(bytes);
|
||||||
|
let decoder = rawler::get_decoder(&source).ok()?;
|
||||||
|
profile::read_noise_profile(decoder.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-RAW-3 | FR-EXP-9
|
/// TRACES: FR-RAW-3 | FR-EXP-9
|
||||||
/// Fully decode sensor data.
|
/// Fully decode sensor data.
|
||||||
///
|
///
|
||||||
@@ -591,6 +608,7 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
|||||||
let dcp::Resolved {
|
let dcp::Resolved {
|
||||||
profile,
|
profile,
|
||||||
tables: profile_tables,
|
tables: profile_tables,
|
||||||
|
baseline_exposure,
|
||||||
..
|
..
|
||||||
} = dcp::resolve(
|
} = dcp::resolve(
|
||||||
profile,
|
profile,
|
||||||
@@ -689,6 +707,7 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
|||||||
samples_per_pixel,
|
samples_per_pixel,
|
||||||
profile,
|
profile,
|
||||||
profile_tables,
|
profile_tables,
|
||||||
|
baseline_exposure,
|
||||||
make: image.camera.clean_make.clone(),
|
make: image.camera.clean_make.clone(),
|
||||||
model: image.camera.clean_model.clone(),
|
model: image.camera.clean_model.clone(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -740,6 +740,35 @@ pub fn read_dng_matrices(decoder: &dyn rawler::decoders::Decoder) -> DngMatrices
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3g
|
||||||
|
/// The DNG `NoiseProfile` tag (51041): the converter's measured noise for
|
||||||
|
/// this body at this ISO, as `(S, O)` per CFA colour plane, so that a
|
||||||
|
/// photosite's variance is `S·x + O` with `x` normalised black-to-white.
|
||||||
|
///
|
||||||
|
/// One pair means all planes share it. `None` where the file has no such
|
||||||
|
/// tag — every proprietary raw, and DNGs from converters that do not measure
|
||||||
|
/// — or where a value is not a finite non-negative number. The learned
|
||||||
|
/// denoise's second-best noise source (denoise.md §3.3), after a measured
|
||||||
|
/// table for the body.
|
||||||
|
pub fn read_noise_profile(decoder: &dyn rawler::decoders::Decoder) -> Option<Vec<(f32, f32)>> {
|
||||||
|
use rawler::decoders::WellKnownIFD;
|
||||||
|
use rawler::tags::DngTag;
|
||||||
|
|
||||||
|
let ifd = decoder.ifd(WellKnownIFD::Root).ok()??;
|
||||||
|
let entry = ifd.get_entry_recursive(DngTag::NoiseProfile)?;
|
||||||
|
let n = entry.count() as usize;
|
||||||
|
if n < 2 || !n.is_multiple_of(2) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let pairs: Vec<(f32, f32)> = (0..n / 2)
|
||||||
|
.map(|i| (entry.force_f32(2 * i), entry.force_f32(2 * i + 1)))
|
||||||
|
.collect();
|
||||||
|
pairs
|
||||||
|
.iter()
|
||||||
|
.all(|(s, o)| s.is_finite() && o.is_finite() && *s >= 0.0 && *o >= 0.0)
|
||||||
|
.then_some(pairs)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[package]
|
||||||
|
name = "dr-denoise"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
dr-decode.workspace = true
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_norway.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
log.workspace = true
|
||||||
|
|
||||||
|
# The network runs under the inference engine like every other model
|
||||||
|
# (docs/dev/inference.md): `ort` is the API, the engine picks the rung.
|
||||||
|
# Optional so the noise model and the tiling test without a runtime.
|
||||||
|
ort = { workspace = true, optional = true }
|
||||||
|
dr-inference-engine = { workspace = true, optional = true }
|
||||||
|
ndarray = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["onnx"]
|
||||||
|
onnx = ["dep:ort", "dep:dr-inference-engine", "dep:ndarray"]
|
||||||
|
# A real ONNX Runtime from disk rather than tract alone, as the app links it.
|
||||||
|
native = ["onnx", "dr-inference-engine/native"]
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# The example repairs hot photosites with the app's own pass, as develop will.
|
||||||
|
dr-gpu.workspace = true
|
||||||
|
pollster.workspace = true
|
||||||
|
env_logger.workspace = true
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
//! Denoise one RAW file end to end, as develop will, and time it.
|
||||||
|
//!
|
||||||
|
//! ```sh
|
||||||
|
//! DARKROOM_ORT_DIR=~/.local/share/darkroom/runtime \
|
||||||
|
//! cargo run --release -p dr-denoise --features native --example denoise_raw -- IMG.CR2 out
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 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
|
||||||
|
//! 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
|
||||||
|
//! at an ONNX Runtime build; the engine's cache goes to `DR_ENGINE_CACHE` or
|
||||||
|
//! a temporary directory.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use dr_denoise::onnx::OnnxNet;
|
||||||
|
use dr_inference_engine::{Config, Role};
|
||||||
|
|
||||||
|
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");
|
||||||
|
std::process::exit(2);
|
||||||
|
};
|
||||||
|
let model =
|
||||||
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../models/denoise/mosaic-1408.onnx");
|
||||||
|
let cache = std::env::var_os("DR_ENGINE_CACHE")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| std::env::temp_dir().join("dr-denoise-engines"));
|
||||||
|
let started = Instant::now();
|
||||||
|
dr_inference_engine::init(Config {
|
||||||
|
runtime_dirs: std::env::var_os("DARKROOM_ORT_DIR")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
cache_dir: cache,
|
||||||
|
models: vec![(Role::Denoiser, model.clone())],
|
||||||
|
embedded: Vec::new(),
|
||||||
|
ceiling: None,
|
||||||
|
threads: 0,
|
||||||
|
decay: Duration::ZERO,
|
||||||
|
});
|
||||||
|
// Wait for the probe and the engine build, so the timing below is the
|
||||||
|
// rung this machine settles on, not the fallback used while it compiles.
|
||||||
|
// The probe starts on its own thread; give it a moment to say so.
|
||||||
|
std::thread::sleep(Duration::from_secs(1));
|
||||||
|
loop {
|
||||||
|
let s = dr_inference_engine::status();
|
||||||
|
if !s.probing && s.engines.0 >= s.engines.1 {
|
||||||
|
println!(
|
||||||
|
"engine {} ({:.1} s to settle)",
|
||||||
|
s.line(),
|
||||||
|
started.elapsed().as_secs_f64()
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = std::fs::read(&input).expect("read raw");
|
||||||
|
let t = Instant::now();
|
||||||
|
let mut raw = dr_decode::decode(&bytes).expect("decode");
|
||||||
|
let meta = dr_decode::metadata(&bytes).expect("metadata");
|
||||||
|
let decode = t.elapsed();
|
||||||
|
|
||||||
|
let t = Instant::now();
|
||||||
|
let ctx =
|
||||||
|
pollster::block_on(dr_gpu::GpuContext::new_headless()).expect("GPU for the hot-pixel pass");
|
||||||
|
let repaired = dr_gpu::Demosaicer::new(&ctx)
|
||||||
|
.expect("demosaicer")
|
||||||
|
.repair_hot_pixels(&mut raw)
|
||||||
|
.expect("repair");
|
||||||
|
let repair = t.elapsed();
|
||||||
|
|
||||||
|
let noise = dr_denoise::noise::for_frame(&raw, &bytes, meta.iso)
|
||||||
|
.expect("no noise source for this frame");
|
||||||
|
println!(
|
||||||
|
"frame {} {} ISO {:?}, {}×{}, {:?}, {repaired} hot photosites repaired",
|
||||||
|
raw.make, raw.model, meta.iso, raw.crop.width, raw.crop.height, raw.cfa_pattern
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"noise {} — σ at 10 % grey (G) {:.5}, read {:.5}, row {:.5}, col {:.5}",
|
||||||
|
noise.source.label(),
|
||||||
|
noise.sigma(1, 0.1),
|
||||||
|
noise.o[1].sqrt(),
|
||||||
|
noise.row,
|
||||||
|
noise.col
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut net = OnnxNet::from_path(&model).expect("model");
|
||||||
|
println!(
|
||||||
|
"rung {}",
|
||||||
|
net.rung().map(|r| r.label()).unwrap_or("?")
|
||||||
|
);
|
||||||
|
let t = Instant::now();
|
||||||
|
let rgb = dr_denoise::denoise(&raw, &noise, &mut net, &mut |done, total| {
|
||||||
|
eprint!("\rtile {done}/{total}");
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.expect("denoise")
|
||||||
|
.expect("not cancelled");
|
||||||
|
let run = t.elapsed();
|
||||||
|
eprintln!();
|
||||||
|
println!(
|
||||||
|
"time decode {:.2} s · hot pixels {:.2} s · network {:.2} s ({:.1} MP)",
|
||||||
|
decode.as_secs_f64(),
|
||||||
|
repair.as_secs_f64(),
|
||||||
|
run.as_secs_f64(),
|
||||||
|
(raw.crop.width * raw.crop.height) as f64 / 1e6
|
||||||
|
);
|
||||||
|
|
||||||
|
let (h, w) = (raw.crop.height as usize, raw.crop.width as usize);
|
||||||
|
let mut npy = Vec::with_capacity(rgb.len() * 4 + 128);
|
||||||
|
let mut header =
|
||||||
|
format!("{{'descr': '<f4', 'fortran_order': False, 'shape': ({h}, {w}, 3), }}");
|
||||||
|
while (10 + header.len() + 1) % 64 != 0 {
|
||||||
|
header.push(' ');
|
||||||
|
}
|
||||||
|
header.push('\n');
|
||||||
|
npy.extend_from_slice(b"\x93NUMPY\x01\x00");
|
||||||
|
npy.extend_from_slice(&(header.len() as u16).to_le_bytes());
|
||||||
|
npy.extend_from_slice(header.as_bytes());
|
||||||
|
for v in &rgb {
|
||||||
|
npy.extend_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
std::fs::write(format!("{out}.npy"), npy).expect("write");
|
||||||
|
println!("wrote {out}.npy");
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//! TRACES: FR-DEV-3g
|
||||||
|
//! Learned demosaic and denoise on the raw mosaic (docs/dev/denoise.md).
|
||||||
|
//!
|
||||||
|
//! A network trained on the library's own base-ISO raws with the 6D's
|
||||||
|
//! measured noise added takes the repaired, normalised mosaic and a σ for
|
||||||
|
//! every photosite, and returns linear camera RGB at full resolution — the
|
||||||
|
//! texture the classical demosaic would have produced, with the noise gone.
|
||||||
|
//! It replaces the demosaic box; nothing downstream changes (§2).
|
||||||
|
//!
|
||||||
|
//! - [`noise`] says how noisy each photosite is, from the best source the
|
||||||
|
//! frame has.
|
||||||
|
//! - [`tile`] runs a fixed-shape network over a whole frame, exactly.
|
||||||
|
//! - [`onnx`] is that network under the inference engine.
|
||||||
|
//!
|
||||||
|
//! The input must already have been through the app's hot-pixel pass
|
||||||
|
//! (`dr_gpu::Demosaicer::repair_hot_pixels`): the noise model was fitted
|
||||||
|
//! with what that pass removes left out.
|
||||||
|
|
||||||
|
pub mod noise;
|
||||||
|
#[cfg(feature = "onnx")]
|
||||||
|
pub mod onnx;
|
||||||
|
pub mod tile;
|
||||||
|
|
||||||
|
use dr_decode::RawImage;
|
||||||
|
|
||||||
|
pub use noise::{NoiseModel, Source};
|
||||||
|
pub use tile::{TileNet, HALO};
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum DenoiseError {
|
||||||
|
#[error("the network cannot take this photograph: {0}")]
|
||||||
|
Unsupported(String),
|
||||||
|
#[error("the denoise model misbehaved: {0}")]
|
||||||
|
Model(String),
|
||||||
|
#[error("could not read the denoise model: {0}")]
|
||||||
|
ModelRead(#[from] std::io::Error),
|
||||||
|
#[cfg(feature = "onnx")]
|
||||||
|
#[error(transparent)]
|
||||||
|
Engine(#[from] dr_inference_engine::Error),
|
||||||
|
#[cfg(feature = "onnx")]
|
||||||
|
#[error(transparent)]
|
||||||
|
Ort(#[from] ort::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the learned stage can take this frame at all: a Bayer mosaic.
|
||||||
|
/// X-Trans needs its own model (§9); a linear DNG has no photosites.
|
||||||
|
pub fn eligible(raw: &RawImage) -> bool {
|
||||||
|
raw.samples_per_pixel == 1 && tile::rggb_offset(raw.cfa_pattern).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active area of `raw`, denoised and demosaiced: `crop.height ×
|
||||||
|
/// crop.width` interleaved RGB, linear camera space, normalised black 0 and
|
||||||
|
/// white 1 per photosite as the classical demosaic normalises.
|
||||||
|
///
|
||||||
|
/// `raw` must be hot-pixel repaired. `None` when `progress` stopped it.
|
||||||
|
pub fn denoise(
|
||||||
|
raw: &RawImage,
|
||||||
|
noise: &NoiseModel,
|
||||||
|
net: &mut dyn TileNet,
|
||||||
|
progress: &mut dyn FnMut(usize, usize) -> bool,
|
||||||
|
) -> Result<Option<Vec<f32>>, DenoiseError> {
|
||||||
|
if !eligible(raw) {
|
||||||
|
return Err(DenoiseError::Unsupported(format!(
|
||||||
|
"{:?} with {} samples per photosite",
|
||||||
|
raw.cfa_pattern, raw.samples_per_pixel
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let active = noise::active(raw);
|
||||||
|
let (h, w) = (active.h, active.w);
|
||||||
|
tile::run_tiled(
|
||||||
|
net,
|
||||||
|
h,
|
||||||
|
w,
|
||||||
|
raw.cfa_pattern,
|
||||||
|
&|y, x| active.at(y, x),
|
||||||
|
&|c, v| noise.sigma(c, v),
|
||||||
|
progress,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
//! TRACES: FR-DEV-3g
|
||||||
|
//! How noisy each photosite is: the network is told, not left to guess
|
||||||
|
//! (denoise.md §3.3).
|
||||||
|
//!
|
||||||
|
//! The model is `σ² = S·x + O + row² + col²` per photosite, `x` the signal
|
||||||
|
//! normalised black-to-white the way the demosaic normalises it. Three
|
||||||
|
//! sources, best first:
|
||||||
|
//!
|
||||||
|
//! 1. **A measured table** for the body ([`Source::Table`]) — the Canon EOS 6D
|
||||||
|
//! today, from the library's own frames.
|
||||||
|
//! 2. **The DNG's `NoiseProfile`** ([`Source::DngProfile`]) — what Adobe's
|
||||||
|
//! converter measured for the body at that ISO.
|
||||||
|
//! 3. **The frame itself** ([`Source::Measured`]) — read, row and column
|
||||||
|
//! noise from its masked border, which is a dark frame taken in the same
|
||||||
|
//! instant, and only the shot gain estimated, from the quietest flat
|
||||||
|
//! patches. Checked against the 6D's table on 130 frames: within ±10 % at
|
||||||
|
//! ISO 1000 and above, scattered below; the network loses under 0.3 dB for
|
||||||
|
//! a σ off by 15–20 %, and over-estimating costs half what
|
||||||
|
//! under-estimating does, so the estimate leans high.
|
||||||
|
//!
|
||||||
|
//! Row and column noise come from the masked border whenever the frame has
|
||||||
|
//! one, whatever the source of the rest.
|
||||||
|
|
||||||
|
use dr_decode::{CfaPattern, RawImage};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// Where a frame's noise figures came from, for develop to say.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Source {
|
||||||
|
Table,
|
||||||
|
DngProfile,
|
||||||
|
Measured,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Source {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Source::Table => "measured for this camera",
|
||||||
|
Source::DngProfile => "from the DNG's noise profile",
|
||||||
|
Source::Measured => "estimated from this photograph",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-photosite noise in the frame's own normalisation (black 0, white 1).
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct NoiseModel {
|
||||||
|
/// Shot gain per colour, R G B.
|
||||||
|
pub s: [f32; 3],
|
||||||
|
/// Read variance per colour, R G B.
|
||||||
|
pub o: [f32; 3],
|
||||||
|
/// Standard deviation shared by a whole row, and by a whole column.
|
||||||
|
pub row: f32,
|
||||||
|
pub col: f32,
|
||||||
|
pub source: Source,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NoiseModel {
|
||||||
|
/// σ for a photosite of colour `c` (0 R, 1 G, 2 B) reading `x`.
|
||||||
|
#[inline]
|
||||||
|
pub fn sigma(&self, c: usize, x: f32) -> f32 {
|
||||||
|
(self.s[c] * x.max(0.0) + self.o[c] + self.row * self.row + self.col * self.col).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same figures scaled for the Amount the spec describes (§3.3):
|
||||||
|
/// above 1 tells the network there is more noise than there is.
|
||||||
|
pub fn scaled(&self, amount: f32) -> NoiseModel {
|
||||||
|
let a2 = amount * amount;
|
||||||
|
NoiseModel {
|
||||||
|
s: self.s.map(|v| v * a2),
|
||||||
|
o: self.o.map(|v| v * a2),
|
||||||
|
row: self.row * amount,
|
||||||
|
col: self.col * amount,
|
||||||
|
source: self.source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The frame's noise, from the best source it has.
|
||||||
|
///
|
||||||
|
/// `bytes` is the file (for a DNG's `NoiseProfile`), `iso` its EXIF ISO.
|
||||||
|
/// `None` only for a frame with no masked border, no profile and no table
|
||||||
|
/// that is also too dark or too busy to measure.
|
||||||
|
pub fn for_frame(raw: &RawImage, bytes: &[u8], iso: Option<u32>) -> Option<NoiseModel> {
|
||||||
|
for_frame_with(raw, dr_decode::noise_profile(bytes).as_deref(), iso)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`for_frame`], given the file's `NoiseProfile` already read
|
||||||
|
/// ([`dr_decode::noise_profile`]) rather than the file, for a caller that
|
||||||
|
/// keeps the header's answer and not the bytes.
|
||||||
|
pub fn for_frame_with(
|
||||||
|
raw: &RawImage,
|
||||||
|
profile: Option<&[(f32, f32)]>,
|
||||||
|
iso: Option<u32>,
|
||||||
|
) -> Option<NoiseModel> {
|
||||||
|
let dark = dark_border(raw);
|
||||||
|
let mut model = iso
|
||||||
|
.and_then(|iso| from_table(raw, iso))
|
||||||
|
.or_else(|| profile.and_then(|p| from_dng_profile(raw, p)))
|
||||||
|
.or_else(|| measured(raw, dark.as_ref()))?;
|
||||||
|
if let Some(d) = dark {
|
||||||
|
// The border saw this exposure's row and column noise directly.
|
||||||
|
if model.source != Source::Table {
|
||||||
|
model.row = d.row;
|
||||||
|
model.col = d.col;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Table {
|
||||||
|
make: String,
|
||||||
|
model: String,
|
||||||
|
rows: Vec<TableRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TableRow {
|
||||||
|
iso: u32,
|
||||||
|
s_dn: [f32; 4],
|
||||||
|
o_dn: [f32; 4],
|
||||||
|
row_dn: f32,
|
||||||
|
col_dn: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABLES: &[&str] = &[include_str!("../tables/canon-eos-6d.yaml")];
|
||||||
|
|
||||||
|
/// The body's measured table at the nearest ISO it holds, converted from DN
|
||||||
|
/// to this frame's normalisation.
|
||||||
|
pub fn from_table(raw: &RawImage, iso: u32) -> Option<NoiseModel> {
|
||||||
|
let table = TABLES.iter().find_map(|t| {
|
||||||
|
let t: Table = serde_norway::from_str(t).ok()?;
|
||||||
|
(t.make.eq_ignore_ascii_case(&raw.make) && t.model.eq_ignore_ascii_case(&raw.model))
|
||||||
|
.then_some(t)
|
||||||
|
})?;
|
||||||
|
let row = table.rows.iter().min_by(|a, b| {
|
||||||
|
let d = |r: &TableRow| ((r.iso as f32).ln() - (iso as f32).ln()).abs();
|
||||||
|
d(a).total_cmp(&d(b))
|
||||||
|
})?;
|
||||||
|
let span = span(raw);
|
||||||
|
// RGGB positions → colours: the greens share.
|
||||||
|
let s = [row.s_dn[0], 0.5 * (row.s_dn[1] + row.s_dn[2]), row.s_dn[3]].map(|v| v / span);
|
||||||
|
let o =
|
||||||
|
[row.o_dn[0], 0.5 * (row.o_dn[1] + row.o_dn[2]), row.o_dn[3]].map(|v| v / (span * span));
|
||||||
|
Some(NoiseModel {
|
||||||
|
s,
|
||||||
|
o,
|
||||||
|
row: row.row_dn / span,
|
||||||
|
col: row.col_dn / span,
|
||||||
|
source: Source::Table,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A DNG's `NoiseProfile`: one pair for every plane, or one per colour plane
|
||||||
|
/// (R, G, B for a Bayer DNG), already in the file's black-to-white units —
|
||||||
|
/// which are the units `dr-decode` normalises by.
|
||||||
|
pub fn from_dng_profile(raw: &RawImage, pairs: &[(f32, f32)]) -> Option<NoiseModel> {
|
||||||
|
if raw.cfa_pattern.is_xtrans() || raw.samples_per_pixel != 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (s, o) = match pairs {
|
||||||
|
[(s, o)] => ([*s; 3], [*o; 3]),
|
||||||
|
[r, g, b, ..] => ([r.0, g.0, b.0], [r.1, g.1, b.1]),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(NoiseModel {
|
||||||
|
s,
|
||||||
|
o,
|
||||||
|
row: 0.0,
|
||||||
|
col: 0.0,
|
||||||
|
source: Source::DngProfile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read, row and column noise measured on the masked border, normalised.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct Dark {
|
||||||
|
pub read: f32,
|
||||||
|
pub row: f32,
|
||||||
|
pub col: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The optically black photosites beside and above the active area.
|
||||||
|
///
|
||||||
|
/// Keeps well clear of the active area: on the 6D the dozen columns nearest
|
||||||
|
/// it see light. Photosites over 8σ are the strip's own hot photosites — the
|
||||||
|
/// same ones in every frame — and are left out, as the app's hot-pixel pass
|
||||||
|
/// removes their kin before the network sees them.
|
||||||
|
pub fn dark_border(raw: &RawImage) -> Option<Dark> {
|
||||||
|
let (x0, y0, w, h) = (
|
||||||
|
raw.crop.x as usize,
|
||||||
|
raw.crop.y as usize,
|
||||||
|
raw.crop.width as usize,
|
||||||
|
raw.crop.height as usize,
|
||||||
|
);
|
||||||
|
let stride = raw.width as usize;
|
||||||
|
let span = span(raw);
|
||||||
|
if x0 < 40 || raw.samples_per_pixel != 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let cols = 4..x0 - 16;
|
||||||
|
let nc = cols.len() as f32;
|
||||||
|
// Residual after removing each row's mean and each column's mean.
|
||||||
|
let mut row_means = Vec::with_capacity(h);
|
||||||
|
let mut col_sum = vec![0.0f64; cols.len()];
|
||||||
|
for y in y0..y0 + h {
|
||||||
|
let line = &raw.data[y * stride..y * stride + x0];
|
||||||
|
let m = cols.clone().map(|x| line[x] as f32).sum::<f32>() / nc;
|
||||||
|
row_means.push(m);
|
||||||
|
for (k, x) in cols.clone().enumerate() {
|
||||||
|
col_sum[k] += (line[x] as f32 - m) as f64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let col_mean: Vec<f32> = col_sum.iter().map(|s| (*s / h as f64) as f32).collect();
|
||||||
|
let resid = |y: usize, k: usize, x: usize| {
|
||||||
|
raw.data[y * stride + x] as f32 - row_means[y - y0] - col_mean[k]
|
||||||
|
};
|
||||||
|
let (mut s1, mut n) = (0.0f64, 0usize);
|
||||||
|
for y in y0..y0 + h {
|
||||||
|
for (k, x) in cols.clone().enumerate() {
|
||||||
|
s1 += (resid(y, k, x) as f64).powi(2);
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let rough = (s1 / n as f64).sqrt() as f32;
|
||||||
|
let (mut s2, mut n2) = (0.0f64, 0usize);
|
||||||
|
for y in y0..y0 + h {
|
||||||
|
for (k, x) in cols.clone().enumerate() {
|
||||||
|
let r = resid(y, k, x);
|
||||||
|
if r.abs() < 8.0 * rough {
|
||||||
|
s2 += (r as f64).powi(2);
|
||||||
|
n2 += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let read = (s2 / n2.max(1) as f64).sqrt() as f32;
|
||||||
|
let rm = row_means.iter().sum::<f32>() / h as f32;
|
||||||
|
let row_var = row_means.iter().map(|m| (m - rm).powi(2)).sum::<f32>() / h as f32;
|
||||||
|
let row = (row_var - read * read / nc).max(0.0).sqrt();
|
||||||
|
|
||||||
|
// Columns: the masked rows above the image span every column.
|
||||||
|
let col = if y0 >= 24 {
|
||||||
|
let rows = 4..y0 - 12;
|
||||||
|
let nr = rows.len() as f32;
|
||||||
|
let means: Vec<f32> = (x0..x0 + w)
|
||||||
|
.map(|x| {
|
||||||
|
rows.clone()
|
||||||
|
.map(|y| raw.data[y * stride + x] as f32)
|
||||||
|
.sum::<f32>()
|
||||||
|
/ nr
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mm = means.iter().sum::<f32>() / means.len() as f32;
|
||||||
|
let var = means.iter().map(|m| (m - mm).powi(2)).sum::<f32>() / means.len() as f32;
|
||||||
|
(var - read * read / nr).max(0.0).sqrt()
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
Some(Dark {
|
||||||
|
read: read / span,
|
||||||
|
row: row / span,
|
||||||
|
col: col / span,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The quietest-third bias of the patch variance, and the residual bias the
|
||||||
|
/// estimate showed against the 6D's table (0.91 at the median), in one: the
|
||||||
|
/// estimate is divided by this.
|
||||||
|
const QUIET_FACTOR: f32 = 0.85 * 0.91;
|
||||||
|
|
||||||
|
/// The frame's own noise: read noise from the border (or, lacking one, the
|
||||||
|
/// floor of the quietest patches), shot gain from flat patches of one green
|
||||||
|
/// plane, the same for every colour, as a sensor's gain is.
|
||||||
|
pub fn measured(raw: &RawImage, dark: Option<&Dark>) -> Option<NoiseModel> {
|
||||||
|
if raw.cfa_pattern.is_xtrans() || raw.samples_per_pixel != 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let m = active(raw);
|
||||||
|
let (h, w) = (m.h, m.w);
|
||||||
|
// One green plane at a two-photosite pitch.
|
||||||
|
let (gy, gx) = green_offset(raw.cfa_pattern)?;
|
||||||
|
let ph = (h - gy) / 2;
|
||||||
|
let pw = (w - gx) / 2;
|
||||||
|
let g = |y: usize, x: usize| m.at(gy + 2 * y, gx + 2 * x);
|
||||||
|
const B: usize = 8;
|
||||||
|
let mut patches: Vec<(f32, f32)> = Vec::new(); // (level, variance)
|
||||||
|
for by in 0..ph / B {
|
||||||
|
for bx in 0..(pw - 2) / B {
|
||||||
|
let (mut s, mut s2, mut lv) = (0.0f32, 0.0f32, 0.0f32);
|
||||||
|
for y in by * B..by * B + B {
|
||||||
|
for x in bx * B..bx * B + B {
|
||||||
|
// Second difference: cancels any gradient; var = 6σ².
|
||||||
|
let d = g(y, x + 2) - 2.0 * g(y, x + 1) + g(y, x);
|
||||||
|
s += d;
|
||||||
|
s2 += d * d;
|
||||||
|
lv += g(y, x + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let n = (B * B) as f32;
|
||||||
|
let var = (s2 / n - (s / n).powi(2)) / 6.0;
|
||||||
|
patches.push((lv / n, var));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let floor = dark.map(|d| d.read);
|
||||||
|
let lo = 4.0 * floor.unwrap_or(0.002);
|
||||||
|
patches.retain(|(l, _)| *l > lo && *l < 0.7);
|
||||||
|
if patches.len() < 500 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
patches.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||||
|
let bins = 12;
|
||||||
|
let per = patches.len() / bins;
|
||||||
|
let mut ests = Vec::new();
|
||||||
|
let mut floors = Vec::new();
|
||||||
|
for b in 0..bins {
|
||||||
|
let mut bin: Vec<(f32, f32)> = patches[b * per..(b + 1) * per].to_vec();
|
||||||
|
if bin.len() < 60 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bin.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||||
|
let quiet = &bin[..bin.len() / 3];
|
||||||
|
let read2 = floor.map(|r| r * r);
|
||||||
|
let mut e: Vec<f32> = quiet
|
||||||
|
.iter()
|
||||||
|
.map(|(l, v)| (v / QUIET_FACTOR - read2.unwrap_or(0.0)) / l)
|
||||||
|
.collect();
|
||||||
|
e.sort_by(f32::total_cmp);
|
||||||
|
ests.push(e[e.len() / 2]);
|
||||||
|
floors.push(quiet[quiet.len() / 2]);
|
||||||
|
}
|
||||||
|
ests.sort_by(f32::total_cmp);
|
||||||
|
let s = *ests.get(ests.len() / 2)?;
|
||||||
|
if !(s.is_finite() && s > 0.0) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// No border: the read variance is what the darkest bin leaves unexplained.
|
||||||
|
let read2 = match floor {
|
||||||
|
Some(r) => r * r,
|
||||||
|
None => {
|
||||||
|
let (l, v) = floors.first().copied()?;
|
||||||
|
(v / QUIET_FACTOR - s * l).max(1e-9)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some(NoiseModel {
|
||||||
|
s: [s; 3],
|
||||||
|
o: [read2; 3],
|
||||||
|
row: dark.map_or(0.0, |d| d.row),
|
||||||
|
col: dark.map_or(0.0, |d| d.col),
|
||||||
|
source: Source::Measured,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Black-to-white range of the frame, as the demosaic normalises it.
|
||||||
|
pub(crate) fn span(raw: &RawImage) -> f32 {
|
||||||
|
let black = raw.black_level.iter().map(|&b| b as f32).sum::<f32>() / 4.0;
|
||||||
|
(raw.white_level as f32 - black).max(1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a green photosite sits in the pattern's 2×2 cell, (dy, dx).
|
||||||
|
fn green_offset(p: CfaPattern) -> Option<(usize, usize)> {
|
||||||
|
match p {
|
||||||
|
CfaPattern::Rggb | CfaPattern::Bggr => Some((0, 1)),
|
||||||
|
CfaPattern::Grbg | CfaPattern::Gbrg => Some((0, 0)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The active area, normalised, read lazily.
|
||||||
|
pub(crate) struct Active<'a> {
|
||||||
|
raw: &'a RawImage,
|
||||||
|
black: [f32; 4],
|
||||||
|
inv: [f32; 4],
|
||||||
|
pub h: usize,
|
||||||
|
pub w: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Active<'_> {
|
||||||
|
/// Photosite (y, x) of the active area, black 0, white 1.
|
||||||
|
#[inline]
|
||||||
|
pub fn at(&self, y: usize, x: usize) -> f32 {
|
||||||
|
let c = (y & 1) * 2 + (x & 1);
|
||||||
|
let v = self.raw.data[(self.raw.crop.y as usize + y) * self.raw.width as usize
|
||||||
|
+ self.raw.crop.x as usize
|
||||||
|
+ x];
|
||||||
|
(v as f32 - self.black[c]) * self.inv[c]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Black levels per position of the crop's 2×2 cell, as the demosaic reads
|
||||||
|
/// them: one reported level is broadcast.
|
||||||
|
pub(crate) fn active(raw: &RawImage) -> Active<'_> {
|
||||||
|
let b = raw.black_level;
|
||||||
|
let black = if b[1] == 0 && b[2] == 0 && b[3] == 0 {
|
||||||
|
[b[0] as f32; 4]
|
||||||
|
} else {
|
||||||
|
b.map(|v| v as f32)
|
||||||
|
};
|
||||||
|
let inv = black.map(|bl| 1.0 / (raw.white_level as f32 - bl).max(1.0));
|
||||||
|
Active {
|
||||||
|
raw,
|
||||||
|
black,
|
||||||
|
inv,
|
||||||
|
h: raw.crop.height as usize,
|
||||||
|
w: raw.crop.width as usize,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//! TRACES: FR-DEV-3g
|
||||||
|
//! The denoise network under the inference engine.
|
||||||
|
//!
|
||||||
|
//! The shipped export takes `mosaic` and `sigma`, `1×1×1408×1408`, and
|
||||||
|
//! returns `rgb`, `1×3×1408×1408` (darkroom-denoise `denoise/export.py`,
|
||||||
|
//! fixed shape because every model the engine runs is). The engine picks the
|
||||||
|
//! rung: fp16 on TensorRT and MIGraphX, which measured 0.00 dB from f32; f32
|
||||||
|
//! on CUDA and the CPU; never the Hexagon, where int8 lost 6–9 dB.
|
||||||
|
|
||||||
|
use crate::tile::TileNet;
|
||||||
|
use crate::DenoiseError;
|
||||||
|
use dr_inference_engine::{Model, Role};
|
||||||
|
|
||||||
|
/// The edge of the tile the shipped export takes.
|
||||||
|
pub const TILE: usize = 1408;
|
||||||
|
|
||||||
|
pub struct OnnxNet {
|
||||||
|
model: Model,
|
||||||
|
tile: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OnnxNet {
|
||||||
|
pub fn from_path(path: &std::path::Path) -> 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where it runs, for a status line.
|
||||||
|
pub fn rung(&self) -> Result<dr_inference_engine::Rung, DenoiseError> {
|
||||||
|
Ok(self.model.acquire()?.rung())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TileNet for OnnxNet {
|
||||||
|
fn tile(&self) -> usize {
|
||||||
|
self.tile
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(&mut self, mosaic: &[f32], sigma: &[f32]) -> Result<Vec<f32>, DenoiseError> {
|
||||||
|
let n = self.tile;
|
||||||
|
let shape = ndarray::IxDyn(&[1, 1, n, n]);
|
||||||
|
let m = ort::value::Tensor::from_array(
|
||||||
|
ndarray::Array::from_shape_vec(shape.clone(), mosaic.to_vec())
|
||||||
|
.map_err(|e| DenoiseError::Model(e.to_string()))?,
|
||||||
|
)?;
|
||||||
|
let s = ort::value::Tensor::from_array(
|
||||||
|
ndarray::Array::from_shape_vec(shape, sigma.to_vec())
|
||||||
|
.map_err(|e| DenoiseError::Model(e.to_string()))?,
|
||||||
|
)?;
|
||||||
|
let acquired = self.model.acquire()?;
|
||||||
|
let mut session = acquired.lock();
|
||||||
|
let outputs = session.run(ort::inputs!["mosaic" => m, "sigma" => s])?;
|
||||||
|
let (shape, data) = outputs[0].try_extract_tensor::<f32>()?;
|
||||||
|
let dims: Vec<i64> = shape.iter().copied().collect();
|
||||||
|
if dims != [1, 3, n as i64, n as i64] {
|
||||||
|
return Err(DenoiseError::Model(format!(
|
||||||
|
"output is {dims:?}, expected [1, 3, {n}, {n}]"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(data.to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
//! TRACES: FR-DEV-3g
|
||||||
|
//! A whole frame through a fixed-shape network, exactly (denoise.md §3.4).
|
||||||
|
//!
|
||||||
|
//! The network sees `TILE_IN`² photosites and its output is exact in the
|
||||||
|
//! central `TILE_IN − 2·HALO`: the halo is wider than its receptive field
|
||||||
|
//! (185 photosites, counted from the layers), so a tile's centre equals the
|
||||||
|
//! whole frame's at the same place. The frame is extended by reflection
|
||||||
|
//! about its edge photosites, which keeps every photosite's CFA colour, so
|
||||||
|
//! edge tiles see real context too.
|
||||||
|
//!
|
||||||
|
//! **Phase.** The network was trained on RGGB. A frame whose pattern starts
|
||||||
|
//! on another colour is read from one photosite up and/or left — the
|
||||||
|
//! reflection supplies that row or column — so its top-left is red, and the
|
||||||
|
//! output is read back from the same offset. Nothing is cropped.
|
||||||
|
|
||||||
|
use dr_decode::CfaPattern;
|
||||||
|
|
||||||
|
/// Photosites of context beyond a tile's kept centre, on every side.
|
||||||
|
pub const HALO: usize = 192;
|
||||||
|
|
||||||
|
/// A fixed-shape network: `mosaic` and `sigma`, `n×n` RGGB, in; `3×n×n`
|
||||||
|
/// planar linear camera RGB out.
|
||||||
|
pub trait TileNet {
|
||||||
|
/// The edge `n` of the square tile the network takes.
|
||||||
|
fn tile(&self) -> usize;
|
||||||
|
fn run(&mut self, mosaic: &[f32], sigma: &[f32]) -> Result<Vec<f32>, crate::DenoiseError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index into `0..n` by reflection about the end photosites, any distance
|
||||||
|
/// out: …2 1 [0 1 2 … n−1] n−2 n−3…, period `2(n−1)`. Parity is kept, which
|
||||||
|
/// is what keeps a CFA colour.
|
||||||
|
#[inline]
|
||||||
|
pub fn reflect(i: isize, n: usize) -> usize {
|
||||||
|
if n == 1 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let p = 2 * (n as isize - 1);
|
||||||
|
let m = i.rem_euclid(p);
|
||||||
|
(if m < n as isize { m } else { p - m }) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far up and left to start reading so the first photosite is red.
|
||||||
|
pub fn rggb_offset(p: CfaPattern) -> Option<(usize, usize)> {
|
||||||
|
match p {
|
||||||
|
CfaPattern::Rggb => Some((0, 0)),
|
||||||
|
CfaPattern::Grbg => Some((0, 1)),
|
||||||
|
CfaPattern::Gbrg => Some((1, 0)),
|
||||||
|
CfaPattern::Bggr => Some((1, 1)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `net` over an `h×w` mosaic given by `at(y, x)`, with σ from
|
||||||
|
/// `sigma(colour, value)`, and return `h×w` interleaved RGB.
|
||||||
|
///
|
||||||
|
/// `progress(done, total)` is called after each tile and stops the run by
|
||||||
|
/// returning `false`, in which case the result is `Ok(None)`.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn run_tiled(
|
||||||
|
net: &mut dyn TileNet,
|
||||||
|
h: usize,
|
||||||
|
w: usize,
|
||||||
|
pattern: CfaPattern,
|
||||||
|
at: &dyn Fn(usize, usize) -> f32,
|
||||||
|
sigma: &dyn Fn(usize, f32) -> f32,
|
||||||
|
progress: &mut dyn FnMut(usize, usize) -> bool,
|
||||||
|
) -> Result<Option<Vec<f32>>, crate::DenoiseError> {
|
||||||
|
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) {
|
||||||
|
return Err(crate::DenoiseError::Model(format!(
|
||||||
|
"tile {n} leaves no even centre past a {HALO} 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));
|
||||||
|
let total = ty * tx;
|
||||||
|
let mut out = vec![0.0f32; h * w * 3];
|
||||||
|
let mut mos = vec![0.0f32; n * n];
|
||||||
|
let mut sig = vec![0.0f32; n * n];
|
||||||
|
// RGGB colour of unified position (u, v).
|
||||||
|
let colour = |u: usize, v: usize| [[0, 1], [1, 2]][u & 1][v & 1];
|
||||||
|
for (k, (i, j)) in (0..ty)
|
||||||
|
.flat_map(|i| (0..tx).map(move |j| (i, j)))
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let (u0, v0) = (i * core, j * core);
|
||||||
|
for r in 0..n {
|
||||||
|
// 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 x = reflect(v - dx as isize, w);
|
||||||
|
let val = at(y, x);
|
||||||
|
mos[r * n + c] = val;
|
||||||
|
sig[r * n + c] = sigma(colour(r, c), val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let rgb = net.run(&mos, &sig)?;
|
||||||
|
if rgb.len() != 3 * n * n {
|
||||||
|
return Err(crate::DenoiseError::Model(format!(
|
||||||
|
"network returned {} values for a {n}² tile",
|
||||||
|
rgb.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
for r in HALO..HALO + core {
|
||||||
|
let u = u0 + r - HALO;
|
||||||
|
if u < dy || u >= uh {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let y = u - dy;
|
||||||
|
for c in HALO..HALO + core {
|
||||||
|
let v = v0 + c - HALO;
|
||||||
|
if v < dx || v >= uw {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let x = v - dx;
|
||||||
|
let o = (y * w + x) * 3;
|
||||||
|
for ch in 0..3 {
|
||||||
|
out[o + ch] = rgb[ch * n * n + r * n + c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !progress(k + 1, total) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reflection_keeps_parity_any_distance_out() {
|
||||||
|
let n = 7;
|
||||||
|
for i in -40isize..40 {
|
||||||
|
let r = reflect(i, n);
|
||||||
|
assert!(r < n);
|
||||||
|
assert_eq!(
|
||||||
|
r % 2,
|
||||||
|
i.rem_euclid(2) as usize,
|
||||||
|
"index {i} reflected to {r}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(reflect(-1, n), 1);
|
||||||
|
assert_eq!(reflect(7, n), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stand-in network with a known, finite reach: each output photosite
|
||||||
|
/// is its 2×2 quad's (R, mean G, B), averaged over the quads within
|
||||||
|
/// `reach` quads. Purely a function of the tile, like the real one.
|
||||||
|
struct BoxNet {
|
||||||
|
n: usize,
|
||||||
|
reach: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TileNet for BoxNet {
|
||||||
|
fn tile(&self) -> usize {
|
||||||
|
self.n
|
||||||
|
}
|
||||||
|
fn run(&mut self, m: &[f32], _s: &[f32]) -> Result<Vec<f32>, crate::DenoiseError> {
|
||||||
|
let n = self.n;
|
||||||
|
let q = n / 2;
|
||||||
|
let quad = |qy: usize, qx: usize| {
|
||||||
|
let (y, x) = (2 * qy, 2 * qx);
|
||||||
|
[
|
||||||
|
m[y * n + x],
|
||||||
|
0.5 * (m[y * n + x + 1] + m[(y + 1) * n + x]),
|
||||||
|
m[(y + 1) * n + x + 1],
|
||||||
|
]
|
||||||
|
};
|
||||||
|
let mut out = vec![0.0; 3 * n * n];
|
||||||
|
for qy in 0..q {
|
||||||
|
for qx in 0..q {
|
||||||
|
let mut acc = [0.0f32; 3];
|
||||||
|
let mut cnt = 0.0;
|
||||||
|
for a in qy.saturating_sub(self.reach)..(qy + self.reach + 1).min(q) {
|
||||||
|
for b in qx.saturating_sub(self.reach)..(qx + self.reach + 1).min(q) {
|
||||||
|
let v = quad(a, b);
|
||||||
|
for c in 0..3 {
|
||||||
|
acc[c] += v[c];
|
||||||
|
}
|
||||||
|
cnt += 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (dy, dx) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
|
||||||
|
for c in 0..3 {
|
||||||
|
out[c * n * n + (2 * qy + dy) * n + 2 * qx + dx] = acc[c] / cnt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mosaic of a smooth colour field in `pattern`, read at (y, x).
|
||||||
|
fn field(pattern: CfaPattern) -> impl Fn(usize, usize) -> f32 {
|
||||||
|
move |y, x| {
|
||||||
|
let rgb = [0.2 + 0.0004 * x as f32, 0.5, 0.1 + 0.0003 * y as f32];
|
||||||
|
rgb[pattern.colour_at(x as u32, y as u32) as usize]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_bayer_phase_comes_back_as_its_own_colours() {
|
||||||
|
// A frame of each pattern, its colours known: the network must see
|
||||||
|
// red where the frame's red photosites are, whatever the phase.
|
||||||
|
for p in [
|
||||||
|
CfaPattern::Rggb,
|
||||||
|
CfaPattern::Grbg,
|
||||||
|
CfaPattern::Gbrg,
|
||||||
|
CfaPattern::Bggr,
|
||||||
|
] {
|
||||||
|
let (h, w) = (300, 410);
|
||||||
|
let at = field(p);
|
||||||
|
let mut net = BoxNet {
|
||||||
|
n: 2 * HALO + 64,
|
||||||
|
reach: 0,
|
||||||
|
};
|
||||||
|
let out = run_tiled(&mut net, h, w, p, &at, &|_, _| 0.01, &mut |_, _| true)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
for (y, x) in [(10, 10), (150, 201), (299, 409), (0, 0), (77, 333)] {
|
||||||
|
let o = &out[(y * w + x) * 3..(y * w + x) * 3 + 3];
|
||||||
|
let want = [0.2 + 0.0004 * x as f32, 0.5, 0.1 + 0.0003 * y as f32];
|
||||||
|
for c in 0..3 {
|
||||||
|
// Within the quad the binned value is at most a photosite away.
|
||||||
|
assert!(
|
||||||
|
(o[c] - want[c]).abs() < 0.0012,
|
||||||
|
"{p:?} at ({y},{x}) channel {c}: {} vs {}",
|
||||||
|
o[c],
|
||||||
|
want[c]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tiles_reproduce_one_pass_over_the_reflected_frame() {
|
||||||
|
// A network whose reach is inside the halo gives the same answer
|
||||||
|
// tiled small as in one tile covering everything.
|
||||||
|
let (h, w) = (230, 170);
|
||||||
|
for p in [CfaPattern::Rggb, CfaPattern::Bggr] {
|
||||||
|
let at = |y: usize, x: usize| ((y * 7919 + x * 104729) % 1000) as f32 / 1000.0;
|
||||||
|
let mut small = BoxNet {
|
||||||
|
n: 2 * HALO + 32,
|
||||||
|
reach: 20,
|
||||||
|
};
|
||||||
|
let mut big = BoxNet {
|
||||||
|
n: 2 * HALO + 256,
|
||||||
|
reach: 20,
|
||||||
|
};
|
||||||
|
let a = run_tiled(&mut small, h, w, p, &at, &|_, _| 0.0, &mut |_, _| true)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let b = run_tiled(&mut big, h, w, p, &at, &|_, _| 0.0, &mut |_, _| true)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let worst = a
|
||||||
|
.iter()
|
||||||
|
.zip(&b)
|
||||||
|
.map(|(x, y)| (x - y).abs())
|
||||||
|
.fold(0.0f32, f32::max);
|
||||||
|
assert!(worst < 1e-5, "{p:?}: tiled and whole differ by {worst}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cancelled_run_returns_nothing() {
|
||||||
|
let mut net = BoxNet {
|
||||||
|
n: 2 * HALO + 32,
|
||||||
|
reach: 0,
|
||||||
|
};
|
||||||
|
let r = run_tiled(
|
||||||
|
&mut net,
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
CfaPattern::Rggb,
|
||||||
|
&|_, _| 0.5,
|
||||||
|
&|_, _| 0.0,
|
||||||
|
&mut |done, _| done < 2,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(r.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Canon EOS 6D noise, measured from the library's own frames (denoise.md §5).
|
||||||
|
# Shot gain S and read variance O per RGGB position from Adobe's NoiseProfile in
|
||||||
|
# converted DNGs, in DN at the ISO's own white level; read noise checked against
|
||||||
|
# the masked border (within 2-3 %); row and column noise from the masked border.
|
||||||
|
# ISO 50 and 100 are extrapolated (S proportional to ISO). Generated by
|
||||||
|
# darkroom-denoise tools/profile.py; regenerate there, never edit by hand.
|
||||||
|
make: Canon
|
||||||
|
model: EOS 6D
|
||||||
|
black: 2048
|
||||||
|
rows:
|
||||||
|
- {iso: 50, white: 15000, s_dn: [0.0854021, 0.085467, 0.085467, 0.0839724], o_dn: [38.2741, 38.6675, 38.6675, 38.9649], row_dn: 0.3423, col_dn: 0.505}
|
||||||
|
- {iso: 100, white: 15000, s_dn: [0.170804, 0.170934, 0.170934, 0.167945], o_dn: [38.339, 38.7332, 38.7332, 39.031], row_dn: 0.3423, col_dn: 0.505}
|
||||||
|
- {iso: 125, white: 15035, s_dn: [0.228108, 0.230361, 0.230361, 0.228345], o_dn: [36.9455, 37.8995, 37.8995, 38.2358], row_dn: 0.3423, col_dn: 0.505}
|
||||||
|
- {iso: 160, white: 12373, s_dn: [0.289653, 0.294915, 0.294915, 0.286887], o_dn: [15.3717, 16.1346, 16.1346, 16.0413], row_dn: 0.212, col_dn: 0.07151}
|
||||||
|
- {iso: 200, white: 15035, s_dn: [0.370969, 0.369922, 0.369922, 0.361443], o_dn: [24.2761, 24.0847, 24.0847, 24.2414], row_dn: 0.2692, col_dn: 0}
|
||||||
|
- {iso: 250, white: 15035, s_dn: [0.461889, 0.457975, 0.457975, 0.449318], o_dn: [38.0975, 37.5041, 37.5041, 37.7424], row_dn: 0.3345, col_dn: 0.4786}
|
||||||
|
- {iso: 320, white: 12323, s_dn: [0.590765, 0.59755, 0.59755, 0.576843], o_dn: [18.5426, 18.9163, 18.9163, 19.1202], row_dn: 0.3158, col_dn: 0.5174}
|
||||||
|
- {iso: 400, white: 15035, s_dn: [0.753591, 0.740586, 0.740586, 0.729874], o_dn: [29.3028, 29.8496, 29.8496, 29.7961], row_dn: 0.4378, col_dn: 0.2691}
|
||||||
|
- {iso: 500, white: 15035, s_dn: [0.937458, 0.920836, 0.920836, 0.899293], o_dn: [45.2196, 46.3954, 46.3954, 46.1012], row_dn: 0.5473, col_dn: 0.4328}
|
||||||
|
- {iso: 640, white: 12323, s_dn: [1.12726, 1.13527, 1.13527, 1.10159], o_dn: [24.9951, 25.1029, 25.1029, 25.7183], row_dn: 0.316, col_dn: 0.4544}
|
||||||
|
- {iso: 800, white: 15035, s_dn: [1.44048, 1.42299, 1.42299, 1.40795], o_dn: [38.7891, 39.302, 39.302, 40.079], row_dn: 0.3877, col_dn: 0.2132}
|
||||||
|
- {iso: 1000, white: 15000, s_dn: [1.77595, 1.75662, 1.75662, 1.74584], o_dn: [63.9499, 64.4203, 64.4203, 65.0739], row_dn: 0.4593, col_dn: 0.3307}
|
||||||
|
- {iso: 1250, white: 12346, s_dn: [2.18211, 2.18313, 2.18313, 2.11979], o_dn: [41.6124, 42.9483, 42.9483, 43.2075], row_dn: 0.3979, col_dn: 0.4496}
|
||||||
|
- {iso: 1600, white: 15035, s_dn: [2.75544, 2.74633, 2.74633, 2.69951], o_dn: [66.3905, 66.4104, 66.4104, 67.253], row_dn: 0.4944, col_dn: 0.4593}
|
||||||
|
- {iso: 2000, white: 15035, s_dn: [3.42754, 3.40445, 3.40445, 3.36808], o_dn: [104.349, 103.648, 103.648, 106.404], row_dn: 0.6094, col_dn: 0.3602}
|
||||||
|
- {iso: 2500, white: 12330, s_dn: [4.17112, 4.17551, 4.17551, 4.17175], o_dn: [94.4289, 91.8508, 91.8508, 96.3598], row_dn: 0.5671, col_dn: 0}
|
||||||
|
- {iso: 3200, white: 15035, s_dn: [5.30088, 5.25742, 5.25742, 5.21782], o_dn: [147.421, 147.302, 147.302, 147.01], row_dn: 0.748, col_dn: 0.8611}
|
||||||
|
- {iso: 4000, white: 15035, s_dn: [6.62037, 6.59922, 6.59922, 6.60871], o_dn: [224.765, 232.408, 232.408, 231.419], row_dn: 0.9335, col_dn: 1.125}
|
||||||
|
- {iso: 5000, white: 12323, s_dn: [8.49542, 8.48265, 8.48265, 8.41176], o_dn: [232.672, 233.922, 233.922, 256.059], row_dn: 1.085, col_dn: 1.852}
|
||||||
|
- {iso: 6400, white: 15035, s_dn: [10.6956, 10.7417, 10.7417, 10.6503], o_dn: [360.311, 368.198, 368.198, 362.848], row_dn: 1.326, col_dn: 2.277}
|
||||||
|
- {iso: 8000, white: 15035, s_dn: [13.1307, 13.3864, 13.3864, 13.147], o_dn: [615.02, 566.666, 566.666, 611.738], row_dn: 1.768, col_dn: 3.141}
|
||||||
|
- {iso: 10000, white: 12365, s_dn: [16.5338, 16.7603, 16.7603, 16.4739], o_dn: [914.064, 904.583, 904.583, 938.024], row_dn: 2.214, col_dn: 3.605}
|
||||||
|
- {iso: 12800, white: 15000, s_dn: [18.4717, 20.9315, 20.9315, 19.3821], o_dn: [1431.85, 1432.33, 1432.33, 1477.82], row_dn: 2.568, col_dn: 4.661}
|
||||||
|
- {iso: 16000, white: 15000, s_dn: [20.527, 26.0841, 26.0841, 21.8866], o_dn: [2203.77, 2357.78, 2357.78, 2193.1], row_dn: 3.521, col_dn: 5.805}
|
||||||
|
- {iso: 20000, white: 13000, s_dn: [25.1517, 32.5307, 32.5307, 26.2303], o_dn: [3490.34, 3647.93, 3647.93, 3423.33], row_dn: 4.336, col_dn: 7.143}
|
||||||
|
- {iso: 25600, white: 15000, s_dn: [22.8743, 40.4641, 40.4641, 23.5537], o_dn: [5184.57, 5690.43, 5690.43, 5286.07], row_dn: 5.682, col_dn: 9.193}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
//! Dump RAW files' mosaics for training the learned denoise (FR-DEV-3g).
|
||||||
|
//!
|
||||||
|
//! The training repo must read photosites the way the app reads them —
|
||||||
|
//! same black and white levels, same active area, same CFA phase — or a
|
||||||
|
//! network trained on one phase runs on another and paints moiré everywhere
|
||||||
|
//! (denoise.md §4.4). So it reads this, not LibRaw.
|
||||||
|
//!
|
||||||
|
//! The photosites are those the demosaic reads: hot and dead ones repaired by
|
||||||
|
//! the app's own pass ([`Demosaicer::repair_hot_pixels`], the same shader
|
||||||
|
//! `run` dispatches), because the learned stage replaces the demosaic and
|
||||||
|
//! takes its input (denoise.md §2). `--unrepaired` skips it.
|
||||||
|
//!
|
||||||
|
//! Reads `input<TAB>output-prefix` lines on stdin and writes, per line,
|
||||||
|
//! `prefix.npy` (the whole readout, masked border included, `u16`, row-major)
|
||||||
|
//! and `prefix.json` (what `decode` and `metadata` say about it). The border
|
||||||
|
//! is kept, and the repair never touches it, because its optically black
|
||||||
|
//! photosites are a dark frame for free: read noise and row noise at that ISO.
|
||||||
|
//!
|
||||||
|
//! ```sh
|
||||||
|
//! printf 'IMG_0001.CR2\tout/IMG_0001\n' |
|
||||||
|
//! cargo run --release -p dr-gpu --example mosaic_dump
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::io::{BufRead, Write};
|
||||||
|
|
||||||
|
use dr_gpu::{Demosaicer, GpuContext};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let repair = !std::env::args().any(|a| a == "--unrepaired");
|
||||||
|
let ctx = pollster::block_on(GpuContext::new_headless()).expect("a GPU for the hot-pixel pass");
|
||||||
|
let demosaicer = Demosaicer::new(&ctx).expect("demosaicer");
|
||||||
|
let mut failed = 0;
|
||||||
|
for line in std::io::stdin().lock().lines() {
|
||||||
|
let line = line.expect("stdin");
|
||||||
|
let Some((input, prefix)) = line.split_once('\t') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match dump(input, prefix, repair.then_some(&demosaicer)) {
|
||||||
|
Ok(()) => println!("ok\t{input}"),
|
||||||
|
Err(e) => {
|
||||||
|
failed += 1;
|
||||||
|
println!("fail\t{input}\t{e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::io::stdout().flush().ok();
|
||||||
|
}
|
||||||
|
std::process::exit(if failed > 0 { 1 } else { 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dump(input: &str, prefix: &str, repair: Option<&Demosaicer>) -> Result<(), String> {
|
||||||
|
let bytes = std::fs::read(input).map_err(|e| e.to_string())?;
|
||||||
|
let mut raw = dr_decode::decode(&bytes).map_err(|e| e.to_string())?;
|
||||||
|
if raw.samples_per_pixel != 1 {
|
||||||
|
return Err("linear DNG: no photosites".into());
|
||||||
|
}
|
||||||
|
let repaired = match repair {
|
||||||
|
Some(d) => d.repair_hot_pixels(&mut raw).map_err(|e| e.to_string())? as i64,
|
||||||
|
None => -1,
|
||||||
|
};
|
||||||
|
let meta = dr_decode::metadata(&bytes).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut npy = Vec::with_capacity(raw.data.len() * 2 + 128);
|
||||||
|
let mut header = format!(
|
||||||
|
"{{'descr': '<u2', 'fortran_order': False, 'shape': ({}, {}), }}",
|
||||||
|
raw.height, raw.width
|
||||||
|
);
|
||||||
|
// The header, its magic and length are padded to a multiple of 64.
|
||||||
|
while (10 + header.len() + 1) % 64 != 0 {
|
||||||
|
header.push(' ');
|
||||||
|
}
|
||||||
|
header.push('\n');
|
||||||
|
npy.extend_from_slice(b"\x93NUMPY\x01\x00");
|
||||||
|
npy.extend_from_slice(&(header.len() as u16).to_le_bytes());
|
||||||
|
npy.extend_from_slice(header.as_bytes());
|
||||||
|
for v in &raw.data {
|
||||||
|
npy.extend_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
std::fs::write(format!("{prefix}.npy"), npy).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let opt = |v: Option<f32>| v.map_or("null".to_string(), |v| v.to_string());
|
||||||
|
let matrix = raw
|
||||||
|
.color_matrix
|
||||||
|
.map_or("null".to_string(), |m| format!("{m:?}"));
|
||||||
|
let json = format!(
|
||||||
|
concat!(
|
||||||
|
"{{\"source\": {:?}, \"make\": {:?}, \"model\": {:?}, ",
|
||||||
|
"\"width\": {}, \"height\": {}, ",
|
||||||
|
"\"crop\": [{}, {}, {}, {}], \"cfa\": {:?}, ",
|
||||||
|
"\"black\": {:?}, \"white\": {}, \"wb\": {:?}, \"cam_to_srgb\": {}, ",
|
||||||
|
"\"iso\": {}, \"shutter\": {}, \"aperture\": {}, \"captured_at\": {}, ",
|
||||||
|
"\"hot_repaired\": {}}}\n"
|
||||||
|
),
|
||||||
|
input,
|
||||||
|
raw.make,
|
||||||
|
raw.model,
|
||||||
|
raw.width,
|
||||||
|
raw.height,
|
||||||
|
raw.crop.x,
|
||||||
|
raw.crop.y,
|
||||||
|
raw.crop.width,
|
||||||
|
raw.crop.height,
|
||||||
|
format!("{:?}", raw.cfa_pattern),
|
||||||
|
raw.black_level,
|
||||||
|
raw.white_level,
|
||||||
|
raw.wb_coeffs,
|
||||||
|
matrix,
|
||||||
|
meta.iso.map_or("null".to_string(), |v| v.to_string()),
|
||||||
|
opt(meta.shutter),
|
||||||
|
opt(meta.aperture),
|
||||||
|
meta.captured_at
|
||||||
|
.map_or("null".to_string(), |v| v.to_string()),
|
||||||
|
repaired,
|
||||||
|
);
|
||||||
|
std::fs::write(format!("{prefix}.json"), json).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -1889,6 +1889,7 @@ mod tests {
|
|||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
@@ -2093,6 +2094,7 @@ mod tests {
|
|||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
@@ -2702,6 +2704,7 @@ mod tests {
|
|||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
@@ -2806,6 +2809,7 @@ mod tests {
|
|||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
@@ -3416,8 +3420,15 @@ mod tests {
|
|||||||
read_centre(&ctx, t)
|
read_centre(&ctx, t)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The default rendering is the DNG reference curve (D21); for a grey its
|
||||||
|
// ProPhoto round trip is the identity, so the reference applies as is.
|
||||||
let scene = 3537.0 / 16383.0;
|
let scene = 3537.0 / 16383.0;
|
||||||
let viewed = dr_pipeline::view::Sigmoid::default_curve().channel(scene);
|
let viewed = dr_pipeline::camera_raw::apply_reference(
|
||||||
|
&dr_types::tone::ACR3_DEFAULT,
|
||||||
|
[scene; 3],
|
||||||
|
dr_pipeline::view::DEFAULT_CONTRAST,
|
||||||
|
dr_pipeline::view::DEFAULT_WHITE,
|
||||||
|
)[0];
|
||||||
let expected = (dr_types::Transfer::Srgb.encode(viewed) * 255.0).round() as i32;
|
let expected = (dr_types::Transfer::Srgb.encode(viewed) * 255.0).round() as i32;
|
||||||
let delta = (i32::from(from_sensor[0]) - expected).abs();
|
let delta = (i32::from(from_sensor[0]) - expected).abs();
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
+281
-64
@@ -346,6 +346,95 @@ impl DemosaicedImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DemosaicedImage {
|
impl DemosaicedImage {
|
||||||
|
/// TRACES: FR-DEV-3g
|
||||||
|
/// The learned demosaic's output for the photograph `like` was
|
||||||
|
/// demosaiced from: `width × height` interleaved RGB, linear camera
|
||||||
|
/// space, normalised as the demosaic normalises — the same texture the
|
||||||
|
/// classical path made, with the noise gone (denoise.md §2).
|
||||||
|
///
|
||||||
|
/// Everything that describes the photograph rather than its pixels —
|
||||||
|
/// matrix, profile tables, as-shot balance — is `like`'s, so nothing
|
||||||
|
/// downstream can tell which demosaic ran. A new [`Self::id`], so every
|
||||||
|
/// cache keyed on the source sees a new source.
|
||||||
|
pub fn from_rgb_f32(
|
||||||
|
ctx: &GpuContext,
|
||||||
|
like: &DemosaicedImage,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
rgb: &[f32],
|
||||||
|
) -> Result<Self, GpuError> {
|
||||||
|
let n = width as usize * height as usize;
|
||||||
|
if rgb.len() != n * 3 {
|
||||||
|
return Err(GpuError::TooLarge(format!(
|
||||||
|
"{} values for a {width}×{height} RGB image",
|
||||||
|
rgb.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let limits = ctx.device.limits();
|
||||||
|
if width > limits.max_texture_dimension_2d || height > limits.max_texture_dimension_2d {
|
||||||
|
return Err(GpuError::TooLarge(format!(
|
||||||
|
"{width}×{height} exceeds the device limit of {}",
|
||||||
|
limits.max_texture_dimension_2d
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut half = vec![0u16; n * 4];
|
||||||
|
let one = f32_to_f16_bits(1.0);
|
||||||
|
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||||
|
let per = n.div_ceil(threads).max(1);
|
||||||
|
std::thread::scope(|scope| {
|
||||||
|
for (k, out) in half.chunks_mut(per * 4).enumerate() {
|
||||||
|
scope.spawn(move || {
|
||||||
|
for (i, texel) in out.chunks_mut(4).enumerate() {
|
||||||
|
let src = &rgb[(k * per + i) * 3..(k * per + i) * 3 + 3];
|
||||||
|
for c in 0..3 {
|
||||||
|
texel[c] = f32_to_f16_bits_unclamped(src[c]);
|
||||||
|
}
|
||||||
|
texel[3] = one;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let texture = ctx.device.create_texture_with_data(
|
||||||
|
&ctx.queue,
|
||||||
|
&wgpu::TextureDescriptor {
|
||||||
|
label: Some("learned-demosaic-source"),
|
||||||
|
size: wgpu::Extent3d {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: Self::FORMAT,
|
||||||
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||||
|
view_formats: &[],
|
||||||
|
},
|
||||||
|
wgpu::util::TextureDataOrder::LayerMajor,
|
||||||
|
bytemuck::cast_slice(&half),
|
||||||
|
);
|
||||||
|
Ok(like.sibling(texture, width, height))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new source standing for the same photograph as `self`: its
|
||||||
|
/// description kept, its pixels `texture`, a fresh id.
|
||||||
|
pub(crate) fn sibling(&self, texture: wgpu::Texture, width: u32, height: u32) -> Self {
|
||||||
|
let view = texture.create_view(&Default::default());
|
||||||
|
Self {
|
||||||
|
texture,
|
||||||
|
view,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
color_matrix: self.color_matrix,
|
||||||
|
profile_tables: self.profile_tables.clone(),
|
||||||
|
as_shot_wb: self.as_shot_wb,
|
||||||
|
non_linear: self.non_linear,
|
||||||
|
id: next_image_id(),
|
||||||
|
frame: self.frame,
|
||||||
|
window: self.window,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-MRG-3
|
/// TRACES: FR-MRG-3
|
||||||
/// A source that is already RGB in camera space: a linear DNG, which is
|
/// A source that is already RGB in camera space: a linear DNG, which is
|
||||||
/// what a merge writes. No demosaic; the samples are normalised by the
|
/// what a merge writes. No demosaic; the samples are normalised by the
|
||||||
@@ -489,7 +578,7 @@ impl DemosaicedImage {
|
|||||||
view,
|
view,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
|
color_matrix: rendering_matrix(raw),
|
||||||
profile_tables: raw.profile_tables.clone(),
|
profile_tables: raw.profile_tables.clone(),
|
||||||
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
|
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
|
||||||
non_linear: false,
|
non_linear: false,
|
||||||
@@ -500,6 +589,23 @@ impl DemosaicedImage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// The camera matrix a raw renders through: the file's — identity where the
|
||||||
|
/// body is uncalibrated, so the image renders with no colour transform
|
||||||
|
/// rather than not at all — times the baseline exposure as a gain
|
||||||
|
/// (camera-profiles.md §11).
|
||||||
|
///
|
||||||
|
/// A uniform gain commutes with every scene operation before the view
|
||||||
|
/// transform, so folding it in here is the same as an exposure step at the
|
||||||
|
/// head of the chain, at no cost. The camera-space tap and the white-balance
|
||||||
|
/// probe read camera RGB before this matrix and are unaffected.
|
||||||
|
/// `RawImage::color_matrix` stays the file's: a merge writes a linear DNG
|
||||||
|
/// from it and must not bake a gain into its pixels.
|
||||||
|
fn rendering_matrix(raw: &RawImage) -> [f32; 9] {
|
||||||
|
let gain = raw.baseline_exposure.exp2();
|
||||||
|
raw.color_matrix.unwrap_or(IDENTITY_3X3).map(|v| v * gain)
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert an f32 to half-precision bits, the general case: sign,
|
/// Convert an f32 to half-precision bits, the general case: sign,
|
||||||
/// subnormals, round-to-nearest-even, saturation at the largest finite.
|
/// subnormals, round-to-nearest-even, saturation at the largest finite.
|
||||||
///
|
///
|
||||||
@@ -761,62 +867,14 @@ impl Demosaicer {
|
|||||||
|
|
||||||
// TRACES: FR-RAW-3
|
// TRACES: FR-RAW-3
|
||||||
// The mosaic the demosaic actually reads: the readout with its hot and
|
// The mosaic the demosaic actually reads: the readout with its hot and
|
||||||
// dead photosites repaired. A second buffer rather than in place,
|
// dead photosites repaired.
|
||||||
// because every photosite's verdict reads its neighbours' originals.
|
let hot = self.hot_pass(
|
||||||
let repaired = self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
|
||||||
label: Some("raw-repaired"),
|
|
||||||
size: raw_buf.size(),
|
|
||||||
usage: wgpu::BufferUsages::STORAGE,
|
|
||||||
mapped_at_creation: false,
|
|
||||||
});
|
|
||||||
let words = packed.len() as u32;
|
|
||||||
let groups = words.div_ceil(HOT_PIXEL_GROUP).max(1);
|
|
||||||
// A 24 MP readout is 190,000 workgroups, past the 65,535 one
|
|
||||||
// dispatch dimension may hold, so the grid folds into rows.
|
|
||||||
let groups_x = groups.min(
|
|
||||||
self.ctx
|
|
||||||
.device
|
|
||||||
.limits()
|
|
||||||
.max_compute_workgroups_per_dimension,
|
|
||||||
);
|
|
||||||
let groups_y = groups.div_ceil(groups_x);
|
|
||||||
let hot_params = hot_pixel_params(
|
|
||||||
raw,
|
raw,
|
||||||
(width, height),
|
(width, height),
|
||||||
words,
|
&raw_buf,
|
||||||
groups_x * HOT_PIXEL_GROUP,
|
packed.len() as u32,
|
||||||
xtrans_tile,
|
xtrans_tile,
|
||||||
);
|
);
|
||||||
let hot_params_buf =
|
|
||||||
self.ctx
|
|
||||||
.device
|
|
||||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
|
||||||
label: Some("hot-pixel-params"),
|
|
||||||
contents: bytemuck::bytes_of(&hot_params),
|
|
||||||
usage: wgpu::BufferUsages::UNIFORM,
|
|
||||||
});
|
|
||||||
let hot_bind_group = self
|
|
||||||
.ctx
|
|
||||||
.device
|
|
||||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
|
||||||
label: Some("hot-pixel-bg"),
|
|
||||||
layout: &self.hot_pixel_layout,
|
|
||||||
entries: &[
|
|
||||||
wgpu::BindGroupEntry {
|
|
||||||
binding: 0,
|
|
||||||
resource: raw_buf.as_entire_binding(),
|
|
||||||
},
|
|
||||||
wgpu::BindGroupEntry {
|
|
||||||
binding: 1,
|
|
||||||
resource: hot_params_buf.as_entire_binding(),
|
|
||||||
},
|
|
||||||
wgpu::BindGroupEntry {
|
|
||||||
binding: 2,
|
|
||||||
resource: repaired.as_entire_binding(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
let params_buf = self
|
let params_buf = self
|
||||||
.ctx
|
.ctx
|
||||||
.device
|
.device
|
||||||
@@ -855,7 +913,7 @@ impl Demosaicer {
|
|||||||
entries: &[
|
entries: &[
|
||||||
wgpu::BindGroupEntry {
|
wgpu::BindGroupEntry {
|
||||||
binding: 0,
|
binding: 0,
|
||||||
resource: repaired.as_entire_binding(),
|
resource: hot.repaired.as_entire_binding(),
|
||||||
},
|
},
|
||||||
wgpu::BindGroupEntry {
|
wgpu::BindGroupEntry {
|
||||||
binding: 1,
|
binding: 1,
|
||||||
@@ -877,15 +935,7 @@ impl Demosaicer {
|
|||||||
// Two passes in one submission. wgpu orders a storage write in one
|
// Two passes in one submission. wgpu orders a storage write in one
|
||||||
// pass before a read of the same buffer in the next, so the demosaic
|
// pass before a read of the same buffer in the next, so the demosaic
|
||||||
// sees every repair.
|
// sees every repair.
|
||||||
{
|
self.record_hot_pass(&mut enc, &hot);
|
||||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
|
||||||
label: Some("hot-pixel-pass"),
|
|
||||||
timestamp_writes: None,
|
|
||||||
});
|
|
||||||
pass.set_pipeline(&self.hot_pixel_pipeline);
|
|
||||||
pass.set_bind_group(0, &hot_bind_group, &[]);
|
|
||||||
pass.dispatch_workgroups(groups_x, groups_y, 1);
|
|
||||||
}
|
|
||||||
{
|
{
|
||||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||||
label: Some("demosaic-pass"),
|
label: Some("demosaic-pass"),
|
||||||
@@ -904,7 +954,7 @@ impl Demosaicer {
|
|||||||
height,
|
height,
|
||||||
// Identity where the body is uncalibrated: the image renders with
|
// Identity where the body is uncalibrated: the image renders with
|
||||||
// no colour transform rather than not at all.
|
// no colour transform rather than not at all.
|
||||||
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
|
color_matrix: rendering_matrix(raw),
|
||||||
profile_tables: raw.profile_tables.clone(),
|
profile_tables: raw.profile_tables.clone(),
|
||||||
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
|
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
|
||||||
// Whatever the profile database had for this body (FR-DEV-3e),
|
// Whatever the profile database had for this body (FR-DEV-3e),
|
||||||
@@ -921,6 +971,169 @@ impl Demosaicer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The hot-pixel pass's resources for one frame, ready to record.
|
||||||
|
struct HotPass {
|
||||||
|
repaired: wgpu::Buffer,
|
||||||
|
bind_group: wgpu::BindGroup,
|
||||||
|
groups: (u32, u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Demosaicer {
|
||||||
|
/// Buffers and bindings for the hot and dead photosite repair of `raw`,
|
||||||
|
/// whose packed samples are in `raw_buf`.
|
||||||
|
fn hot_pass(
|
||||||
|
&self,
|
||||||
|
raw: &RawImage,
|
||||||
|
(width, height): (u32, u32),
|
||||||
|
raw_buf: &wgpu::Buffer,
|
||||||
|
words: u32,
|
||||||
|
xtrans_tile: Option<[u32; 4]>,
|
||||||
|
) -> HotPass {
|
||||||
|
// A second buffer rather than in place, because every photosite's
|
||||||
|
// verdict reads its neighbours' originals.
|
||||||
|
let repaired = self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("raw-repaired"),
|
||||||
|
size: raw_buf.size(),
|
||||||
|
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
let groups = words.div_ceil(HOT_PIXEL_GROUP).max(1);
|
||||||
|
// A 24 MP readout is 190,000 workgroups, past the 65,535 one
|
||||||
|
// dispatch dimension may hold, so the grid folds into rows.
|
||||||
|
let groups_x = groups.min(
|
||||||
|
self.ctx
|
||||||
|
.device
|
||||||
|
.limits()
|
||||||
|
.max_compute_workgroups_per_dimension,
|
||||||
|
);
|
||||||
|
let groups_y = groups.div_ceil(groups_x);
|
||||||
|
let hot_params = hot_pixel_params(
|
||||||
|
raw,
|
||||||
|
(width, height),
|
||||||
|
words,
|
||||||
|
groups_x * HOT_PIXEL_GROUP,
|
||||||
|
xtrans_tile,
|
||||||
|
);
|
||||||
|
let hot_params_buf =
|
||||||
|
self.ctx
|
||||||
|
.device
|
||||||
|
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("hot-pixel-params"),
|
||||||
|
contents: bytemuck::bytes_of(&hot_params),
|
||||||
|
usage: wgpu::BufferUsages::UNIFORM,
|
||||||
|
});
|
||||||
|
let bind_group = self
|
||||||
|
.ctx
|
||||||
|
.device
|
||||||
|
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("hot-pixel-bg"),
|
||||||
|
layout: &self.hot_pixel_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: raw_buf.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: hot_params_buf.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: repaired.as_entire_binding(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
HotPass {
|
||||||
|
repaired,
|
||||||
|
bind_group,
|
||||||
|
groups: (groups_x, groups_y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_hot_pass(&self, enc: &mut wgpu::CommandEncoder, hot: &HotPass) {
|
||||||
|
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||||
|
label: Some("hot-pixel-pass"),
|
||||||
|
timestamp_writes: None,
|
||||||
|
});
|
||||||
|
pass.set_pipeline(&self.hot_pixel_pipeline);
|
||||||
|
pass.set_bind_group(0, &hot.bind_group, &[]);
|
||||||
|
pass.dispatch_workgroups(hot.groups.0, hot.groups.1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-RAW-3 | FR-DEV-3g
|
||||||
|
/// Repair `raw`'s hot and dead photosites in place, exactly as [`Self::run`]
|
||||||
|
/// does before it demosaics, and return how many changed.
|
||||||
|
///
|
||||||
|
/// For the learned demosaic (denoise.md §2), which reads the same repaired
|
||||||
|
/// mosaic the classical one does: its training data and its input in the
|
||||||
|
/// app must have been through this one pass, not a lookalike.
|
||||||
|
pub fn repair_hot_pixels(&self, raw: &mut RawImage) -> Result<usize, GpuError> {
|
||||||
|
if raw.samples_per_pixel != 1 {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let (width, height) = (raw.crop.width.max(1), raw.crop.height.max(1));
|
||||||
|
let xtrans_tile = raw
|
||||||
|
.cfa_pattern
|
||||||
|
.is_xtrans()
|
||||||
|
.then(|| xtrans_params_for(raw, width, height).tile);
|
||||||
|
let packed = pack_samples(&raw.data);
|
||||||
|
let raw_buf = self
|
||||||
|
.ctx
|
||||||
|
.device
|
||||||
|
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("raw-samples"),
|
||||||
|
contents: bytemuck::cast_slice(&packed),
|
||||||
|
usage: wgpu::BufferUsages::STORAGE,
|
||||||
|
});
|
||||||
|
let hot = self.hot_pass(
|
||||||
|
raw,
|
||||||
|
(width, height),
|
||||||
|
&raw_buf,
|
||||||
|
packed.len() as u32,
|
||||||
|
xtrans_tile,
|
||||||
|
);
|
||||||
|
let readback = self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("raw-repaired-readback"),
|
||||||
|
size: hot.repaired.size(),
|
||||||
|
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
let mut enc = self
|
||||||
|
.ctx
|
||||||
|
.device
|
||||||
|
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("hot-pixel-encoder"),
|
||||||
|
});
|
||||||
|
self.record_hot_pass(&mut enc, &hot);
|
||||||
|
enc.copy_buffer_to_buffer(&hot.repaired, 0, &readback, 0, hot.repaired.size());
|
||||||
|
self.ctx.queue.submit(Some(enc.finish()));
|
||||||
|
|
||||||
|
let slice = readback.slice(..);
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||||||
|
let _ = tx.send(r);
|
||||||
|
});
|
||||||
|
self.ctx
|
||||||
|
.device
|
||||||
|
.poll(wgpu::PollType::wait_indefinitely())
|
||||||
|
.map_err(|e| GpuError::Readback(e.to_string()))?;
|
||||||
|
rx.recv()
|
||||||
|
.map_err(|e| GpuError::Readback(e.to_string()))?
|
||||||
|
.map_err(|e| GpuError::Readback(e.to_string()))?;
|
||||||
|
let words: Vec<u32> = bytemuck::cast_slice(&slice.get_mapped_range()).to_vec();
|
||||||
|
readback.unmap();
|
||||||
|
|
||||||
|
let mut changed = 0;
|
||||||
|
for (i, v) in raw.data.iter_mut().enumerate() {
|
||||||
|
let w = words[i / 2];
|
||||||
|
let new = if i % 2 == 0 { w & 0xFFFF } else { w >> 16 } as u16;
|
||||||
|
changed += usize::from(new != *v);
|
||||||
|
*v = new;
|
||||||
|
}
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const IDENTITY_3X3: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
|
const IDENTITY_3X3: [f32; 9] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
|
||||||
|
|
||||||
/// Pack u16 samples two per u32, little-endian within the word.
|
/// Pack u16 samples two per u32, little-endian within the word.
|
||||||
@@ -1414,6 +1627,7 @@ mod tests {
|
|||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
@@ -1530,6 +1744,7 @@ mod tests {
|
|||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
@@ -1827,6 +2042,7 @@ mod tests {
|
|||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
@@ -1912,6 +2128,7 @@ mod tests {
|
|||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
//! TRACES: FR-DEV-3g
|
||||||
|
//! Grain back into a denoised photograph, as brightness only.
|
||||||
|
//!
|
||||||
|
//! The learned denoise's one live control. The network's result and the
|
||||||
|
//! classical demosaic of the same mosaic differ by the noise the network
|
||||||
|
//! removed — plus the classical path's colour speckle and demosaic false
|
||||||
|
//! colour, which nobody wants back. So only the brightness of the difference
|
||||||
|
//! is returned, in proportion to `grain`:
|
||||||
|
//!
|
||||||
|
//! `out = denoised + grain · ΔY / wb`, with `ΔY = Y(wb · (classical − denoised))`
|
||||||
|
//!
|
||||||
|
//! `Y` is taken after the as-shot balance and handed back divided by it, so
|
||||||
|
//! the grain is neutral in the finished picture rather than tinted the
|
||||||
|
//! colour of the sensor's raw response. At 0 the result is the network's
|
||||||
|
//! exactly; at 1 the brightness noise is all back, the colour noise none.
|
||||||
|
//!
|
||||||
|
//! A pass of its own producing a new source rather than a term in the
|
||||||
|
//! adjust shader: the blend depends only on the two images and one number,
|
||||||
|
//! a 20 MP pass is a few milliseconds, and a new source id is all the
|
||||||
|
//! adjust pass's caches need to know it changed.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::demosaic::DemosaicedImage;
|
||||||
|
use crate::{GpuContext, GpuError};
|
||||||
|
|
||||||
|
const SHADER: &str = r#"
|
||||||
|
struct Params {
|
||||||
|
grain: f32,
|
||||||
|
_pad0: f32,
|
||||||
|
_pad1: f32,
|
||||||
|
_pad2: f32,
|
||||||
|
wb: vec4<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0) var denoised: texture_2d<f32>;
|
||||||
|
@group(0) @binding(1) var classical: texture_2d<f32>;
|
||||||
|
@group(0) @binding(2) var<uniform> p: Params;
|
||||||
|
@group(0) @binding(3) var out: texture_storage_2d<rgba16float, write>;
|
||||||
|
|
||||||
|
@compute @workgroup_size(8, 8)
|
||||||
|
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||||
|
let dims = textureDimensions(denoised);
|
||||||
|
if (gid.x >= dims.x || gid.y >= dims.y) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let xy = vec2<i32>(gid.xy);
|
||||||
|
let d = textureLoad(denoised, xy, 0).rgb;
|
||||||
|
let c = textureLoad(classical, xy, 0).rgb;
|
||||||
|
let wb = p.wb.rgb;
|
||||||
|
let dy = p.grain * dot(vec3<f32>(0.2126, 0.7152, 0.0722), wb * (c - d));
|
||||||
|
textureStore(out, xy, vec4<f32>(d + dy / wb, 1.0));
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
|
struct Params {
|
||||||
|
grain: f32,
|
||||||
|
_pad: [f32; 3],
|
||||||
|
wb: [f32; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GrainBlend {
|
||||||
|
ctx: GpuContext,
|
||||||
|
pipeline: wgpu::ComputePipeline,
|
||||||
|
layout: wgpu::BindGroupLayout,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GrainBlend {
|
||||||
|
pub fn new(ctx: &GpuContext) -> Self {
|
||||||
|
let device = &ctx.device;
|
||||||
|
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("grain-blend"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(SHADER.into()),
|
||||||
|
});
|
||||||
|
let texture = |binding| wgpu::BindGroupLayoutEntry {
|
||||||
|
binding,
|
||||||
|
visibility: wgpu::ShaderStages::COMPUTE,
|
||||||
|
ty: wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
};
|
||||||
|
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("grain-blend-layout"),
|
||||||
|
entries: &[
|
||||||
|
texture(0),
|
||||||
|
texture(1),
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 2,
|
||||||
|
visibility: wgpu::ShaderStages::COMPUTE,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Uniform,
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 3,
|
||||||
|
visibility: wgpu::ShaderStages::COMPUTE,
|
||||||
|
ty: wgpu::BindingType::StorageTexture {
|
||||||
|
access: wgpu::StorageTextureAccess::WriteOnly,
|
||||||
|
format: DemosaicedImage::FORMAT,
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
label: Some("grain-blend-pipeline-layout"),
|
||||||
|
bind_group_layouts: &[Some(&layout)],
|
||||||
|
immediate_size: 0,
|
||||||
|
});
|
||||||
|
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||||
|
label: Some("grain-blend"),
|
||||||
|
layout: Some(&pipeline_layout),
|
||||||
|
module: &module,
|
||||||
|
entry_point: Some("main"),
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
ctx: ctx.clone(),
|
||||||
|
pipeline,
|
||||||
|
layout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `denoised` with `grain` (0–1) of `classical`'s brightness noise back.
|
||||||
|
/// Both must be the same photograph at the same size.
|
||||||
|
pub fn blend(
|
||||||
|
&self,
|
||||||
|
denoised: &DemosaicedImage,
|
||||||
|
classical: &DemosaicedImage,
|
||||||
|
grain: f32,
|
||||||
|
) -> Result<Arc<DemosaicedImage>, GpuError> {
|
||||||
|
let (w, h) = (denoised.texture().width(), denoised.texture().height());
|
||||||
|
if (classical.texture().width(), classical.texture().height()) != (w, h) {
|
||||||
|
return Err(GpuError::TooLarge(format!(
|
||||||
|
"grain from a {}×{} source into a {w}×{h} one",
|
||||||
|
classical.texture().width(),
|
||||||
|
classical.texture().height()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let device = &self.ctx.device;
|
||||||
|
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("grain-blended-source"),
|
||||||
|
size: wgpu::Extent3d {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: DemosaicedImage::FORMAT,
|
||||||
|
usage: wgpu::TextureUsages::STORAGE_BINDING
|
||||||
|
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||||
|
| wgpu::TextureUsages::COPY_SRC,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
let out_view = texture.create_view(&Default::default());
|
||||||
|
let wb = denoised.as_shot_wb();
|
||||||
|
let g = wb[1].max(1e-6);
|
||||||
|
let params = Params {
|
||||||
|
grain: grain.clamp(0.0, 1.0),
|
||||||
|
_pad: [0.0; 3],
|
||||||
|
// Green-normalised, and never zero: the shader divides by it.
|
||||||
|
wb: [(wb[0] / g).max(1e-3), 1.0, (wb[2] / g).max(1e-3), 1.0],
|
||||||
|
};
|
||||||
|
use wgpu::util::DeviceExt;
|
||||||
|
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("grain-blend-params"),
|
||||||
|
contents: bytemuck::bytes_of(¶ms),
|
||||||
|
usage: wgpu::BufferUsages::UNIFORM,
|
||||||
|
});
|
||||||
|
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("grain-blend-bg"),
|
||||||
|
layout: &self.layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: wgpu::BindingResource::TextureView(denoised.view()),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::TextureView(classical.view()),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: buffer.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 3,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&out_view),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("grain-blend"),
|
||||||
|
});
|
||||||
|
{
|
||||||
|
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||||
|
label: Some("grain-blend"),
|
||||||
|
timestamp_writes: None,
|
||||||
|
});
|
||||||
|
pass.set_pipeline(&self.pipeline);
|
||||||
|
pass.set_bind_group(0, &bind, &[]);
|
||||||
|
pass.dispatch_workgroups(w.div_ceil(8), h.div_ceil(8), 1);
|
||||||
|
}
|
||||||
|
self.ctx.queue.submit(Some(enc.finish()));
|
||||||
|
Ok(Arc::new(denoised.sibling(texture, w, h)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ mod demosaic;
|
|||||||
mod detail;
|
mod detail;
|
||||||
mod error;
|
mod error;
|
||||||
mod focus;
|
mod focus;
|
||||||
|
mod grain;
|
||||||
mod histogram;
|
mod histogram;
|
||||||
mod mask;
|
mod mask;
|
||||||
mod merge;
|
mod merge;
|
||||||
@@ -41,6 +42,7 @@ pub use demosaic::{DemosaicedImage, Demosaicer};
|
|||||||
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
|
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
|
||||||
pub use error::GpuError;
|
pub use error::GpuError;
|
||||||
pub use focus::{FocusPeakPass, FocusPeaking, PeakColour, PeakSensitivity};
|
pub use focus::{FocusPeakPass, FocusPeaking, PeakColour, PeakSensitivity};
|
||||||
|
pub use grain::GrainBlend;
|
||||||
pub use merge::{Band, MergeFrame, MergeOutput, MergePass};
|
pub use merge::{Band, MergeFrame, MergeOutput, MergePass};
|
||||||
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
|
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
|
||||||
// at all at a crate root shared with demosaic and segmentation.
|
// at all at a crate root shared with demosaic and segmentation.
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ fn frame(tables: Option<ProfileTables>) -> RawImage {
|
|||||||
samples_per_pixel: 3,
|
samples_per_pixel: 3,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: tables.map(Arc::new),
|
profile_tables: tables.map(Arc::new),
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
@@ -133,6 +134,7 @@ fn strong_tables() -> ProfileTables {
|
|||||||
origin: ProfileOrigin::Embedded,
|
origin: ProfileOrigin::Embedded,
|
||||||
hue_sat: Some(HueSatTable::new(hd, sd, 1, false, hue_sat).unwrap()),
|
hue_sat: Some(HueSatTable::new(hd, sd, 1, false, hue_sat).unwrap()),
|
||||||
look: Some(look),
|
look: Some(look),
|
||||||
|
tone_curve: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +192,46 @@ fn the_shader_agrees_with_the_cpu_reference() {
|
|||||||
assert_agrees(&got, |c| apply_reference(&tables, c, 2.0), "look at 200%");
|
assert_agrees(&got, |c| apply_reference(&tables, c, 2.0), "look at 200%");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn camera_raw_tone_agrees_with_its_cpu_reference() {
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
// D21's rendering on 256 colours: the ProPhoto round trip, the clip, the
|
||||||
|
// curve from the profile buffer's placeholder, and RGBTone's placement
|
||||||
|
// of the middle channel, against `camera_raw::apply_reference`.
|
||||||
|
let Some(ctx) = ctx() else {
|
||||||
|
eprintln!("skipping: no GPU adapter");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let source = Demosaicer::new(&ctx)
|
||||||
|
.expect("demosaicer")
|
||||||
|
.run(&frame(None))
|
||||||
|
.expect("upload");
|
||||||
|
let mut view = dr_pipeline::ops::ViewTransform::new();
|
||||||
|
view.set_param(
|
||||||
|
dr_pipeline::ops::view_transform::CURVE,
|
||||||
|
dr_pipeline::ops::view_transform::CAMERA_RAW,
|
||||||
|
);
|
||||||
|
let ops: Vec<Box<dyn Operation>> = vec![Box::new(view)];
|
||||||
|
let shader = dr_pipeline::compose(&ops);
|
||||||
|
let mut adjust = AdjustPass::new(&ctx);
|
||||||
|
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
||||||
|
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||||
|
let got: Vec<[u8; 3]> = pixels.chunks_exact(4).map(|p| [p[0], p[1], p[2]]).collect();
|
||||||
|
let curve = &dr_types::tone::ACR3_DEFAULT;
|
||||||
|
assert_agrees(
|
||||||
|
&got,
|
||||||
|
|c| {
|
||||||
|
dr_pipeline::camera_raw::apply_reference(
|
||||||
|
curve,
|
||||||
|
c,
|
||||||
|
dr_pipeline::view::DEFAULT_CONTRAST,
|
||||||
|
dr_pipeline::view::DEFAULT_WHITE,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"DNG reference tone",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn switched_off_or_absent_the_render_is_unchanged() {
|
fn switched_off_or_absent_the_render_is_unchanged() {
|
||||||
let Some(ctx) = ctx() else {
|
let Some(ctx) = ctx() else {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ fn flat_raw(level: u16) -> RawImage {
|
|||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
//! TRACES: FR-DEV-3g
|
||||||
|
//! The grain blend, read back off the device.
|
||||||
|
|
||||||
|
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||||
|
use dr_gpu::{DemosaicedImage, Demosaicer, GpuContext, GrainBlend};
|
||||||
|
|
||||||
|
const W: u32 = 16;
|
||||||
|
const H: u32 = 8;
|
||||||
|
|
||||||
|
fn ctx() -> Option<GpuContext> {
|
||||||
|
pollster::block_on(GpuContext::new_headless()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A photograph to stand the uploads beside: its as-shot balance is what
|
||||||
|
/// the grain is made neutral under.
|
||||||
|
fn like(ctx: &GpuContext) -> DemosaicedImage {
|
||||||
|
let raw = RawImage {
|
||||||
|
width: W,
|
||||||
|
height: H,
|
||||||
|
data: vec![400; (W * H) as usize],
|
||||||
|
cfa_pattern: 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: CropRect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: W,
|
||||||
|
height: H,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Demosaicer::new(ctx).unwrap().run(&raw).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(ctx: &GpuContext, img: &DemosaicedImage) -> Vec<[f32; 4]> {
|
||||||
|
let (w, h) = (img.texture().width(), img.texture().height());
|
||||||
|
let padded =
|
||||||
|
(w * 8).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||||
|
let buf = ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: None,
|
||||||
|
size: (padded * h) as u64,
|
||||||
|
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
let mut enc = ctx.device.create_command_encoder(&Default::default());
|
||||||
|
enc.copy_texture_to_buffer(
|
||||||
|
img.texture().as_image_copy(),
|
||||||
|
wgpu::TexelCopyBufferInfo {
|
||||||
|
buffer: &buf,
|
||||||
|
layout: wgpu::TexelCopyBufferLayout {
|
||||||
|
offset: 0,
|
||||||
|
bytes_per_row: Some(padded),
|
||||||
|
rows_per_image: Some(h),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wgpu::Extent3d {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ctx.queue.submit(Some(enc.finish()));
|
||||||
|
let slice = buf.slice(..);
|
||||||
|
slice.map_async(wgpu::MapMode::Read, |_| {});
|
||||||
|
ctx.device
|
||||||
|
.poll(wgpu::PollType::wait_indefinitely())
|
||||||
|
.unwrap();
|
||||||
|
let bytes = slice.get_mapped_range();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for y in 0..h as usize {
|
||||||
|
let row: &[u16] =
|
||||||
|
bytemuck::cast_slice(&bytes[y * padded as usize..y * padded as usize + w as usize * 8]);
|
||||||
|
for t in row.chunks(4) {
|
||||||
|
out.push([0, 1, 2, 3].map(|c| half_to_f32(t[c])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn half_to_f32(h: u16) -> f32 {
|
||||||
|
let s = if h & 0x8000 != 0 { -1.0 } else { 1.0 };
|
||||||
|
let e = ((h >> 10) & 0x1f) as i32;
|
||||||
|
let m = (h & 0x3ff) as f32;
|
||||||
|
if e == 0 {
|
||||||
|
s * m * 2f32.powi(-24)
|
||||||
|
} else {
|
||||||
|
s * (1.0 + m / 1024.0) * 2f32.powi(e - 15)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grain_returns_only_neutral_brightness() {
|
||||||
|
let Some(ctx) = ctx() else {
|
||||||
|
eprintln!("no GPU adapter; skipping");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let base = like(&ctx);
|
||||||
|
let n = (W * H) as usize;
|
||||||
|
let d: Vec<f32> = (0..n).flat_map(|_| [0.20, 0.30, 0.10]).collect();
|
||||||
|
// The classical result: the same colour plus noise, coloured noise too.
|
||||||
|
let c: Vec<f32> = (0..n)
|
||||||
|
.flat_map(|i| {
|
||||||
|
let a = ((i * 37) % 11) as f32 / 110.0 - 0.05;
|
||||||
|
let b = ((i * 53) % 7) as f32 / 140.0 - 0.025;
|
||||||
|
[0.20 + a, 0.30 + b, 0.10 - a]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let denoised = DemosaicedImage::from_rgb_f32(&ctx, &base, W, H, &d).unwrap();
|
||||||
|
let classical = DemosaicedImage::from_rgb_f32(&ctx, &base, W, H, &c).unwrap();
|
||||||
|
let blend = GrainBlend::new(&ctx);
|
||||||
|
let wb = [2.0f32, 1.0, 1.5];
|
||||||
|
|
||||||
|
let none = read(&ctx, &blend.blend(&denoised, &classical, 0.0).unwrap());
|
||||||
|
for p in &none {
|
||||||
|
for ch in 0..3 {
|
||||||
|
assert!(
|
||||||
|
(p[ch] - d[ch]).abs() < 1e-3,
|
||||||
|
"grain 0 must be the network's result: {p:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let all = read(&ctx, &blend.blend(&denoised, &classical, 1.0).unwrap());
|
||||||
|
for (i, p) in all.iter().enumerate() {
|
||||||
|
let want_dy: f32 = [0.2126f32, 0.7152, 0.0722]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(ch, k)| k * wb[ch] * (c[i * 3 + ch] - d[ch]))
|
||||||
|
.sum();
|
||||||
|
// After white balance every channel moved by the same amount.
|
||||||
|
for ch in 0..3 {
|
||||||
|
let moved = wb[ch] * (p[ch] - d[ch]);
|
||||||
|
assert!(
|
||||||
|
(moved - want_dy).abs() < 2e-3,
|
||||||
|
"pixel {i} channel {ch}: moved {moved}, want {want_dy}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ fn frame(pattern: CfaPattern, level: u16, set: &[(u32, u32, u16)]) -> RawImage {
|
|||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
@@ -140,3 +141,33 @@ fn a_hot_photosite_on_x_trans_is_invisible() {
|
|||||||
let diff = worst(&clean, &hot);
|
let diff = worst(&clean, &hot);
|
||||||
assert!(diff <= 1, "a hot X-Trans photosite still shows, by {diff}");
|
assert!(diff <= 1, "a hot X-Trans photosite still shows, by {diff}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The repair alone, read back (FR-DEV-3g): the learned demosaic takes the
|
||||||
|
/// mosaic this pass leaves, so it must be the same pass and nothing more —
|
||||||
|
/// the hot photosite replaced, a real highlight and every other photosite
|
||||||
|
/// untouched.
|
||||||
|
#[test]
|
||||||
|
fn the_repaired_mosaic_reads_back_with_only_the_defect_changed() {
|
||||||
|
let Some(ctx) = ctx() else {
|
||||||
|
eprintln!("no GPU adapter; skipping");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let d = Demosaicer::new(&ctx).expect("demosaicer");
|
||||||
|
let mut star = vec![(MIDDLE, MIDDLE, WHITE)];
|
||||||
|
for dy in 0..3 {
|
||||||
|
for dx in 0..3 {
|
||||||
|
star.push((4 + dx, 4 + dy, WHITE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let before = frame(CfaPattern::Rggb, 40, &star);
|
||||||
|
let mut raw = before.clone();
|
||||||
|
let changed = d.repair_hot_pixels(&mut raw).expect("repair");
|
||||||
|
assert_eq!(changed, 1, "only the lone hot photosite should change");
|
||||||
|
let at = (MIDDLE * SIZE + MIDDLE) as usize;
|
||||||
|
assert_eq!(
|
||||||
|
raw.data[at], 40,
|
||||||
|
"repaired to its brightest same-colour neighbour"
|
||||||
|
);
|
||||||
|
let others = (0..raw.data.len()).filter(|&i| i != at);
|
||||||
|
assert!(others.into_iter().all(|i| raw.data[i] == before.data[i]));
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ fn flat(ctx: &GpuContext, level: f32) -> dr_gpu::DemosaicedImage {
|
|||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ fn linear_frame(w: u32, h: u32, noise: bool) -> RawImage {
|
|||||||
samples_per_pixel: 3,
|
samples_per_pixel: 3,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ fn flat_raw(level: u16) -> RawImage {
|
|||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: CropRect {
|
crop: CropRect {
|
||||||
@@ -45,6 +46,53 @@ fn flat_raw(level: u16) -> RawImage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The default chain with D19's sigmoid chosen explicitly, so these tests
|
||||||
|
/// stay about the sigmoid whichever curve is the default (D21).
|
||||||
|
fn sigmoid_chain() -> EditGraph {
|
||||||
|
let mut g = EditGraph::default_chain();
|
||||||
|
g.set_param(
|
||||||
|
dr_pipeline::ops::view_transform::ID,
|
||||||
|
dr_pipeline::ops::view_transform::CURVE,
|
||||||
|
dr_pipeline::ops::view_transform::SIGMOID,
|
||||||
|
);
|
||||||
|
g
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn camera_raw_tone_agrees_with_the_acr3_curve() {
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
// D21: a raw with no profile, the DNG reference curve chosen, renders a grey
|
||||||
|
// through the ACR3 default curve, which the profile buffer's placeholder
|
||||||
|
// carries.
|
||||||
|
let Some(ctx) = ctx() else {
|
||||||
|
eprintln!("skipping: no GPU adapter");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut graph = EditGraph::default_chain();
|
||||||
|
graph.set_param(
|
||||||
|
dr_pipeline::ops::view_transform::ID,
|
||||||
|
dr_pipeline::ops::view_transform::CURVE,
|
||||||
|
dr_pipeline::ops::view_transform::CAMERA_RAW,
|
||||||
|
);
|
||||||
|
// The table itself, so its own contrast: the default bends the input a
|
||||||
|
// little past it (D21).
|
||||||
|
graph.set_param(
|
||||||
|
dr_pipeline::ops::view_transform::ID,
|
||||||
|
dr_pipeline::ops::view_transform::CONTRAST,
|
||||||
|
dr_pipeline::view::REFERENCE_CONTRAST,
|
||||||
|
);
|
||||||
|
for level in [500u16, 4_000, 8_520, 20_000, 40_000] {
|
||||||
|
let scene = f32::from(level) / f32::from(u16::MAX);
|
||||||
|
let display = dr_types::tone::evaluate(&dr_types::tone::ACR3_DEFAULT, scene);
|
||||||
|
let expected = (dr_types::Transfer::Srgb.encode(display) * 255.0).round() as i32;
|
||||||
|
let got = i32::from(rendered(&ctx, level, &graph));
|
||||||
|
assert!(
|
||||||
|
(got - expected).abs() <= 2,
|
||||||
|
"raw {level} rendered as {got}, the ACR3 curve says {expected}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Render `graph` over a flat frame and return the centre pixel's red.
|
/// Render `graph` over a flat frame and return the centre pixel's red.
|
||||||
///
|
///
|
||||||
/// The centre rather than a corner: a demosaic has to invent its edges.
|
/// The centre rather than a corner: a demosaic has to invent its edges.
|
||||||
@@ -69,7 +117,7 @@ fn the_shader_agrees_with_the_cpu_reference() {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let curve = Sigmoid::default_curve();
|
let curve = Sigmoid::default_curve();
|
||||||
let graph = EditGraph::default_chain();
|
let graph = sigmoid_chain();
|
||||||
for level in [0u16, 500, 4_000, 8_520, 32_768, 60_000, u16::MAX] {
|
for level in [0u16, 500, 4_000, 8_520, 32_768, 60_000, u16::MAX] {
|
||||||
let scene = f32::from(level) / f32::from(u16::MAX);
|
let scene = f32::from(level) / f32::from(u16::MAX);
|
||||||
let display = curve.channel(scene).min(1.0);
|
let display = curve.channel(scene).min(1.0);
|
||||||
@@ -95,7 +143,7 @@ fn highlights_above_one_stay_distinct() {
|
|||||||
eprintln!("skipping: no GPU adapter");
|
eprintln!("skipping: no GPU adapter");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut graph = EditGraph::default_chain();
|
let mut graph = sigmoid_chain();
|
||||||
graph.set_param(
|
graph.set_param(
|
||||||
dr_pipeline::ops::exposure::ID,
|
dr_pipeline::ops::exposure::ID,
|
||||||
dr_pipeline::ops::exposure::EXPOSURE,
|
dr_pipeline::ops::exposure::EXPOSURE,
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ ort = { workspace = true, features = ["cuda", "tensorrt"] }
|
|||||||
[target.'cfg(target_os = "android")'.dependencies]
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
ort = { workspace = true, features = ["qnn"] }
|
ort = { workspace = true, features = ["qnn"] }
|
||||||
|
|
||||||
|
# The Apple rung: CoreML's option builder, which fills the runtime's generic
|
||||||
|
# key/value map. `ort-sys`'s `coreml` feature is empty; nothing links.
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
ort = { workspace = true, features = ["coreml"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# The floor: `tract` supplies the API table when no runtime file is found, or
|
# The floor: `tract` supplies the API table when no runtime file is found, or
|
||||||
# always, in a build without `native`. Tests want this and nothing else.
|
# always, in a build without `native`. Tests want this and nothing else.
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ pub fn context_path(cfg: &Config, bytes: &[u8]) -> PathBuf {
|
|||||||
.join(format!("{:016x}_ctx.onnx", hash(bytes)))
|
.join(format!("{:016x}_ctx.onnx", hash(bytes)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where CoreML compiles `bytes` to: one directory per model, because
|
||||||
|
/// CoreML's own cache key leaves out the weights of a model loaded from
|
||||||
|
/// memory (`session::coreml`), and one per runtime version, which wrote it.
|
||||||
|
pub fn coreml_dir(cfg: &Config, bytes: &[u8]) -> PathBuf {
|
||||||
|
let runtime = match crate::api::runtime() {
|
||||||
|
crate::Runtime::OnnxRuntime { version, .. } => version,
|
||||||
|
crate::Runtime::Tract => "tract".into(),
|
||||||
|
};
|
||||||
|
cfg.cache_dir
|
||||||
|
.join("coreml")
|
||||||
|
.join(runtime)
|
||||||
|
.join(format!("{:016x}", hash(bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
/// After the probe: compile every configured model the selected rung can
|
/// After the probe: compile every configured model the selected rung can
|
||||||
/// take, smallest first, recording each as it lands.
|
/// take, smallest first, recording each as it lands.
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -90,12 +104,27 @@ pub fn run() {
|
|||||||
Source::Bytes(b) => (b.to_vec(), format!("embedded {role:?}")),
|
Source::Bytes(b) => (b.to_vec(), format!("embedded {role:?}")),
|
||||||
};
|
};
|
||||||
let key = key(rung, &bytes);
|
let key = key(rung, &bytes);
|
||||||
if state().lock().unwrap().cache.compiled.contains(&key) {
|
{
|
||||||
continue;
|
let s = state().lock().unwrap();
|
||||||
|
if s.cache.compiled.contains(&key) || s.cache.refused.contains(&key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
log::info!("inference: compiling {name} for {}", rung.label());
|
log::info!("inference: compiling {name} for {}", rung.label());
|
||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
match crate::session::build(rung, role, &bytes, &cfg) {
|
let built = match crate::probe::attempt(&cfg, &key, || {
|
||||||
|
crate::session::build(rung, role, &bytes, &cfg)
|
||||||
|
}) {
|
||||||
|
Ok(built) => built,
|
||||||
|
Err(_) => {
|
||||||
|
// Refused: the process died inside this compile before.
|
||||||
|
let mut s = state().lock().unwrap();
|
||||||
|
s.cache.refused.insert(key);
|
||||||
|
crate::probe::write_cache(&s.config, &s.cache);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match built {
|
||||||
Ok(session) => {
|
Ok(session) => {
|
||||||
drop(session);
|
drop(session);
|
||||||
let mut s = state().lock().unwrap();
|
let mut s = state().lock().unwrap();
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ pub enum Role {
|
|||||||
/// convolutions, so any rung serves it; fp16 on TensorRT and int8 on
|
/// convolutions, so any rung serves it; fp16 on TensorRT and int8 on
|
||||||
/// the Hexagon are the point of it.
|
/// the Hexagon are the point of it.
|
||||||
Inpainter,
|
Inpainter,
|
||||||
|
/// The learned demosaic and denoise on the raw mosaic (docs/dev/denoise.md).
|
||||||
|
/// fp16 costs it nothing measurable; int8 costs 6–9 dB, because 256
|
||||||
|
/// levels cannot hold the shadow steps it exists to recover — so the
|
||||||
|
/// Hexagon does not take it.
|
||||||
|
Denoiser,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which numeric form of a model a session was built from.
|
/// Which numeric form of a model a session was built from.
|
||||||
@@ -78,6 +83,12 @@ pub enum Rung {
|
|||||||
MiGraphX,
|
MiGraphX,
|
||||||
/// Qualcomm's Hexagon NPU through QNN, int8 models only. Android only.
|
/// Qualcomm's Hexagon NPU through QNN, int8 models only. Android only.
|
||||||
Hexagon,
|
Hexagon,
|
||||||
|
/// Apple, through CoreML: the Neural Engine, the GPU or the CPU, as
|
||||||
|
/// CoreML schedules it. macOS only. Compiles an ML Program per model on
|
||||||
|
/// first use, so it is a compiling rung with the CPU below it. The
|
||||||
|
/// embedder stays on the CPU, as on the Hexagon: the Neural Engine
|
||||||
|
/// computes in fp16 (§7).
|
||||||
|
CoreMl,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Rung {
|
impl Rung {
|
||||||
@@ -88,6 +99,7 @@ impl Rung {
|
|||||||
Rung::TensorRt => "TensorRT",
|
Rung::TensorRt => "TensorRT",
|
||||||
Rung::MiGraphX => "MIGraphX",
|
Rung::MiGraphX => "MIGraphX",
|
||||||
Rung::Hexagon => "Hexagon NPU",
|
Rung::Hexagon => "Hexagon NPU",
|
||||||
|
Rung::CoreMl => "CoreML",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,13 +108,16 @@ impl Rung {
|
|||||||
fn fallback(self) -> Rung {
|
fn fallback(self) -> Rung {
|
||||||
match self {
|
match self {
|
||||||
Rung::TensorRt => Rung::Cuda,
|
Rung::TensorRt => Rung::Cuda,
|
||||||
Rung::MiGraphX | Rung::Hexagon | Rung::Cuda | Rung::Cpu => Rung::Cpu,
|
Rung::MiGraphX | Rung::Hexagon | Rung::CoreMl | Rung::Cuda | Rung::Cpu => Rung::Cpu,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a session on this rung needs an engine built first.
|
/// Whether a session on this rung needs an engine built first.
|
||||||
fn compiles(self) -> bool {
|
fn compiles(self) -> bool {
|
||||||
matches!(self, Rung::TensorRt | Rung::MiGraphX | Rung::Hexagon)
|
matches!(
|
||||||
|
self,
|
||||||
|
Rung::TensorRt | Rung::MiGraphX | Rung::Hexagon | Rung::CoreMl
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The model form this rung wants for a role.
|
/// The model form this rung wants for a role.
|
||||||
@@ -116,9 +131,14 @@ impl Rung {
|
|||||||
/// Whether this rung runs `role` at all. The Hexagon takes int8 graphs
|
/// Whether this rung runs `role` at all. The Hexagon takes int8 graphs
|
||||||
/// only, and the embedder is never int8 (§7) — it runs on the CPU
|
/// only, and the embedder is never int8 (§7) — it runs on the CPU
|
||||||
/// beside a detector on the NPU, so its vectors compare across devices.
|
/// beside a detector on the NPU, so its vectors compare across devices.
|
||||||
|
/// Nor is the denoiser: its int8 form failed the 0.5 dB gate by 6–9 dB
|
||||||
|
/// (denoise.md §8), so it runs on the CPU there too. CoreML is kept off
|
||||||
|
/// the embedder for the same reason as the Hexagon: the Neural Engine is
|
||||||
|
/// fp16, and which unit runs a graph is CoreML's choice.
|
||||||
fn serves(self, role: Role) -> bool {
|
fn serves(self, role: Role) -> bool {
|
||||||
match self {
|
match self {
|
||||||
Rung::Hexagon => role != Role::Embedder,
|
Rung::Hexagon => !matches!(role, Role::Embedder | Role::Denoiser),
|
||||||
|
Rung::CoreMl => role != Role::Embedder,
|
||||||
_ => true,
|
_ => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,6 +368,12 @@ struct Cache {
|
|||||||
/// the fingerprint changes: a wedged driver must not cost every launch
|
/// the fingerprint changes: a wedged driver must not cost every launch
|
||||||
/// thirty seconds.
|
/// thirty seconds.
|
||||||
failed: Vec<(Rung, String)>,
|
failed: Vec<(Rung, String)>,
|
||||||
|
/// Engine keys whose compile the process died inside, launch after
|
||||||
|
/// launch (`probe::attempt`). Left on the fallback until the
|
||||||
|
/// fingerprint changes. Defaulted, so a cache from before this field
|
||||||
|
/// still reads.
|
||||||
|
#[serde(default)]
|
||||||
|
refused: BTreeSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct State {
|
struct State {
|
||||||
@@ -586,6 +612,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn the_hexagon_never_takes_the_embedder() {
|
fn the_hexagon_never_takes_the_embedder() {
|
||||||
assert!(!Rung::Hexagon.serves(Role::Embedder));
|
assert!(!Rung::Hexagon.serves(Role::Embedder));
|
||||||
|
assert!(!Rung::Hexagon.serves(Role::Denoiser));
|
||||||
assert!(Rung::Hexagon.serves(Role::Detector));
|
assert!(Rung::Hexagon.serves(Role::Detector));
|
||||||
assert_eq!(Rung::Hexagon.form(Role::Detector), Form::Int8);
|
assert_eq!(Rung::Hexagon.form(Role::Detector), Form::Int8);
|
||||||
// A detector offered in f32 on a Hexagon device lands on the CPU.
|
// A detector offered in f32 on a Hexagon device lands on the CPU.
|
||||||
@@ -631,6 +658,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coreml_takes_a_compiled_detector_and_never_the_embedder() {
|
||||||
|
let hash = engines::hash(b"detector");
|
||||||
|
let mut s = State {
|
||||||
|
config: Config::default(),
|
||||||
|
cache: Cache {
|
||||||
|
rung: Some(Rung::CoreMl),
|
||||||
|
..Cache::default()
|
||||||
|
},
|
||||||
|
probing: false,
|
||||||
|
wanted: 0,
|
||||||
|
};
|
||||||
|
let on = |s: &State, role| effective_rung(s, Rung::CoreMl, role, Form::F32, hash);
|
||||||
|
// Before its program is compiled the detector waits on the CPU.
|
||||||
|
assert_eq!(on(&s, Role::Detector), Rung::Cpu);
|
||||||
|
s.cache.compiled.insert(engines::key_of(Rung::CoreMl, hash));
|
||||||
|
assert_eq!(on(&s, Role::Detector), Rung::CoreMl);
|
||||||
|
// The embedder does not move, compiled or not (§7).
|
||||||
|
assert_eq!(on(&s, Role::Embedder), Rung::Cpu);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_status_reports_only_the_rungs_above_the_selection() {
|
fn the_status_reports_only_the_rungs_above_the_selection() {
|
||||||
let _serial = serial();
|
let _serial = serial();
|
||||||
|
|||||||
@@ -16,10 +16,15 @@ use crate::{api::Runtime, state, Cache, Config, Form, Role, Rung};
|
|||||||
fn ladder(ceiling: Option<Rung>) -> Vec<Rung> {
|
fn ladder(ceiling: Option<Rung>) -> Vec<Rung> {
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
let all = [Rung::Hexagon];
|
let all = [Rung::Hexagon];
|
||||||
|
// Unmeasured (§2 ⁵): it is on the ladder because the probe's clock and
|
||||||
|
// `attempt` make a wrong guess cost one slow or failed probe, not a
|
||||||
|
// slow or crashing app.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let all = [Rung::CoreMl];
|
||||||
// A desktop has one vendor's GPU; the other vendor's providers are
|
// A desktop has one vendor's GPU; the other vendor's providers are
|
||||||
// "not enabled in this build" or a library that fails to load, and
|
// "not enabled in this build" or a library that fails to load, and
|
||||||
// either answer arrives in milliseconds.
|
// either answer arrives in milliseconds.
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(any(target_os = "android", target_os = "macos")))]
|
||||||
let all = [Rung::TensorRt, Rung::Cuda, Rung::MiGraphX];
|
let all = [Rung::TensorRt, Rung::Cuda, Rung::MiGraphX];
|
||||||
all.into_iter()
|
all.into_iter()
|
||||||
.filter(|r| ceiling.is_none_or(|c| *r <= c))
|
.filter(|r| ceiling.is_none_or(|c| *r <= c))
|
||||||
@@ -81,7 +86,11 @@ pub fn run(runtime: Runtime) {
|
|||||||
log::info!("inference: floor {floor:.1} ms on the CPU provider");
|
log::info!("inference: floor {floor:.1} ms on the CPU provider");
|
||||||
|
|
||||||
for rung in ladder(cfg.ceiling) {
|
for rung in ladder(cfg.ceiling) {
|
||||||
match time_rung(rung, role, &canonical, &cfg) {
|
let timed = attempt(&cfg, &format!("probe {}", rung.label()), || {
|
||||||
|
time_rung(rung, role, &canonical, &cfg)
|
||||||
|
})
|
||||||
|
.and_then(|timed| timed);
|
||||||
|
match timed {
|
||||||
Ok((ms, key)) if ms < floor => {
|
Ok((ms, key)) if ms < floor => {
|
||||||
cache.rung = Some(rung);
|
cache.rung = Some(rung);
|
||||||
cache.reason = format!("{ms:.1} ms against {floor:.1} ms on the CPU");
|
cache.reason = format!("{ms:.1} ms against {floor:.1} ms on the CPU");
|
||||||
@@ -118,6 +127,52 @@ fn finish(cache: Cache) {
|
|||||||
s.probing = false;
|
s.probing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many launches in a row may die inside one attempt before it is
|
||||||
|
/// refused. Two, not one: quitting the app while TensorRT spends forty
|
||||||
|
/// seconds on an engine leaves the same trace as a provider that aborted.
|
||||||
|
const STRIKES: u32 = 2;
|
||||||
|
|
||||||
|
/// Run `f` — a session build on a provider — with `what` written down
|
||||||
|
/// first, so that if the provider takes the process with it the next launch
|
||||||
|
/// knows what to stop trying.
|
||||||
|
///
|
||||||
|
/// A provider can fail by aborting rather than by returning an error:
|
||||||
|
/// XNNPACK did on SCRFD (§2), and a C++ exception or a panic across the C
|
||||||
|
/// API is an abort. The probe runs in the app's own process, so a rung that
|
||||||
|
/// does this once would do it on every launch, before the first photograph
|
||||||
|
/// is on screen. The file (`attempt` in the cache directory) holds the
|
||||||
|
/// attempt and how many launches have started it without finishing;
|
||||||
|
/// finishing, by success or by error, removes it. After [`STRIKES`] the
|
||||||
|
/// attempt is refused, and the caller records the refusal in the cache,
|
||||||
|
/// where it lasts until the fingerprint changes like any other failure.
|
||||||
|
pub fn attempt<T>(cfg: &Config, what: &str, f: impl FnOnce() -> T) -> Result<T, String> {
|
||||||
|
if cfg.cache_dir.as_os_str().is_empty() {
|
||||||
|
return Ok(f());
|
||||||
|
}
|
||||||
|
let path = cfg.cache_dir.join("attempt");
|
||||||
|
let died = std::fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| {
|
||||||
|
let (w, n) = s.split_once('\t')?;
|
||||||
|
(w == what).then(|| n.trim().parse::<u32>().ok())?
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
if died >= STRIKES {
|
||||||
|
log::error!("inference: the app died during `{what}` on the last {died} launches; not trying it again");
|
||||||
|
return Err(format!(
|
||||||
|
"the app died while trying this on {died} launches in a row"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if died > 0 {
|
||||||
|
log::warn!("inference: the last launch died during `{what}`; trying it once more");
|
||||||
|
}
|
||||||
|
let _ = std::fs::create_dir_all(&cfg.cache_dir);
|
||||||
|
let _ = std::fs::write(&path, format!("{what}\t{}", died + 1));
|
||||||
|
let out = f();
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
/// The smallest detector, or the smallest model of any role if there is
|
/// The smallest detector, or the smallest model of any role if there is
|
||||||
/// none. A ~2 MB detector is the cheapest real test of a provider, and the
|
/// none. A ~2 MB detector is the cheapest real test of a provider, and the
|
||||||
/// detector is the role the int8 forms exist for — the eye classifiers are
|
/// detector is the role the int8 forms exist for — the eye classifiers are
|
||||||
@@ -168,21 +223,33 @@ fn time_rung(
|
|||||||
started.elapsed().as_secs_f64()
|
started.elapsed().as_secs_f64()
|
||||||
);
|
);
|
||||||
|
|
||||||
let shape: Vec<usize> = session.inputs()[0]
|
// Zeros for every input the model declares, by name — the denoiser
|
||||||
.dtype()
|
// takes two (mosaic and σ), and a probe that fed only the first failed
|
||||||
.tensor_shape()
|
// every rung and left it on the CPU.
|
||||||
.ok_or("model input is not a tensor")?
|
let feeds: Vec<(String, Vec<usize>)> = session
|
||||||
|
.inputs()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&d| if d > 0 { d as usize } else { 1 })
|
.map(|i| {
|
||||||
.collect();
|
let shape = i
|
||||||
let zeros = vec![0f32; shape.iter().product()];
|
.dtype()
|
||||||
|
.tensor_shape()
|
||||||
|
.ok_or("model input is not a tensor")?
|
||||||
|
.iter()
|
||||||
|
.map(|&d| if d > 0 { d as usize } else { 1 })
|
||||||
|
.collect();
|
||||||
|
Ok((i.name().to_string(), shape))
|
||||||
|
})
|
||||||
|
.collect::<Result<_, &str>>()?;
|
||||||
let run = |session: &mut ort::session::Session| -> Result<f64, String> {
|
let run = |session: &mut ort::session::Session| -> Result<f64, String> {
|
||||||
let input = ort::value::Tensor::from_array((shape.clone(), zeros.clone()))
|
let mut inputs: Vec<(String, ort::session::SessionInputValue)> = Vec::new();
|
||||||
.map_err(|e| e.to_string())?;
|
for (name, shape) in &feeds {
|
||||||
|
let zeros = vec![0f32; shape.iter().product()];
|
||||||
|
let t = ort::value::Tensor::from_array((shape.clone(), zeros))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
inputs.push((name.clone(), t.into()));
|
||||||
|
}
|
||||||
let t = Instant::now();
|
let t = Instant::now();
|
||||||
let out = session
|
let out = session.run(inputs).map_err(|e| e.to_string())?;
|
||||||
.run(ort::inputs![input])
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
let _ = out[0]
|
let _ = out[0]
|
||||||
.try_extract_tensor::<f32>()
|
.try_extract_tensor::<f32>()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -310,7 +377,50 @@ fn system_property(name: &str) -> String {
|
|||||||
String::from_utf8_lossy(&buf[..n.max(0) as usize]).into_owned()
|
String::from_utf8_lossy(&buf[..n.max(0) as usize]).into_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
#[cfg(target_os = "macos")]
|
||||||
|
fn device_identity() -> String {
|
||||||
|
// The chip, and the OS release: CoreML ships with the OS, so a macOS
|
||||||
|
// update is a new provider as surely as a new driver is on Linux.
|
||||||
|
format!(
|
||||||
|
"{} macOS {}",
|
||||||
|
sysctl("machdep.cpu.brand_string"),
|
||||||
|
sysctl("kern.osproductversion")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn sysctl(name: &str) -> String {
|
||||||
|
extern "C" {
|
||||||
|
fn sysctlbyname(
|
||||||
|
name: *const std::ffi::c_char,
|
||||||
|
oldp: *mut std::ffi::c_void,
|
||||||
|
oldlenp: *mut usize,
|
||||||
|
newp: *mut std::ffi::c_void,
|
||||||
|
newlen: usize,
|
||||||
|
) -> i32;
|
||||||
|
}
|
||||||
|
let name = std::ffi::CString::new(name).unwrap();
|
||||||
|
let mut buf = [0u8; 256];
|
||||||
|
let mut len = buf.len();
|
||||||
|
// SAFETY: libSystem's documented call; `len` is the buffer's size in and
|
||||||
|
// the string's length, with its terminator, out.
|
||||||
|
let rc = unsafe {
|
||||||
|
sysctlbyname(
|
||||||
|
name.as_ptr(),
|
||||||
|
buf.as_mut_ptr().cast(),
|
||||||
|
&mut len,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc != 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let s = &buf[..len.min(buf.len())];
|
||||||
|
String::from_utf8_lossy(s.strip_suffix(&[0]).unwrap_or(s)).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "macos")))]
|
||||||
fn device_identity() -> String {
|
fn device_identity() -> String {
|
||||||
String::new()
|
String::new()
|
||||||
}
|
}
|
||||||
@@ -338,3 +448,49 @@ pub fn write_cache(cfg: &Config, cache: &Cache) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn a_cache_dir(name: &str) -> Config {
|
||||||
|
let dir = std::env::temp_dir().join(format!("dr-attempt-{}-{name}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
Config {
|
||||||
|
cache_dir: dir,
|
||||||
|
..Config::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a launch that died inside `what` leaves behind.
|
||||||
|
fn died_inside(cfg: &Config, what: &str, launches: u32) {
|
||||||
|
std::fs::create_dir_all(&cfg.cache_dir).unwrap();
|
||||||
|
std::fs::write(cfg.cache_dir.join("attempt"), format!("{what}\t{launches}")).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_finished_attempt_leaves_no_trace() {
|
||||||
|
let cfg = a_cache_dir("finished");
|
||||||
|
assert_eq!(attempt(&cfg, "probe CoreML", || 7), Ok(7));
|
||||||
|
assert!(!cfg.cache_dir.join("attempt").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_death_is_forgiven_and_two_are_not() {
|
||||||
|
let cfg = a_cache_dir("strikes");
|
||||||
|
died_inside(&cfg, "probe CoreML", 1);
|
||||||
|
assert_eq!(attempt(&cfg, "probe CoreML", || 7), Ok(7));
|
||||||
|
|
||||||
|
died_inside(&cfg, "probe CoreML", 2);
|
||||||
|
let mut ran = false;
|
||||||
|
assert!(attempt(&cfg, "probe CoreML", || ran = true).is_err());
|
||||||
|
assert!(!ran, "a refused attempt must not run");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn another_attempts_deaths_do_not_count() {
|
||||||
|
let cfg = a_cache_dir("other");
|
||||||
|
died_inside(&cfg, "probe TensorRT", 2);
|
||||||
|
assert_eq!(attempt(&cfg, "probe CUDA", || 7), Ok(7));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,24 +19,61 @@ pub fn build(rung: Rung, role: Role, bytes: &[u8], cfg: &Config) -> ort::Result<
|
|||||||
// `stack_tensors`) — a panic across the C API, which is an abort. The
|
// `stack_tensors`) — a panic across the C API, which is an abort. The
|
||||||
// app never asked tract for that and does not start now.
|
// app never asked tract for that and does not start now.
|
||||||
let mut b = Session::builder()?.with_intra_threads(threads(cfg))?;
|
let mut b = Session::builder()?.with_intra_threads(threads(cfg))?;
|
||||||
|
if crate::api::runtime().is_native() {
|
||||||
|
b = with_runtime_log(b)?;
|
||||||
|
}
|
||||||
// A Hexagon session loads the compiled context when there is one and
|
// A Hexagon session loads the compiled context when there is one and
|
||||||
// compiles it from the model when there is not; the engine thread is
|
// compiles it from the model when there is not; the engine thread is
|
||||||
// what makes the second case rare (§6).
|
// what makes the second case rare (§6).
|
||||||
let context = (rung == Rung::Hexagon).then(|| crate::engines::context_path(cfg, bytes));
|
let context = (rung == Rung::Hexagon).then(|| crate::engines::context_path(cfg, bytes));
|
||||||
let ready = context.as_ref().is_some_and(|p| p.is_file());
|
let ready = context.as_ref().is_some_and(|p| p.is_file());
|
||||||
b = providers(
|
// What the rung keeps for this model: the context the Hexagon is to
|
||||||
b,
|
// write, or the directory CoreML compiles into.
|
||||||
rung,
|
let per_model = match rung {
|
||||||
role,
|
Rung::CoreMl => Some(crate::engines::coreml_dir(cfg, bytes)),
|
||||||
cfg,
|
_ if ready => None,
|
||||||
if ready { None } else { context.as_deref() },
|
_ => context.clone(),
|
||||||
)?;
|
};
|
||||||
|
b = providers(b, rung, role, cfg, per_model.as_deref())?;
|
||||||
match (ready, context) {
|
match (ready, context) {
|
||||||
(true, Some(path)) => b.commit_from_file(path),
|
(true, Some(path)) => b.commit_from_file(path),
|
||||||
_ => b.commit_from_memory(bytes),
|
_ => b.commit_from_memory(bytes),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send the runtime's own messages for this session to `log`, under the
|
||||||
|
/// target `onnxruntime`, instead of to ONNX Runtime's stdio logger.
|
||||||
|
///
|
||||||
|
/// Its stderr is nowhere once the app is launched from a menu, and what a
|
||||||
|
/// provider says while it partitions a graph — how many nodes it took, which
|
||||||
|
/// operator it declined, the library it failed to load — is most of what a
|
||||||
|
/// failed rung tells you (docs/dev/inference.md §4). The level follows the
|
||||||
|
/// filter: warnings always, `debug` adds the runtime's info lines (the
|
||||||
|
/// partition counts), `trace` its verbose ones (every node placement).
|
||||||
|
fn with_runtime_log(
|
||||||
|
b: ort::session::builder::SessionBuilder,
|
||||||
|
) -> ort::Result<ort::session::builder::SessionBuilder> {
|
||||||
|
use ort::logging::LogLevel;
|
||||||
|
let level = if log::log_enabled!(target: "onnxruntime", log::Level::Trace) {
|
||||||
|
LogLevel::Verbose
|
||||||
|
} else if log::log_enabled!(target: "onnxruntime", log::Level::Debug) {
|
||||||
|
LogLevel::Info
|
||||||
|
} else {
|
||||||
|
LogLevel::Warning
|
||||||
|
};
|
||||||
|
let forward = |level: LogLevel, _category: &str, _id: &str, location: &str, message: &str| {
|
||||||
|
let level = match level {
|
||||||
|
LogLevel::Verbose => log::Level::Trace,
|
||||||
|
LogLevel::Info => log::Level::Debug,
|
||||||
|
LogLevel::Warning => log::Level::Warn,
|
||||||
|
LogLevel::Error | LogLevel::Fatal => log::Level::Error,
|
||||||
|
};
|
||||||
|
log::log!(target: "onnxruntime", level, "{message} ({location})");
|
||||||
|
};
|
||||||
|
Ok(b.with_logger(std::sync::Arc::new(forward))?
|
||||||
|
.with_log_level(level)?)
|
||||||
|
}
|
||||||
|
|
||||||
/// The intra-op pool: what the config says, else the cores less two for
|
/// The intra-op pool: what the config says, else the cores less two for
|
||||||
/// the compositor and the decoder (§9). tract ignores it.
|
/// the compositor and the decoder (§9). tract ignores it.
|
||||||
fn threads(cfg: &Config) -> usize {
|
fn threads(cfg: &Config) -> usize {
|
||||||
@@ -54,11 +91,12 @@ fn providers(
|
|||||||
rung: Rung,
|
rung: Rung,
|
||||||
role: Role,
|
role: Role,
|
||||||
cfg: &Config,
|
cfg: &Config,
|
||||||
_generate_context: Option<&std::path::Path>,
|
per_model: Option<&std::path::Path>,
|
||||||
) -> ort::Result<ort::session::builder::SessionBuilder> {
|
) -> ort::Result<ort::session::builder::SessionBuilder> {
|
||||||
use ort::ep;
|
use ort::ep;
|
||||||
match rung {
|
match rung {
|
||||||
Rung::Cpu => Ok(b),
|
Rung::Cpu => Ok(b),
|
||||||
|
Rung::CoreMl => coreml(b, per_model),
|
||||||
Rung::Cuda => {
|
Rung::Cuda => {
|
||||||
Ok(b.with_execution_providers([ep::CUDA::default().build().error_on_failure()])?)
|
Ok(b.with_execution_providers([ep::CUDA::default().build().error_on_failure()])?)
|
||||||
}
|
}
|
||||||
@@ -103,6 +141,43 @@ fn providers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CoreML, compiling an ML Program — the format with the operators these
|
||||||
|
/// graphs use and the one that reaches the Neural Engine — into `cache`.
|
||||||
|
///
|
||||||
|
/// The option names are those ONNX Runtime 1.29 reads from the generic
|
||||||
|
/// key/value map (`coreml_options.cc`), which is what `ort`'s builder
|
||||||
|
/// fills. The cache is per model because of how CoreML keys it: a model
|
||||||
|
/// committed from memory, as every session here is, has no path, and the
|
||||||
|
/// key falls back to a hash of the graph's input and node names — not its
|
||||||
|
/// weights. Two exports of one architecture would share a program. The
|
||||||
|
/// directory `engines::coreml_dir` names is the hash of the bytes.
|
||||||
|
///
|
||||||
|
/// Every compute unit is allowed, so CoreML may place a graph on the
|
||||||
|
/// Neural Engine, the GPU or the CPU; the probe's clock judges the result.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn coreml(
|
||||||
|
b: ort::session::builder::SessionBuilder,
|
||||||
|
cache: Option<&std::path::Path>,
|
||||||
|
) -> ort::Result<ort::session::builder::SessionBuilder> {
|
||||||
|
use ort::ep::{self, coreml};
|
||||||
|
let mut ep = ep::CoreML::default()
|
||||||
|
.with_model_format(coreml::ModelFormat::MLProgram)
|
||||||
|
.with_compute_units(coreml::ComputeUnits::All);
|
||||||
|
if let Some(dir) = cache {
|
||||||
|
let _ = std::fs::create_dir_all(dir);
|
||||||
|
ep = ep.with_model_cache_dir(dir.to_string_lossy());
|
||||||
|
}
|
||||||
|
Ok(b.with_execution_providers([ep.build().error_on_failure()])?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "macos")))]
|
||||||
|
fn coreml(
|
||||||
|
_b: ort::session::builder::SessionBuilder,
|
||||||
|
_cache: Option<&std::path::Path>,
|
||||||
|
) -> ort::Result<ort::session::builder::SessionBuilder> {
|
||||||
|
unreachable!("the CoreML rung is on the macOS ladder only")
|
||||||
|
}
|
||||||
|
|
||||||
/// Register MIGraphX through ONNX Runtime's generic key/value entry point.
|
/// Register MIGraphX through ONNX Runtime's generic key/value entry point.
|
||||||
///
|
///
|
||||||
/// `ort`'s own builder (`ep::MIGraphX`) fills the legacy
|
/// `ort`'s own builder (`ep::MIGraphX`) fills the legacy
|
||||||
@@ -175,8 +250,8 @@ fn providers(
|
|||||||
.build()
|
.build()
|
||||||
.error_on_failure()])?)
|
.error_on_failure()])?)
|
||||||
}
|
}
|
||||||
Rung::Cuda | Rung::TensorRt | Rung::MiGraphX => {
|
Rung::Cuda | Rung::TensorRt | Rung::MiGraphX | Rung::CoreMl => {
|
||||||
unreachable!("no desktop GPU rung on Android")
|
unreachable!("no desktop rung on Android")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,24 +21,41 @@ params:
|
|||||||
kind: amount
|
kind: amount
|
||||||
|
|
||||||
uniforms:
|
uniforms:
|
||||||
amount: vibrance / 100
|
amount:
|
||||||
|
value: vibrance / 100 * 1.3
|
||||||
|
doc: |
|
||||||
|
Scaled so that a value delivers the strength it names. Measured, not
|
||||||
|
chosen: fitted on 45 of the photographer's earlier exports whose only
|
||||||
|
colour setting was a vibrance of about +24, against their raws.
|
||||||
|
|
||||||
helpers: [luminance, tone_position, colour_saturation]
|
helpers: [luminance]
|
||||||
|
|
||||||
wgsl: |
|
wgsl: |
|
||||||
let luma = luminance(c);
|
let luma = luminance(c);
|
||||||
let sat = colour_saturation(c);
|
|
||||||
|
// How saturated a colour *looks*, so measured on display-encoded values.
|
||||||
|
// In scene-linear light an ordinary tan reads as 0.78 saturated and the
|
||||||
|
// falloff below would leave it a twentieth of the effect; encoded, it reads
|
||||||
|
// as 0.5, which is what the eye sees.
|
||||||
|
let e = pow(max(c, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.2));
|
||||||
|
let e_hi = max(e.r, max(e.g, e.b));
|
||||||
|
let e_lo = min(e.r, min(e.g, e.b));
|
||||||
|
let sat = select(0.0, (e_hi - e_lo) / e_hi, e_hi > 0.00001);
|
||||||
|
|
||||||
// The vibrance curve: full effect on grey, tapering to nothing on colours
|
// The vibrance curve: full effect on grey, tapering to nothing on colours
|
||||||
// that are already saturated. Squaring the falloff keeps the mid-range
|
// that are already saturated. Squaring the falloff keeps the mid-range
|
||||||
// responsive while still protecting the extremes.
|
// responsive while still protecting the extremes.
|
||||||
let falloff = (1.0 - sat) * (1.0 - sat);
|
let falloff = (1.0 - sat) * (1.0 - sat);
|
||||||
|
|
||||||
// Skin protection. Skin sits in a narrow band of hue where red leads green
|
// Skin protection, for skin: hues between about 10 and 50 degrees (red
|
||||||
// leads blue; pushing it is what makes vibrance look wrong on portraits.
|
// leading, green between red and blue) that are not strongly saturated.
|
||||||
// Detected by channel ordering rather than a hue angle, which costs a
|
// Red-over-green-over-blue alone is every warm colour in a photograph —
|
||||||
// conversion and buys nothing here.
|
// wood, sand, brick, sunlit grass — and halving all of them is most of why
|
||||||
let is_skin = f32(c.r > c.g && c.g > c.b);
|
// vibrance used to do so little.
|
||||||
|
let span = max(e_hi - e_lo, 0.00001);
|
||||||
|
let skin_hue = select(0.0, 60.0 * (e.g - e.b) / span, e.r >= e.g && e.g >= e.b);
|
||||||
|
let in_band = smoothstep(4.0, 12.0, skin_hue) * (1.0 - smoothstep(42.0, 52.0, skin_hue));
|
||||||
|
let is_skin = in_band * (1.0 - smoothstep(0.45, 0.7, sat)) * f32(e.r >= e.g && e.g >= e.b);
|
||||||
let skin_guard = 1.0 - is_skin * 0.5;
|
let skin_guard = 1.0 - is_skin * 0.5;
|
||||||
|
|
||||||
let strength = amount * falloff * skin_guard;
|
let strength = amount * falloff * skin_guard;
|
||||||
@@ -49,9 +66,18 @@ tests:
|
|||||||
- name: it_starts_neutral
|
- name: it_starts_neutral
|
||||||
expect_active: false
|
expect_active: false
|
||||||
|
|
||||||
- name: the_amount_is_normalised_to_unit_range
|
- name: the_amount_is_the_measured_scale
|
||||||
|
why: |
|
||||||
|
Fitted against the photographer's earlier exports, so a value delivers
|
||||||
|
the strength it names.
|
||||||
set: { vibrance: 100 }
|
set: { vibrance: 100 }
|
||||||
expect: { amount: 1.0 }
|
expect: { amount: 1.3 }
|
||||||
|
|
||||||
|
- name: saturation_is_judged_as_displayed
|
||||||
|
why: |
|
||||||
|
Judged in scene-linear light, ordinary warm colours read as nearly
|
||||||
|
saturated and get almost none of the effect.
|
||||||
|
expect_wgsl: ["let e = pow(max(c, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.2));"]
|
||||||
|
|
||||||
- name: muted_colours_get_more_than_saturated_ones
|
- name: muted_colours_get_more_than_saturated_ones
|
||||||
why: |
|
why: |
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
drpl 1
|
drpl 1
|
||||||
|
|
||||||
# Vivid: more colour than the default rendering, for the photographer used
|
# Vivid: more colour than the default rendering (camera-profiles.md §9).
|
||||||
# to Lightroom's richer starting point (camera-profiles.md §9).
|
|
||||||
#
|
#
|
||||||
# That difference is mostly Camera Raw's tone curve, not the camera
|
# These do the work themselves, and work on every photograph — a JPEG, a body with no profile. They lean on
|
||||||
# profile: Adobe Standard's look table, which D20 applies, desaturates dark
|
|
||||||
# tones (camera-profiles.md §1). So these do the work themselves, and work
|
|
||||||
# on every photograph — a JPEG, a body with no profile. They lean on
|
|
||||||
# vibrance before saturation: vibrance lifts muted colours most and holds
|
# vibrance before saturation: vibrance lifts muted colours most and holds
|
||||||
# skin back, so a frame gets richer before anything in it looks painted.
|
# skin back, so a frame gets richer before anything in it looks painted.
|
||||||
# Saturation, which moves every colour alike, is used sparingly on top.
|
# Saturation, which moves every colour alike, is used sparingly on top.
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
//! TRACES: FR-DEV-3j | FR-DEV-3e
|
||||||
|
//! The DNG SDK's reference tone, as a rendering the view transform can
|
||||||
|
//! choose (D21).
|
||||||
|
//!
|
||||||
|
//! The DNG specification's reference rendering runs a raw through the
|
||||||
|
//! profile's `ProfileToneCurve`, or the ACR3 default for a profile with none.
|
||||||
|
//! Half of what the curve does is *how* it is applied. The SDK's
|
||||||
|
//! `RefBaselineRGBTone` runs it on the largest and the smallest channel, and
|
||||||
|
//! places the middle channel at the fraction between them it had before. Hue
|
||||||
|
//! is kept; saturation rises wherever the curve is steeper than the
|
||||||
|
//! diagonal, which for the ACR3 curve is the shadows and the midtones.
|
||||||
|
//!
|
||||||
|
//! It runs in linear ProPhoto, as the SDK does, on values clipped to
|
||||||
|
//! `[0, 1]`; its output is linear and goes to the output transform as the
|
||||||
|
//! sigmoid's does. The curve is read from the profile buffer
|
||||||
|
//! (`ops::camera_profile::profile_buffer`), which always carries one.
|
||||||
|
//!
|
||||||
|
//! [`apply_reference`] is the arithmetic on the CPU; the GPU test holds the
|
||||||
|
//! shader to it.
|
||||||
|
|
||||||
|
use crate::ops::camera_profile::{mul, working_prophoto};
|
||||||
|
use crate::view::{DEFAULT_WHITE, REFERENCE_CONTRAST, SCENE_GREY};
|
||||||
|
|
||||||
|
/// The input scale for a white point: 1 at the default, so sensor white is
|
||||||
|
/// display white as in the SDK's reference; each stop of `white` above it halves the
|
||||||
|
/// input.
|
||||||
|
pub fn input_scale(white: f32) -> f32 {
|
||||||
|
(DEFAULT_WHITE - white).exp2()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The power the input is bent by about middle grey: 1 at
|
||||||
|
/// [`REFERENCE_CONTRAST`], where the curve is the reference's untouched.
|
||||||
|
///
|
||||||
|
/// The default contrast sits above it, so a photograph out of the camera is
|
||||||
|
/// bent by `DEFAULT_CONTRAST / REFERENCE_CONTRAST` — the extra contrast
|
||||||
|
/// Lightroom's exports showed over the bare reference curve (D21 addendum).
|
||||||
|
pub fn contrast_power(contrast: f32) -> f32 {
|
||||||
|
contrast / REFERENCE_CONTRAST
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The curve, its scale and its contrast applied to one ProPhoto colour.
|
||||||
|
fn rgb_tone(curve: &[f32], p: [f32; 3]) -> [f32; 3] {
|
||||||
|
let p = p.map(|v| v.clamp(0.0, 1.0));
|
||||||
|
let hi = p[0].max(p[1]).max(p[2]);
|
||||||
|
let lo = p[0].min(p[1]).min(p[2]);
|
||||||
|
let (c_hi, c_lo) = (
|
||||||
|
dr_types::tone::evaluate(curve, hi),
|
||||||
|
dr_types::tone::evaluate(curve, lo),
|
||||||
|
);
|
||||||
|
if hi - lo <= 1e-7 {
|
||||||
|
return [c_hi; 3];
|
||||||
|
}
|
||||||
|
p.map(|v| c_lo + (c_hi - c_lo) * (v - lo) / (hi - lo))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3j
|
||||||
|
/// The view transform's DNG reference rendering of one working-space colour.
|
||||||
|
pub fn apply_reference(curve: &[f32], c: [f32; 3], contrast: f32, white: f32) -> [f32; 3] {
|
||||||
|
let (to, back) = working_prophoto();
|
||||||
|
let scale = input_scale(white);
|
||||||
|
let power = contrast_power(contrast);
|
||||||
|
let mut p = mul(to, c).map(|v| v * scale);
|
||||||
|
if power != 1.0 {
|
||||||
|
p = p.map(|v| SCENE_GREY * (v.max(0.0) / SCENE_GREY).powf(power));
|
||||||
|
}
|
||||||
|
mul(back, rgb_tone(curve, p))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The WGSL, a helper the view transform asks for after
|
||||||
|
/// `ops::camera_profile`'s ProPhoto constants. Mirrors [`apply_reference`].
|
||||||
|
pub const CAMERA_RAW_WGSL: &str = "
|
||||||
|
fn camera_raw_curve(x: f32) -> f32 {
|
||||||
|
let base = profile_curve_base();
|
||||||
|
let n = u32(profile_table[2].x);
|
||||||
|
let s = clamp(x, 0.0, 1.0) * f32(n - 1u);
|
||||||
|
let i = min(u32(s), n - 2u);
|
||||||
|
return mix(profile_table[base + i].x, profile_table[base + i + 1u].x, s - f32(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The SDK's RGBTone: the curve on the largest and smallest channel, the
|
||||||
|
// middle one kept at its fraction between them, so hue survives.
|
||||||
|
fn camera_raw_tone(c: vec3<f32>, scale: f32, power: f32, grey: f32) -> vec3<f32> {
|
||||||
|
var p = PROFILE_FROM_WORKING * c * scale;
|
||||||
|
if (power != 1.0) {
|
||||||
|
p = grey * pow(max(p, vec3<f32>(0.0)) / grey, vec3<f32>(power));
|
||||||
|
}
|
||||||
|
p = clamp(p, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||||
|
let hi = max(p.r, max(p.g, p.b));
|
||||||
|
let lo = min(p.r, min(p.g, p.b));
|
||||||
|
let c_hi = camera_raw_curve(hi);
|
||||||
|
let c_lo = camera_raw_curve(lo);
|
||||||
|
var out = vec3<f32>(c_hi);
|
||||||
|
if (hi - lo > 1e-7) {
|
||||||
|
out = vec3<f32>(c_lo) + (c_hi - c_lo) * (p - vec3<f32>(lo)) / (hi - lo);
|
||||||
|
}
|
||||||
|
return PROFILE_TO_WORKING * out;
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::view::DEFAULT_CONTRAST;
|
||||||
|
use dr_types::tone::{evaluate, ACR3_DEFAULT};
|
||||||
|
|
||||||
|
fn identity() -> Vec<f32> {
|
||||||
|
(0..1025).map(|i| i as f32 / 1024.0).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn at_the_reference_the_input_is_untouched() {
|
||||||
|
assert_eq!(input_scale(DEFAULT_WHITE), 1.0);
|
||||||
|
assert_eq!(contrast_power(REFERENCE_CONTRAST), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_default_adds_the_measured_contrast() {
|
||||||
|
// Fitted on Lightroom exports with neutral settings (D21 addendum):
|
||||||
|
// the bare reference curve is a little flat against them.
|
||||||
|
let p = contrast_power(DEFAULT_CONTRAST);
|
||||||
|
assert!((1.05..1.12).contains(&p), "{p}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grey_goes_through_the_curve_and_stays_grey() {
|
||||||
|
for v in [0.02, 0.13, 0.5] {
|
||||||
|
let out = apply_reference(&ACR3_DEFAULT, [v; 3], REFERENCE_CONTRAST, DEFAULT_WHITE);
|
||||||
|
let want = evaluate(&ACR3_DEFAULT, v);
|
||||||
|
assert!(
|
||||||
|
out.iter().all(|o| (o - want).abs() < 1e-4),
|
||||||
|
"{v}: {out:?} vs {want}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_identity_curve_changes_nothing_inside_the_range() {
|
||||||
|
let c = [0.4, 0.2, 0.1];
|
||||||
|
let out = apply_reference(&identity(), c, REFERENCE_CONTRAST, DEFAULT_WHITE);
|
||||||
|
assert!(
|
||||||
|
out.iter().zip(c).all(|(o, c)| (o - c).abs() < 1e-4),
|
||||||
|
"{out:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_middle_channel_keeps_its_place_between_the_other_two() {
|
||||||
|
let p = [0.3, 0.12, 0.05];
|
||||||
|
let out = rgb_tone(&ACR3_DEFAULT, p);
|
||||||
|
let before = (p[1] - p[2]) / (p[0] - p[2]);
|
||||||
|
let after = (out[1] - out[2]) / (out[0] - out[2]);
|
||||||
|
assert!((before - after).abs() < 1e-5, "{before} {after}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_acr_curve_raises_saturation_in_the_midtones() {
|
||||||
|
let p = [0.15, 0.08, 0.05];
|
||||||
|
let out = rgb_tone(&ACR3_DEFAULT, p);
|
||||||
|
let sat = |c: [f32; 3]| (c[0] - c[2]) / c[0];
|
||||||
|
assert!(sat(out) > sat(p), "{p:?} -> {out:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn white_halves_the_input_per_stop() {
|
||||||
|
assert_eq!(input_scale(DEFAULT_WHITE + 1.0), 0.5);
|
||||||
|
assert!(contrast_power(2.8) > 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -464,6 +464,15 @@ impl ParamDescriptor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same choice with another variant as its default.
|
||||||
|
///
|
||||||
|
/// For a choice whose variants were numbered before its default was
|
||||||
|
/// settled: a sidecar records the index, so reordering the variants to
|
||||||
|
/// put the default first would change what saved edits mean.
|
||||||
|
pub fn with_default(self, default: f32) -> Self {
|
||||||
|
Self { default, ..self }
|
||||||
|
}
|
||||||
|
|
||||||
/// A 0…1 fraction — a proportion of something, rather than an amount.
|
/// A 0…1 fraction — a proportion of something, rather than an amount.
|
||||||
///
|
///
|
||||||
/// Its own constructor because the crop rect needs four of them and the
|
/// Its own constructor because the crop rect needs four of them and the
|
||||||
|
|||||||
@@ -170,6 +170,18 @@ pub struct EditGraph {
|
|||||||
/// correction the photograph asked for — see
|
/// correction the photograph asked for — see
|
||||||
/// [`crate::descriptor::ParamDescriptor::switch_on`].
|
/// [`crate::descriptor::ParamDescriptor::switch_on`].
|
||||||
lens_profile_applied: bool,
|
lens_profile_applied: bool,
|
||||||
|
/// TRACES: FR-DEV-3g
|
||||||
|
/// Whether this photograph can take the learned denoise — a Bayer
|
||||||
|
/// mosaic — set by whoever opened it. Derived from the file like the
|
||||||
|
/// 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,
|
||||||
|
/// How much of the removed noise's brightness to put back, 0–100.
|
||||||
|
denoise_grain: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3f
|
/// TRACES: FR-DEV-3f
|
||||||
@@ -226,6 +238,9 @@ impl EditGraph {
|
|||||||
],
|
],
|
||||||
lens_profile: None,
|
lens_profile: None,
|
||||||
lens_profile_applied: true,
|
lens_profile_applied: true,
|
||||||
|
denoise_available: false,
|
||||||
|
denoise_applied: false,
|
||||||
|
denoise_grain: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +414,26 @@ impl EditGraph {
|
|||||||
self.lens_profile.as_ref()
|
self.lens_profile.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3g
|
||||||
|
/// Offer the learned denoise, or not: true for a Bayer mosaic.
|
||||||
|
pub fn set_denoise_available(&mut self, available: bool) {
|
||||||
|
self.denoise_available = available;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3g
|
||||||
|
/// Whether the learned denoise is asked for. A setting kept on a
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3g
|
||||||
|
/// The grain to keep, 0–1.
|
||||||
|
pub fn denoise_grain(&self) -> f32 {
|
||||||
|
self.denoise_grain / 100.0
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3
|
/// TRACES: FR-DEV-3
|
||||||
/// Whether the matched profile is being applied.
|
/// Whether the matched profile is being applied.
|
||||||
pub fn lens_profile_applied(&self) -> bool {
|
pub fn lens_profile_applied(&self) -> bool {
|
||||||
@@ -581,9 +616,37 @@ impl EditGraph {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-3g
|
||||||
|
// Offered only where the photograph can take it, for the lens
|
||||||
|
// switch's reason: a control that can do nothing must not look as if
|
||||||
|
// it could.
|
||||||
|
let denoise = self.denoise_available.then(|| {
|
||||||
|
let desc = crate::learned_denoise::descriptor();
|
||||||
|
OpCapability {
|
||||||
|
id: desc.id,
|
||||||
|
label: desc.label,
|
||||||
|
active: self.denoise_applied,
|
||||||
|
params: desc
|
||||||
|
.params
|
||||||
|
.iter()
|
||||||
|
.map(|p| ParamCapability {
|
||||||
|
id: p.id,
|
||||||
|
label: p.label,
|
||||||
|
kind: p.kind.clone(),
|
||||||
|
default: p.default,
|
||||||
|
value: self.param(desc.id, p.id).unwrap_or(p.default),
|
||||||
|
facet: p.facet,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
presentation: None,
|
||||||
|
attributes: desc.attributes.clone(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
switch
|
switch
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.chain(warps)
|
.chain(warps)
|
||||||
|
.chain(denoise)
|
||||||
.chain(ops)
|
.chain(ops)
|
||||||
.chain(std::iter::once(framing))
|
.chain(std::iter::once(framing))
|
||||||
.collect()
|
.collect()
|
||||||
@@ -675,6 +738,11 @@ impl EditGraph {
|
|||||||
// `capabilities`, with the operations and the warps and for the
|
// `capabilities`, with the operations and the warps and for the
|
||||||
// same reason (FR-DEV-3c).
|
// same reason (FR-DEV-3c).
|
||||||
lens_profile_applied: _,
|
lens_profile_applied: _,
|
||||||
|
// Derived from the file, like the profile above.
|
||||||
|
denoise_available: _,
|
||||||
|
// Edits, in the state through `capabilities` like the lens switch.
|
||||||
|
denoise_applied: _,
|
||||||
|
denoise_grain: _,
|
||||||
masks,
|
masks,
|
||||||
film,
|
film,
|
||||||
spots,
|
spots,
|
||||||
@@ -746,6 +814,16 @@ impl EditGraph {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
|
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::GRAIN => {
|
||||||
|
self.denoise_grain = value.clamp(0.0, 100.0)
|
||||||
|
}
|
||||||
|
_ => log::warn!("unknown parameter {param} on {op}; ignoring"),
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if op == crate::lens::profile_switch::ID {
|
if op == crate::lens::profile_switch::ID {
|
||||||
if param != crate::lens::profile_switch::APPLY {
|
if param != crate::lens::profile_switch::APPLY {
|
||||||
log::warn!("unknown parameter {param} on {op}; ignoring");
|
log::warn!("unknown parameter {param} on {op}; ignoring");
|
||||||
@@ -803,6 +881,15 @@ impl EditGraph {
|
|||||||
|
|
||||||
/// Read a parameter back.
|
/// Read a parameter back.
|
||||||
pub fn param(&self, op: OpId, param: ParamId) -> Option<f32> {
|
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::APPLY => {
|
||||||
|
Some(if self.denoise_applied { 1.0 } else { 0.0 })
|
||||||
|
}
|
||||||
|
p if p == crate::learned_denoise::GRAIN => Some(self.denoise_grain),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
if op == crate::lens::profile_switch::ID {
|
if op == crate::lens::profile_switch::ID {
|
||||||
return (param == crate::lens::profile_switch::APPLY)
|
return (param == crate::lens::profile_switch::APPLY)
|
||||||
.then_some(if self.lens_profile_applied { 1.0 } else { 0.0 });
|
.then_some(if self.lens_profile_applied { 1.0 } else { 0.0 });
|
||||||
@@ -849,6 +936,10 @@ impl EditGraph {
|
|||||||
// a reset does not change which lens took the photograph. What returns
|
// 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.
|
// to default is the answer to whether to use it, which is on.
|
||||||
self.set_lens_profile_applied(true);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the crop rectangle. Clamped to keep it inside the frame.
|
/// Set the crop rectangle. Clamped to keep it inside the frame.
|
||||||
@@ -1995,4 +2086,40 @@ mod tests {
|
|||||||
let after = cropped.render_scale(source, (1500, 1000));
|
let after = cropped.render_scale(source, (1500, 1000));
|
||||||
assert!(after.ratio() > fit.ratio());
|
assert!(after.ratio() > fit.ratio());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_learned_denoise_is_offered_only_where_it_can_run() {
|
||||||
|
use crate::learned_denoise;
|
||||||
|
let mut g = EditGraph::default_chain();
|
||||||
|
assert!(!g.capabilities().iter().any(|c| c.id == learned_denoise::ID));
|
||||||
|
g.set_denoise_available(true);
|
||||||
|
let cap = g
|
||||||
|
.capabilities()
|
||||||
|
.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!((g.denoise_grain() - 0.3).abs() < 1e-6);
|
||||||
|
g.reset();
|
||||||
|
assert!(!g.denoise_applied());
|
||||||
|
assert_eq!(g.denoise_grain(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
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!((h.denoise_grain() - 0.4).abs() < 1e-6);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//! TRACES: FR-DEV-3g
|
||||||
|
//! The learned denoise's settings: whether to use it, 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
|
||||||
|
//! (docs/dev/denoise.md §2, §7), and the grain is a blend of its result with
|
||||||
|
//! the classical one, done where the source is chosen. But what a
|
||||||
|
//! photographer sets travels the one road every setting travels — the
|
||||||
|
//! capability list feeds the panel, [`crate::Preset`] captures it, the
|
||||||
|
//! sidecar stores it, the undo stack replays it (FR-DEV-3c) — so it is
|
||||||
|
//! published as a capability, like the lens profile switch.
|
||||||
|
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
|
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, ParamDescriptor, Scale, Unit};
|
||||||
|
use crate::{OpId, ParamId};
|
||||||
|
|
||||||
|
pub const ID: OpId = OpId("learned_denoise");
|
||||||
|
pub const APPLY: ParamId = ParamId("apply");
|
||||||
|
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.
|
||||||
|
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::scalar(
|
||||||
|
"grain",
|
||||||
|
"param.learned_denoise.grain",
|
||||||
|
0.0,
|
||||||
|
100.0,
|
||||||
|
0.0,
|
||||||
|
Unit::Percent,
|
||||||
|
Scale::Linear,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// With the classical noise reduction, which is what a photographer
|
||||||
|
// looks for it beside.
|
||||||
|
attributes: vec![Attribute::Detail],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
pub fn descriptor() -> Arc<OpDescriptor> {
|
||||||
|
DESCRIPTOR.clone()
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
//! data neither would be physically meaningful (ARCH §5.2).
|
//! data neither would be physically meaningful (ARCH §5.2).
|
||||||
|
|
||||||
pub mod bundled;
|
pub mod bundled;
|
||||||
|
pub mod camera_raw;
|
||||||
pub mod coverage;
|
pub mod coverage;
|
||||||
pub mod declared;
|
pub mod declared;
|
||||||
pub mod descriptor;
|
pub mod descriptor;
|
||||||
@@ -40,6 +41,7 @@ pub mod detail;
|
|||||||
pub mod framing;
|
pub mod framing;
|
||||||
pub mod graph;
|
pub mod graph;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
|
pub mod learned_denoise;
|
||||||
pub mod lens;
|
pub mod lens;
|
||||||
pub mod mask;
|
pub mod mask;
|
||||||
pub mod neutral;
|
pub mod neutral;
|
||||||
|
|||||||
@@ -48,13 +48,14 @@ pub const LOOK: ParamId = ParamId("look");
|
|||||||
/// The look's strength at which the LookTable is applied as the profile
|
/// The look's strength at which the LookTable is applied as the profile
|
||||||
/// states it, in percent.
|
/// states it, in percent.
|
||||||
pub const DEFAULT_LOOK: f32 = 100.0;
|
pub const DEFAULT_LOOK: f32 = 100.0;
|
||||||
/// Twice the profile's look: Lightroom's *Amount* reaches the same.
|
/// Twice the profile's look.
|
||||||
pub const MAX_LOOK: f32 = 200.0;
|
pub const MAX_LOOK: f32 = 200.0;
|
||||||
|
|
||||||
/// Entries of the buffer's header: one `vec4` describing each table —
|
/// Entries of the buffer's header, before the entries themselves: one
|
||||||
/// `(hue divisions, saturation divisions, value divisions, sRGB-encoded)`,
|
/// `vec4` describing each table — `(hue divisions, saturation divisions,
|
||||||
/// zero hue divisions meaning absent — before the entries themselves.
|
/// value divisions, sRGB-encoded)`, zero hue divisions meaning absent — and
|
||||||
pub const HEADER_ENTRIES: usize = 2;
|
/// a third whose `.x` is the tone curve's length (camera-profiles.md §12).
|
||||||
|
pub const HEADER_ENTRIES: usize = 3;
|
||||||
|
|
||||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
Arc::new(OpDescriptor {
|
Arc::new(OpDescriptor {
|
||||||
@@ -80,7 +81,7 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
|||||||
/// Linear sRGB (the working space) to linear ProPhoto, and back, row-major,
|
/// Linear sRGB (the working space) to linear ProPhoto, and back, row-major,
|
||||||
/// each row scaled to sum to one so that working white is ProPhoto white
|
/// each row scaled to sum to one so that working white is ProPhoto white
|
||||||
/// exactly and a neutral reaches the tables with zero saturation.
|
/// exactly and a neutral reaches the tables with zero saturation.
|
||||||
fn working_prophoto() -> &'static ([f32; 9], [f32; 9]) {
|
pub(crate) fn working_prophoto() -> &'static ([f32; 9], [f32; 9]) {
|
||||||
static M: LazyLock<([f32; 9], [f32; 9])> = LazyLock::new(|| {
|
static M: LazyLock<([f32; 9], [f32; 9])> = LazyLock::new(|| {
|
||||||
let to = normalise_rows(dr_types::ColourSpace::ProPhoto.from_linear_srgb());
|
let to = normalise_rows(dr_types::ColourSpace::ProPhoto.from_linear_srgb());
|
||||||
let back = normalise_rows(invert(&to).expect("ProPhoto's matrix is invertible"));
|
let back = normalise_rows(invert(&to).expect("ProPhoto's matrix is invertible"));
|
||||||
@@ -117,7 +118,7 @@ fn invert(m: &[f32; 9]) -> Option<[f32; 9]> {
|
|||||||
Some(inv.map(|v| v as f32))
|
Some(inv.map(|v| v as f32))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mul(m: &[f32; 9], c: [f32; 3]) -> [f32; 3] {
|
pub(crate) fn mul(m: &[f32; 9], c: [f32; 3]) -> [f32; 3] {
|
||||||
std::array::from_fn(|r| m[r * 3] * c[0] + m[r * 3 + 1] * c[1] + m[r * 3 + 2] * c[2])
|
std::array::from_fn(|r| m[r * 3] * c[0] + m[r * 3 + 1] * c[1] + m[r * 3 + 2] * c[2])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,19 +128,48 @@ fn wgsl_mat(m: &[f32; 9]) -> String {
|
|||||||
format!("mat3x3<f32>({}, {}, {})", col(0), col(1), col(2))
|
format!("mat3x3<f32>({}, {}, {})", col(0), col(1), col(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
static HELPERS: LazyLock<[Helper; 1]> = LazyLock::new(|| {
|
/// The working space to ProPhoto and back, as WGSL constants, and where
|
||||||
|
/// the profile buffer's sections begin. A helper of its own because the
|
||||||
|
/// view transform's DNG reference curve needs it too, and helpers are emitted
|
||||||
|
/// once each, in the order first asked for.
|
||||||
|
pub(crate) static PROPHOTO_HELPER: LazyLock<Helper> = LazyLock::new(|| {
|
||||||
let (to, back) = working_prophoto();
|
let (to, back) = working_prophoto();
|
||||||
let source = format!(
|
let source = format!(
|
||||||
"const PROFILE_FROM_WORKING = {};\nconst PROFILE_TO_WORKING = {};\n{LOOKUP_WGSL}",
|
"const PROFILE_FROM_WORKING = {};\nconst PROFILE_TO_WORKING = {};\n{SECTIONS_WGSL}",
|
||||||
wgsl_mat(to),
|
wgsl_mat(to),
|
||||||
wgsl_mat(back)
|
wgsl_mat(back)
|
||||||
);
|
);
|
||||||
[Helper {
|
Helper {
|
||||||
name: "profile_apply",
|
name: "profile_curve_base",
|
||||||
source: Box::leak(source.into_boxed_str()),
|
source: Box::leak(source.into_boxed_str()),
|
||||||
}]
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static HELPERS: LazyLock<[Helper; 2]> = LazyLock::new(|| {
|
||||||
|
[
|
||||||
|
*PROPHOTO_HELPER,
|
||||||
|
Helper {
|
||||||
|
name: "profile_apply",
|
||||||
|
source: LOOKUP_WGSL,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Where each section of the profile buffer starts, from its header.
|
||||||
|
const SECTIONS_WGSL: &str = "
|
||||||
|
fn profile_entries(dims: vec4<f32>) -> u32 {
|
||||||
|
return u32(dims.x * dims.y * dims.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_look_base() -> u32 {
|
||||||
|
return 3u + profile_entries(profile_table[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_curve_base() -> u32 {
|
||||||
|
return profile_look_base() + profile_entries(profile_table[1]);
|
||||||
|
}
|
||||||
|
";
|
||||||
|
|
||||||
/// The lookup, in WGSL. Mirrors [`apply_reference`] line for line.
|
/// The lookup, in WGSL. Mirrors [`apply_reference`] line for line.
|
||||||
const LOOKUP_WGSL: &str = r#"
|
const LOOKUP_WGSL: &str = r#"
|
||||||
fn profile_srgb_encode(v: f32) -> f32 {
|
fn profile_srgb_encode(v: f32) -> f32 {
|
||||||
@@ -327,12 +357,11 @@ if (hue_sat_dims.x > 0.0 || look_dims.x > 0.0) {
|
|||||||
// A colour outside ProPhoto has no HSV the tables were made for; it
|
// A colour outside ProPhoto has no HSV the tables were made for; it
|
||||||
// passes through rather than being floored, which would clip it (D19).
|
// passes through rather than being floored, which would clip it (D19).
|
||||||
if (min(p.r, min(p.g, p.b)) >= 0.0) {
|
if (min(p.r, min(p.g, p.b)) >= 0.0) {
|
||||||
let look_base = 2u + u32(hue_sat_dims.x * hue_sat_dims.y * hue_sat_dims.z);
|
|
||||||
if (hue_sat_dims.x > 0.0) {
|
if (hue_sat_dims.x > 0.0) {
|
||||||
p = profile_apply(hue_sat_dims, 2u, p, 1.0);
|
p = profile_apply(hue_sat_dims, 3u, p, 1.0);
|
||||||
}
|
}
|
||||||
if (look_dims.x > 0.0 && look > 0.0) {
|
if (look_dims.x > 0.0 && look > 0.0) {
|
||||||
p = profile_apply(look_dims, look_base, p, look);
|
p = profile_apply(look_dims, profile_look_base(), p, look);
|
||||||
}
|
}
|
||||||
c = PROFILE_TO_WORKING * p;
|
c = PROFILE_TO_WORKING * p;
|
||||||
}
|
}
|
||||||
@@ -352,13 +381,16 @@ if (hue_sat_dims.x > 0.0 || look_dims.x > 0.0) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3e
|
/// TRACES: FR-DEV-3e | FR-DEV-3j
|
||||||
/// The storage buffer a source's tables are uploaded as: the two header
|
/// The storage buffer a source's profile is uploaded as: the three header
|
||||||
/// `vec4`s, the HueSatMap's entries, then the LookTable's, each entry
|
/// `vec4`s, the HueSatMap's entries, the LookTable's, each entry
|
||||||
/// `(hue shift, saturation scale, value scale, 0)`.
|
/// `(hue shift, saturation scale, value scale, 0)`, then the tone curve's
|
||||||
|
/// samples in `.x`.
|
||||||
///
|
///
|
||||||
/// Two zero `vec4`s where there are no tables — the placeholder every source
|
/// The curve is always there: the profile's own where it has one, Camera
|
||||||
/// without a profile binds, and what makes the fragment pass through.
|
/// Raw's ACR3 default otherwise — including in the placeholder every source
|
||||||
|
/// without a profile binds, whose tables are absent, so a raw with no
|
||||||
|
/// profile still has the reference tone curve when it is chosen (D21).
|
||||||
pub fn profile_buffer(tables: Option<&ProfileTables>) -> Vec<[f32; 4]> {
|
pub fn profile_buffer(tables: Option<&ProfileTables>) -> Vec<[f32; 4]> {
|
||||||
let header = |t: Option<&HueSatTable>| match t {
|
let header = |t: Option<&HueSatTable>| match t {
|
||||||
Some(t) => [
|
Some(t) => [
|
||||||
@@ -371,10 +403,18 @@ pub fn profile_buffer(tables: Option<&ProfileTables>) -> Vec<[f32; 4]> {
|
|||||||
};
|
};
|
||||||
let hue_sat = tables.and_then(|t| t.hue_sat.as_ref());
|
let hue_sat = tables.and_then(|t| t.hue_sat.as_ref());
|
||||||
let look = tables.and_then(|t| t.look.as_ref());
|
let look = tables.and_then(|t| t.look.as_ref());
|
||||||
let mut out = vec![header(hue_sat), header(look)];
|
let curve: &[f32] = tables
|
||||||
|
.and_then(|t| t.tone_curve.as_deref())
|
||||||
|
.unwrap_or(&dr_types::tone::ACR3_DEFAULT);
|
||||||
|
let mut out = vec![
|
||||||
|
header(hue_sat),
|
||||||
|
header(look),
|
||||||
|
[curve.len() as f32, 0.0, 0.0, 0.0],
|
||||||
|
];
|
||||||
for t in [hue_sat, look].into_iter().flatten() {
|
for t in [hue_sat, look].into_iter().flatten() {
|
||||||
out.extend(t.entries.iter().map(|e| [e[0], e[1], e[2], 0.0]));
|
out.extend(t.entries.iter().map(|e| [e[0], e[1], e[2], 0.0]));
|
||||||
}
|
}
|
||||||
|
out.extend(curve.iter().map(|&v| [v, 0.0, 0.0, 0.0]));
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,6 +576,7 @@ mod tests {
|
|||||||
origin: ProfileOrigin::Embedded,
|
origin: ProfileOrigin::Embedded,
|
||||||
hue_sat,
|
hue_sat,
|
||||||
look,
|
look,
|
||||||
|
tone_curve: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,7 +704,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_buffer_puts_the_header_first_and_the_look_after_the_hue_sat_map() {
|
fn the_buffer_puts_the_header_first_and_the_look_after_the_hue_sat_map() {
|
||||||
assert_eq!(profile_buffer(None), vec![[0.0; 4]; 2]);
|
let bare = profile_buffer(None);
|
||||||
|
assert_eq!(bare[..2], [[0.0; 4]; 2], "no tables");
|
||||||
|
assert_eq!(bare[2][0], 1025.0, "and the reference default curve");
|
||||||
|
assert_eq!(bare.len(), HEADER_ENTRIES + 1025);
|
||||||
let t = tables(
|
let t = tables(
|
||||||
Some(uniform(2, 2, 1, [1.0, 2.0, 3.0])),
|
Some(uniform(2, 2, 1, [1.0, 2.0, 3.0])),
|
||||||
Some(uniform(3, 2, 2, [4.0, 5.0, 6.0])),
|
Some(uniform(3, 2, 2, [4.0, 5.0, 6.0])),
|
||||||
@@ -671,8 +715,9 @@ mod tests {
|
|||||||
let b = profile_buffer(Some(&t));
|
let b = profile_buffer(Some(&t));
|
||||||
assert_eq!(b[0], [2.0, 2.0, 1.0, 0.0]);
|
assert_eq!(b[0], [2.0, 2.0, 1.0, 0.0]);
|
||||||
assert_eq!(b[1], [3.0, 2.0, 2.0, 0.0]);
|
assert_eq!(b[1], [3.0, 2.0, 2.0, 0.0]);
|
||||||
assert_eq!(b.len(), HEADER_ENTRIES + 4 + 12);
|
assert_eq!(b.len(), HEADER_ENTRIES + 4 + 12 + 1025);
|
||||||
assert_eq!(b[2], [1.0, 2.0, 3.0, 0.0]);
|
assert_eq!(b[HEADER_ENTRIES], [1.0, 2.0, 3.0, 0.0]);
|
||||||
assert_eq!(b[HEADER_ENTRIES + 4], [4.0, 5.0, 6.0, 0.0]);
|
assert_eq!(b[HEADER_ENTRIES + 4], [4.0, 5.0, 6.0, 0.0]);
|
||||||
|
assert_eq!(b[HEADER_ENTRIES + 16][0], dr_types::tone::ACR3_DEFAULT[0]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,11 +33,33 @@ use crate::view::{Sigmoid, CONTRAST_RANGE, DEFAULT_CONTRAST, DEFAULT_WHITE, WHIT
|
|||||||
pub const ID: OpId = OpId("view_transform");
|
pub const ID: OpId = OpId("view_transform");
|
||||||
pub const CONTRAST: ParamId = ParamId("contrast");
|
pub const CONTRAST: ParamId = ParamId("contrast");
|
||||||
pub const WHITE: ParamId = ParamId("white");
|
pub const WHITE: ParamId = ParamId("white");
|
||||||
|
/// TRACES: FR-DEV-3j
|
||||||
|
/// Which curve renders: the DNG reference (D21, the default) or D19's sigmoid.
|
||||||
|
///
|
||||||
|
/// The reference because it is the one that matches what the photographs were
|
||||||
|
/// first developed with: on 60 Lightroom exports with neutral settings it
|
||||||
|
/// renders their raws within MSE ~150 of Lightroom's own JPEGs at the default
|
||||||
|
/// contrast, where 0.20.0's sigmoid was ~1200 (darker by about 0.7 EV and
|
||||||
|
/// flatter). The sigmoid keeps index 0 because sidecars record the index.
|
||||||
|
pub const CURVE: ParamId = ParamId("curve");
|
||||||
|
|
||||||
static HELPERS: [Helper; 1] = [Helper {
|
/// [`CURVE`]'s values, in the order of its variants.
|
||||||
name: "view_sigmoid",
|
pub const SIGMOID: f32 = 0.0;
|
||||||
source: crate::view::VIEW_SIGMOID_WGSL,
|
pub const CAMERA_RAW: f32 = 1.0;
|
||||||
}];
|
|
||||||
|
static HELPERS: LazyLock<[Helper; 3]> = LazyLock::new(|| {
|
||||||
|
[
|
||||||
|
Helper {
|
||||||
|
name: "view_sigmoid",
|
||||||
|
source: crate::view::VIEW_SIGMOID_WGSL,
|
||||||
|
},
|
||||||
|
*crate::ops::camera_profile::PROPHOTO_HELPER,
|
||||||
|
Helper {
|
||||||
|
name: "camera_raw_tone",
|
||||||
|
source: crate::camera_raw::CAMERA_RAW_WGSL,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
Arc::new(OpDescriptor {
|
Arc::new(OpDescriptor {
|
||||||
@@ -67,6 +89,15 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
|||||||
Scale::Linear,
|
Scale::Linear,
|
||||||
1,
|
1,
|
||||||
),
|
),
|
||||||
|
ParamDescriptor::choice(
|
||||||
|
"curve",
|
||||||
|
"param.view_transform.curve",
|
||||||
|
vec![
|
||||||
|
LocalizedKey("param.view_transform.curve.sigmoid"),
|
||||||
|
LocalizedKey("param.view_transform.curve.camera_raw"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.with_default(CAMERA_RAW),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -75,6 +106,7 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
|||||||
pub struct ViewTransform {
|
pub struct ViewTransform {
|
||||||
contrast: f32,
|
contrast: f32,
|
||||||
white: f32,
|
white: f32,
|
||||||
|
curve: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ViewTransform {
|
impl Default for ViewTransform {
|
||||||
@@ -82,6 +114,7 @@ impl Default for ViewTransform {
|
|||||||
Self {
|
Self {
|
||||||
contrast: DEFAULT_CONTRAST,
|
contrast: DEFAULT_CONTRAST,
|
||||||
white: DEFAULT_WHITE,
|
white: DEFAULT_WHITE,
|
||||||
|
curve: CAMERA_RAW,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +139,7 @@ impl Operation for ViewTransform {
|
|||||||
match id {
|
match id {
|
||||||
CONTRAST => self.contrast = value,
|
CONTRAST => self.contrast = value,
|
||||||
WHITE => self.white = value,
|
WHITE => self.white = value,
|
||||||
|
CURVE => self.curve = value.round(),
|
||||||
_ => log::warn!("view_transform: unknown parameter {id}"),
|
_ => log::warn!("view_transform: unknown parameter {id}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,12 +148,13 @@ impl Operation for ViewTransform {
|
|||||||
match id {
|
match id {
|
||||||
CONTRAST => self.contrast,
|
CONTRAST => self.contrast,
|
||||||
WHITE => self.white,
|
WHITE => self.white,
|
||||||
|
CURVE => self.curve,
|
||||||
_ => 0.0,
|
_ => 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_active(&self) -> bool {
|
fn is_active(&self) -> bool {
|
||||||
self.contrast != DEFAULT_CONTRAST || self.white != DEFAULT_WHITE
|
self.contrast != DEFAULT_CONTRAST || self.white != DEFAULT_WHITE || self.curve != CAMERA_RAW
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stage(&self) -> Stage {
|
fn stage(&self) -> Stage {
|
||||||
@@ -130,8 +165,13 @@ impl Operation for ViewTransform {
|
|||||||
"\
|
"\
|
||||||
// Skipped for an already-rendered source: a JPEG is a display rendering
|
// Skipped for an already-rendered source: a JPEG is a display rendering
|
||||||
// already, and rendering it again would compress it twice.
|
// already, and rendering it again would compress it twice.
|
||||||
|
// D19's sigmoid by default, the DNG reference tone by choice (D21).
|
||||||
if (!non_linear) {
|
if (!non_linear) {
|
||||||
c = view_sigmoid(c, slope, inv_k, peak);
|
if (mode > 0.5) {
|
||||||
|
c = camera_raw_tone(c, cr_scale, cr_power, cr_grey);
|
||||||
|
} else {
|
||||||
|
c = view_sigmoid(c, slope, inv_k, peak);
|
||||||
|
}
|
||||||
}"
|
}"
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
@@ -151,11 +191,27 @@ if (!non_linear) {
|
|||||||
name: "peak",
|
name: "peak",
|
||||||
value: s.w,
|
value: s.w,
|
||||||
},
|
},
|
||||||
|
Uniform {
|
||||||
|
name: "mode",
|
||||||
|
value: self.curve,
|
||||||
|
},
|
||||||
|
Uniform {
|
||||||
|
name: "cr_scale",
|
||||||
|
value: crate::camera_raw::input_scale(self.white),
|
||||||
|
},
|
||||||
|
Uniform {
|
||||||
|
name: "cr_power",
|
||||||
|
value: crate::camera_raw::contrast_power(self.contrast),
|
||||||
|
},
|
||||||
|
Uniform {
|
||||||
|
name: "cr_grey",
|
||||||
|
value: crate::view::SCENE_GREY,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn helpers(&self) -> &[Helper] {
|
fn helpers(&self) -> &[Helper] {
|
||||||
&HELPERS
|
HELPERS.as_slice()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +247,7 @@ mod tests {
|
|||||||
let s = Sigmoid::new(2.0, 6.0);
|
let s = Sigmoid::new(2.0, 6.0);
|
||||||
let u = op.uniforms();
|
let u = op.uniforms();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
u.iter().map(|u| u.value).collect::<Vec<_>>(),
|
u.iter().take(3).map(|u| u.value).collect::<Vec<_>>(),
|
||||||
vec![s.n, s.inv_k, s.w]
|
vec![s.n, s.inv_k, s.w]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,19 @@ pub const SCENE_GREY: f32 = 0.13;
|
|||||||
/// Display-linear middle grey — what a camera JPEG shows a grey card as.
|
/// Display-linear middle grey — what a camera JPEG shows a grey card as.
|
||||||
pub const DISPLAY_GREY: f32 = 0.18;
|
pub const DISPLAY_GREY: f32 = 0.18;
|
||||||
|
|
||||||
/// The default contrast, the sigmoid's log-log slope parameter `n`.
|
/// The default contrast: the sigmoid's log-log slope `n`, and for the DNG
|
||||||
pub const DEFAULT_CONTRAST: f32 = 1.4;
|
/// reference curve a power of `DEFAULT_CONTRAST / REFERENCE_CONTRAST` about grey.
|
||||||
|
///
|
||||||
|
/// Fitted, not chosen: on Lightroom 6 exports whose look settings were neutral,
|
||||||
|
/// the DNG reference curve matched the exports best with the input bent by about
|
||||||
|
/// 1.08 (held-out MSE 224 at 1.4, ~150 at 1.5). The sigmoid, which is no longer
|
||||||
|
/// the default, fitted best near 1.7 and is better at 1.5 than at 1.4.
|
||||||
|
pub const DEFAULT_CONTRAST: f32 = 1.5;
|
||||||
|
|
||||||
|
/// The contrast at which each curve is its own reference: the sigmoid's match
|
||||||
|
/// to the retired base curve (see the module note), and the reference table
|
||||||
|
/// untouched.
|
||||||
|
pub const REFERENCE_CONTRAST: f32 = 1.4;
|
||||||
|
|
||||||
/// The default white point, in stops above [`SCENE_GREY`].
|
/// The default white point, in stops above [`SCENE_GREY`].
|
||||||
pub const DEFAULT_WHITE: f32 = 4.0;
|
pub const DEFAULT_WHITE: f32 = 4.0;
|
||||||
@@ -223,11 +234,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_default_stays_close_to_the_retired_curve() {
|
fn the_reference_stays_close_to_the_retired_curve() {
|
||||||
// TRACES: FR-DEV-3j | FR-DEV-3e
|
// TRACES: FR-DEV-3j | FR-DEV-3e
|
||||||
// D19's promise to every existing photograph: the midtones do not
|
// D19's promise, now kept by the sigmoid at its reference contrast
|
||||||
// move by more than a third of a stop.
|
// rather than by the default (D21 moved the default to the DNG reference
|
||||||
let s = Sigmoid::default_curve();
|
// curve): the midtones do not move by more than a third of a stop
|
||||||
|
// from the retired base curve.
|
||||||
|
let s = Sigmoid::new(REFERENCE_CONTRAST, DEFAULT_WHITE);
|
||||||
let mut x = 0.03_f32;
|
let mut x = 0.03_f32;
|
||||||
while x <= 1.0 {
|
while x <= 1.0 {
|
||||||
let ev = (s.channel(x) / retired_default(x)).log2();
|
let ev = (s.channel(x) / retired_default(x)).log2();
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
//! wrong on most images and invisibly so, which is worse than an honest gap,
|
//! wrong on most images and invisibly so, which is worse than an honest gap,
|
||||||
//! so these keys are counted as skipped and reported.
|
//! so these keys are counted as skipped and reported.
|
||||||
//!
|
//!
|
||||||
//! **Tone curves, colour mixing, masks, lens profiles and grain.** Each is a
|
//! **Tone curves, masks, lens profiles and grain.** Each is a
|
||||||
//! structure rather than a number, and each would need its own argument about
|
//! structure rather than a number, and each would need its own argument about
|
||||||
//! whether the two applications mean the same thing. They are skipped by
|
//! whether the two applications mean the same thing. They are skipped by
|
||||||
//! omission — a key not in the table is simply not understood — and the
|
//! omission — a key not in the table is simply not understood — and the
|
||||||
@@ -137,6 +137,158 @@ const MAPPINGS: &[Mapping] = &[
|
|||||||
param: "saturation",
|
param: "saturation",
|
||||||
convert: Convert::Direct,
|
convert: Convert::Direct,
|
||||||
},
|
},
|
||||||
|
// TRACES: FR-DEV-6
|
||||||
|
// Lightroom's HSL panel: eight bands, each ±100 for hue, saturation and
|
||||||
|
// luminance, onto the colour mixer's twelve. Lightroom's bands sit where
|
||||||
|
// ours do except two, matched to the nearest of ours by hue: Aqua (180°)
|
||||||
|
// is our cyan, Purple (270°) our violet; chartreuse, spring, azure and
|
||||||
|
// rose have no Lightroom counterpart and are left alone. One for one, as
|
||||||
|
// a first translation — the band widths differ, and `lr-fit`'s
|
||||||
|
// measurement against Lightroom's own output may yet scale these.
|
||||||
|
Mapping {
|
||||||
|
crs: "HueAdjustmentRed",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "red_hue",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "SaturationAdjustmentRed",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "red_sat",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "LuminanceAdjustmentRed",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "red_lum",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "HueAdjustmentOrange",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "orange_hue",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "SaturationAdjustmentOrange",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "orange_sat",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "LuminanceAdjustmentOrange",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "orange_lum",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "HueAdjustmentYellow",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "yellow_hue",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "SaturationAdjustmentYellow",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "yellow_sat",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "LuminanceAdjustmentYellow",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "yellow_lum",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "HueAdjustmentGreen",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "green_hue",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "SaturationAdjustmentGreen",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "green_sat",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "LuminanceAdjustmentGreen",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "green_lum",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "HueAdjustmentAqua",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "cyan_hue",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "SaturationAdjustmentAqua",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "cyan_sat",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "LuminanceAdjustmentAqua",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "cyan_lum",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "HueAdjustmentBlue",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "blue_hue",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "SaturationAdjustmentBlue",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "blue_sat",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "LuminanceAdjustmentBlue",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "blue_lum",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "HueAdjustmentPurple",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "violet_hue",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "SaturationAdjustmentPurple",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "violet_sat",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "LuminanceAdjustmentPurple",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "violet_lum",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "HueAdjustmentMagenta",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "magenta_hue",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "SaturationAdjustmentMagenta",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "magenta_sat",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
|
Mapping {
|
||||||
|
crs: "LuminanceAdjustmentMagenta",
|
||||||
|
op: "colour_mixer",
|
||||||
|
param: "magenta_lum",
|
||||||
|
convert: Convert::Direct,
|
||||||
|
},
|
||||||
// Adobe's sharpening runs 0…150 where ours runs 0…100, so a preset asking
|
// Adobe's sharpening runs 0…150 where ours runs 0…100, so a preset asking
|
||||||
// for its maximum gets ours rather than being clamped there silently.
|
// for its maximum gets ours rather than being clamped there silently.
|
||||||
Mapping {
|
Mapping {
|
||||||
@@ -192,6 +344,27 @@ pub enum ImportError {
|
|||||||
NoSettings,
|
NoSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// The Lightroom edit stored inside a photograph — the XMP packet Lightroom
|
||||||
|
/// writes into a DNG — translated as a preset is.
|
||||||
|
///
|
||||||
|
/// `None` where the file carries no packet, or one with no Camera Raw
|
||||||
|
/// settings in it (darktable's sidecars, a camera's own XMP). The packet is
|
||||||
|
/// found by its delimiters rather than by walking the TIFF structure: it is
|
||||||
|
/// plain text by specification, and the same search serves any container.
|
||||||
|
pub fn read_embedded(bytes: &[u8]) -> Option<Import> {
|
||||||
|
const OPEN: &[u8] = b"<x:xmpmeta";
|
||||||
|
const CLOSE: &[u8] = b"</x:xmpmeta>";
|
||||||
|
let start = find(bytes, OPEN)?;
|
||||||
|
let end = start + find(&bytes[start..], CLOSE)? + CLOSE.len();
|
||||||
|
let text = std::str::from_utf8(&bytes[start..end]).ok()?;
|
||||||
|
read_xmp(text).ok().filter(|i| !i.preset.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||||
|
haystack.windows(needle.len()).position(|w| w == needle)
|
||||||
|
}
|
||||||
|
|
||||||
/// Read one Lightroom `.xmp` preset.
|
/// Read one Lightroom `.xmp` preset.
|
||||||
///
|
///
|
||||||
/// Tolerant in the same direction the sidecar parser is: a value that will not
|
/// Tolerant in the same direction the sidecar parser is: a value that will not
|
||||||
@@ -375,6 +548,56 @@ mod tests {
|
|||||||
.copied()
|
.copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dngs_embedded_lightroom_edit_comes_across_with_its_hsl() {
|
||||||
|
// TRACES: FR-DEV-6
|
||||||
|
// The shape Lightroom 6 writes into a DNG, trimmed: the library's
|
||||||
|
// house look, as camera-profiles.md's D21 note records it.
|
||||||
|
let mut file = b"II*\0 binary header bytes ".to_vec();
|
||||||
|
file.extend_from_slice(
|
||||||
|
br#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:ProcessVersion="6.7" crs:Exposure2012="0.00" crs:Highlights2012="-40" crs:Blacks2012="-20" crs:SaturationAdjustmentBlue="+58" crs:SaturationAdjustmentAqua="+50" crs:SaturationAdjustmentPurple="+23" crs:HueAdjustmentRed="-5" crs:LuminanceAdjustmentGreen="+7"/></rdf:RDF></x:xmpmeta><?xpacket end="w"?>"#,
|
||||||
|
);
|
||||||
|
file.extend_from_slice(b"\0 more binary");
|
||||||
|
let import = read_embedded(&file).expect("an edit");
|
||||||
|
let p = import.preset.params();
|
||||||
|
let get = |op: &str, param: &str| p.get(&(op.to_string(), param.to_string())).copied();
|
||||||
|
assert_eq!(get("colour_mixer", "blue_sat"), Some(58.0));
|
||||||
|
assert_eq!(get("colour_mixer", "cyan_sat"), Some(50.0));
|
||||||
|
assert_eq!(get("colour_mixer", "violet_sat"), Some(23.0));
|
||||||
|
assert_eq!(get("colour_mixer", "red_hue"), Some(-5.0));
|
||||||
|
assert_eq!(get("colour_mixer", "green_lum"), Some(7.0));
|
||||||
|
assert_eq!(get("highlights_shadows", "highlights"), Some(-40.0));
|
||||||
|
assert_eq!(get("blacks_whites", "blacks"), Some(-20.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_libraries_dngs_carry_the_house_look() {
|
||||||
|
// TRACES: FR-DEV-6
|
||||||
|
// The library's own Lightroom 6 DNG, where it is on this machine.
|
||||||
|
let Some(home) = std::env::var_os("HOME") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let path =
|
||||||
|
std::path::Path::new(&home).join("Nextcloud/PhotosRaw/2017/2017-08-12/_MG_9080.dng");
|
||||||
|
let Ok(bytes) = std::fs::read(&path) else {
|
||||||
|
eprintln!("skipped: no sample DNG at {}", path.display());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let import = read_embedded(&bytes).expect("Lightroom's edit");
|
||||||
|
let p = import.preset.params();
|
||||||
|
let get = |op: &str, param: &str| p.get(&(op.to_string(), param.to_string())).copied();
|
||||||
|
assert_eq!(get("colour_mixer", "blue_sat"), Some(58.0));
|
||||||
|
assert_eq!(get("colour_mixer", "cyan_sat"), Some(50.0));
|
||||||
|
assert_eq!(get("highlights_shadows", "highlights"), Some(-40.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_with_no_camera_raw_settings_has_no_edit() {
|
||||||
|
assert!(read_embedded(b"no packet at all").is_none());
|
||||||
|
let darktable = br#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="3"/></rdf:RDF></x:xmpmeta>"#;
|
||||||
|
assert!(read_embedded(darktable).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_mapping_names_a_parameter_this_build_actually_has() {
|
fn every_mapping_names_a_parameter_this_build_actually_has() {
|
||||||
// The test that keeps the table honest. Adobe's half cannot be checked
|
// The test that keeps the table honest. Adobe's half cannot be checked
|
||||||
|
|||||||
@@ -115,6 +115,11 @@ pub struct ProfileTables {
|
|||||||
pub origin: ProfileOrigin,
|
pub origin: ProfileOrigin,
|
||||||
pub hue_sat: Option<HueSatTable>,
|
pub hue_sat: Option<HueSatTable>,
|
||||||
pub look: Option<HueSatTable>,
|
pub look: Option<HueSatTable>,
|
||||||
|
/// The profile's `ProfileToneCurve`, resampled onto
|
||||||
|
/// [`crate::tone::TONE_SAMPLES`] points; `None` where it has none, and
|
||||||
|
/// the view transform's DNG reference curve then uses
|
||||||
|
/// [`crate::tone::ACR3_DEFAULT`] (D21).
|
||||||
|
pub tone_curve: Option<Vec<f32>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a profile was found (camera-profiles.md §4).
|
/// Where a profile was found (camera-profiles.md §4).
|
||||||
@@ -129,7 +134,7 @@ pub enum ProfileOrigin {
|
|||||||
impl ProfileTables {
|
impl ProfileTables {
|
||||||
/// Whether there is anything to apply.
|
/// Whether there is anything to apply.
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.hue_sat.is_none() && self.look.is_none()
|
self.hue_sat.is_none() && self.look.is_none() && self.tone_curve.is_none()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub mod place;
|
|||||||
pub mod selector;
|
pub mod selector;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod time;
|
pub mod time;
|
||||||
|
pub mod tone;
|
||||||
|
|
||||||
pub use colour::{Chromaticities, Transfer};
|
pub use colour::{Chromaticities, Transfer};
|
||||||
pub use hue_sat::{HueSatTable, ProfileOrigin, ProfileTables};
|
pub use hue_sat::{HueSatTable, ProfileOrigin, ProfileTables};
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
//! TRACES: FR-DEV-3j | FR-DEV-3e
|
||||||
|
//! The DNG SDK's reference tone curve, as data (D21).
|
||||||
|
//!
|
||||||
|
//! # Where the numbers come from
|
||||||
|
//!
|
||||||
|
//! [`ACR3_DEFAULT`] is the "ACR3 default" tone curve of Adobe's DNG SDK
|
||||||
|
//! (`dng_tone_curve_acr3_default`): what the SDK's reference renders a raw through
|
||||||
|
//! when its camera profile carries no `ProfileToneCurve` of its own, which is
|
||||||
|
//! true of Adobe Standard. The values are RawTherapee's
|
||||||
|
//! `adobe_camera_raw_default_curve` (`rtengine/dcp.cc`, GPLv3), copied
|
||||||
|
//! digit for digit; `the_default_curve_is_rawtherapees` checks a sample of
|
||||||
|
//! them.
|
||||||
|
//!
|
||||||
|
//! It maps linear values to linear values, 1025 samples evenly over
|
||||||
|
//! `[0, 1]`, interpolated linearly between them. See
|
||||||
|
//! `docs/dev/camera-profiles.md` §12 for how it is applied — on the largest
|
||||||
|
//! and smallest channel, not on each — which is half of what it does.
|
||||||
|
|
||||||
|
/// Samples in a resolved tone curve: the ACR3 table's own resolution, and
|
||||||
|
/// what a profile's curve is resampled onto.
|
||||||
|
pub const TONE_SAMPLES: usize = 1025;
|
||||||
|
|
||||||
|
/// The ACR3 default tone curve, linear in, linear out.
|
||||||
|
///
|
||||||
|
/// `approx_constant` is allowed because one sample is 0.70711, which clippy
|
||||||
|
/// takes for an approximation of 1/√2. It is a measured value of the curve,
|
||||||
|
/// copied as published; replacing it with the constant would change it.
|
||||||
|
#[rustfmt::skip]
|
||||||
|
#[allow(clippy::approx_constant)]
|
||||||
|
pub const ACR3_DEFAULT: [f32; TONE_SAMPLES] = [
|
||||||
|
0.00000, 0.00078, 0.00160, 0.00242, 0.00314, 0.00385, 0.00460, 0.00539,
|
||||||
|
0.00623, 0.00712, 0.00806, 0.00906, 0.01012, 0.01122, 0.01238, 0.01359,
|
||||||
|
0.01485, 0.01616, 0.01751, 0.01890, 0.02033, 0.02180, 0.02331, 0.02485,
|
||||||
|
0.02643, 0.02804, 0.02967, 0.03134, 0.03303, 0.03475, 0.03648, 0.03824,
|
||||||
|
0.04002, 0.04181, 0.04362, 0.04545, 0.04730, 0.04916, 0.05103, 0.05292,
|
||||||
|
0.05483, 0.05675, 0.05868, 0.06063, 0.06259, 0.06457, 0.06655, 0.06856,
|
||||||
|
0.07057, 0.07259, 0.07463, 0.07668, 0.07874, 0.08081, 0.08290, 0.08499,
|
||||||
|
0.08710, 0.08921, 0.09134, 0.09348, 0.09563, 0.09779, 0.09996, 0.10214,
|
||||||
|
0.10433, 0.10652, 0.10873, 0.11095, 0.11318, 0.11541, 0.11766, 0.11991,
|
||||||
|
0.12218, 0.12445, 0.12673, 0.12902, 0.13132, 0.13363, 0.13595, 0.13827,
|
||||||
|
0.14061, 0.14295, 0.14530, 0.14765, 0.15002, 0.15239, 0.15477, 0.15716,
|
||||||
|
0.15956, 0.16197, 0.16438, 0.16680, 0.16923, 0.17166, 0.17410, 0.17655,
|
||||||
|
0.17901, 0.18148, 0.18395, 0.18643, 0.18891, 0.19141, 0.19391, 0.19641,
|
||||||
|
0.19893, 0.20145, 0.20398, 0.20651, 0.20905, 0.21160, 0.21416, 0.21672,
|
||||||
|
0.21929, 0.22185, 0.22440, 0.22696, 0.22950, 0.23204, 0.23458, 0.23711,
|
||||||
|
0.23963, 0.24215, 0.24466, 0.24717, 0.24967, 0.25216, 0.25465, 0.25713,
|
||||||
|
0.25961, 0.26208, 0.26454, 0.26700, 0.26945, 0.27189, 0.27433, 0.27676,
|
||||||
|
0.27918, 0.28160, 0.28401, 0.28641, 0.28881, 0.29120, 0.29358, 0.29596,
|
||||||
|
0.29833, 0.30069, 0.30305, 0.30540, 0.30774, 0.31008, 0.31241, 0.31473,
|
||||||
|
0.31704, 0.31935, 0.32165, 0.32395, 0.32623, 0.32851, 0.33079, 0.33305,
|
||||||
|
0.33531, 0.33756, 0.33981, 0.34205, 0.34428, 0.34650, 0.34872, 0.35093,
|
||||||
|
0.35313, 0.35532, 0.35751, 0.35969, 0.36187, 0.36404, 0.36620, 0.36835,
|
||||||
|
0.37050, 0.37264, 0.37477, 0.37689, 0.37901, 0.38112, 0.38323, 0.38533,
|
||||||
|
0.38742, 0.38950, 0.39158, 0.39365, 0.39571, 0.39777, 0.39982, 0.40186,
|
||||||
|
0.40389, 0.40592, 0.40794, 0.40996, 0.41197, 0.41397, 0.41596, 0.41795,
|
||||||
|
0.41993, 0.42191, 0.42388, 0.42584, 0.42779, 0.42974, 0.43168, 0.43362,
|
||||||
|
0.43554, 0.43747, 0.43938, 0.44129, 0.44319, 0.44509, 0.44698, 0.44886,
|
||||||
|
0.45073, 0.45260, 0.45447, 0.45632, 0.45817, 0.46002, 0.46186, 0.46369,
|
||||||
|
0.46551, 0.46733, 0.46914, 0.47095, 0.47275, 0.47454, 0.47633, 0.47811,
|
||||||
|
0.47989, 0.48166, 0.48342, 0.48518, 0.48693, 0.48867, 0.49041, 0.49214,
|
||||||
|
0.49387, 0.49559, 0.49730, 0.49901, 0.50072, 0.50241, 0.50410, 0.50579,
|
||||||
|
0.50747, 0.50914, 0.51081, 0.51247, 0.51413, 0.51578, 0.51742, 0.51906,
|
||||||
|
0.52069, 0.52232, 0.52394, 0.52556, 0.52717, 0.52878, 0.53038, 0.53197,
|
||||||
|
0.53356, 0.53514, 0.53672, 0.53829, 0.53986, 0.54142, 0.54297, 0.54452,
|
||||||
|
0.54607, 0.54761, 0.54914, 0.55067, 0.55220, 0.55371, 0.55523, 0.55673,
|
||||||
|
0.55824, 0.55973, 0.56123, 0.56271, 0.56420, 0.56567, 0.56715, 0.56861,
|
||||||
|
0.57007, 0.57153, 0.57298, 0.57443, 0.57587, 0.57731, 0.57874, 0.58017,
|
||||||
|
0.58159, 0.58301, 0.58443, 0.58583, 0.58724, 0.58864, 0.59003, 0.59142,
|
||||||
|
0.59281, 0.59419, 0.59556, 0.59694, 0.59830, 0.59966, 0.60102, 0.60238,
|
||||||
|
0.60373, 0.60507, 0.60641, 0.60775, 0.60908, 0.61040, 0.61173, 0.61305,
|
||||||
|
0.61436, 0.61567, 0.61698, 0.61828, 0.61957, 0.62087, 0.62216, 0.62344,
|
||||||
|
0.62472, 0.62600, 0.62727, 0.62854, 0.62980, 0.63106, 0.63232, 0.63357,
|
||||||
|
0.63482, 0.63606, 0.63730, 0.63854, 0.63977, 0.64100, 0.64222, 0.64344,
|
||||||
|
0.64466, 0.64587, 0.64708, 0.64829, 0.64949, 0.65069, 0.65188, 0.65307,
|
||||||
|
0.65426, 0.65544, 0.65662, 0.65779, 0.65897, 0.66013, 0.66130, 0.66246,
|
||||||
|
0.66362, 0.66477, 0.66592, 0.66707, 0.66821, 0.66935, 0.67048, 0.67162,
|
||||||
|
0.67275, 0.67387, 0.67499, 0.67611, 0.67723, 0.67834, 0.67945, 0.68055,
|
||||||
|
0.68165, 0.68275, 0.68385, 0.68494, 0.68603, 0.68711, 0.68819, 0.68927,
|
||||||
|
0.69035, 0.69142, 0.69249, 0.69355, 0.69461, 0.69567, 0.69673, 0.69778,
|
||||||
|
0.69883, 0.69988, 0.70092, 0.70196, 0.70300, 0.70403, 0.70506, 0.70609,
|
||||||
|
0.70711, 0.70813, 0.70915, 0.71017, 0.71118, 0.71219, 0.71319, 0.71420,
|
||||||
|
0.71520, 0.71620, 0.71719, 0.71818, 0.71917, 0.72016, 0.72114, 0.72212,
|
||||||
|
0.72309, 0.72407, 0.72504, 0.72601, 0.72697, 0.72794, 0.72890, 0.72985,
|
||||||
|
0.73081, 0.73176, 0.73271, 0.73365, 0.73460, 0.73554, 0.73647, 0.73741,
|
||||||
|
0.73834, 0.73927, 0.74020, 0.74112, 0.74204, 0.74296, 0.74388, 0.74479,
|
||||||
|
0.74570, 0.74661, 0.74751, 0.74842, 0.74932, 0.75021, 0.75111, 0.75200,
|
||||||
|
0.75289, 0.75378, 0.75466, 0.75555, 0.75643, 0.75730, 0.75818, 0.75905,
|
||||||
|
0.75992, 0.76079, 0.76165, 0.76251, 0.76337, 0.76423, 0.76508, 0.76594,
|
||||||
|
0.76679, 0.76763, 0.76848, 0.76932, 0.77016, 0.77100, 0.77183, 0.77267,
|
||||||
|
0.77350, 0.77432, 0.77515, 0.77597, 0.77680, 0.77761, 0.77843, 0.77924,
|
||||||
|
0.78006, 0.78087, 0.78167, 0.78248, 0.78328, 0.78408, 0.78488, 0.78568,
|
||||||
|
0.78647, 0.78726, 0.78805, 0.78884, 0.78962, 0.79040, 0.79118, 0.79196,
|
||||||
|
0.79274, 0.79351, 0.79428, 0.79505, 0.79582, 0.79658, 0.79735, 0.79811,
|
||||||
|
0.79887, 0.79962, 0.80038, 0.80113, 0.80188, 0.80263, 0.80337, 0.80412,
|
||||||
|
0.80486, 0.80560, 0.80634, 0.80707, 0.80780, 0.80854, 0.80926, 0.80999,
|
||||||
|
0.81072, 0.81144, 0.81216, 0.81288, 0.81360, 0.81431, 0.81503, 0.81574,
|
||||||
|
0.81645, 0.81715, 0.81786, 0.81856, 0.81926, 0.81996, 0.82066, 0.82135,
|
||||||
|
0.82205, 0.82274, 0.82343, 0.82412, 0.82480, 0.82549, 0.82617, 0.82685,
|
||||||
|
0.82753, 0.82820, 0.82888, 0.82955, 0.83022, 0.83089, 0.83155, 0.83222,
|
||||||
|
0.83288, 0.83354, 0.83420, 0.83486, 0.83552, 0.83617, 0.83682, 0.83747,
|
||||||
|
0.83812, 0.83877, 0.83941, 0.84005, 0.84069, 0.84133, 0.84197, 0.84261,
|
||||||
|
0.84324, 0.84387, 0.84450, 0.84513, 0.84576, 0.84639, 0.84701, 0.84763,
|
||||||
|
0.84825, 0.84887, 0.84949, 0.85010, 0.85071, 0.85132, 0.85193, 0.85254,
|
||||||
|
0.85315, 0.85375, 0.85436, 0.85496, 0.85556, 0.85615, 0.85675, 0.85735,
|
||||||
|
0.85794, 0.85853, 0.85912, 0.85971, 0.86029, 0.86088, 0.86146, 0.86204,
|
||||||
|
0.86262, 0.86320, 0.86378, 0.86435, 0.86493, 0.86550, 0.86607, 0.86664,
|
||||||
|
0.86720, 0.86777, 0.86833, 0.86889, 0.86945, 0.87001, 0.87057, 0.87113,
|
||||||
|
0.87168, 0.87223, 0.87278, 0.87333, 0.87388, 0.87443, 0.87497, 0.87552,
|
||||||
|
0.87606, 0.87660, 0.87714, 0.87768, 0.87821, 0.87875, 0.87928, 0.87981,
|
||||||
|
0.88034, 0.88087, 0.88140, 0.88192, 0.88244, 0.88297, 0.88349, 0.88401,
|
||||||
|
0.88453, 0.88504, 0.88556, 0.88607, 0.88658, 0.88709, 0.88760, 0.88811,
|
||||||
|
0.88862, 0.88912, 0.88963, 0.89013, 0.89063, 0.89113, 0.89163, 0.89212,
|
||||||
|
0.89262, 0.89311, 0.89360, 0.89409, 0.89458, 0.89507, 0.89556, 0.89604,
|
||||||
|
0.89653, 0.89701, 0.89749, 0.89797, 0.89845, 0.89892, 0.89940, 0.89987,
|
||||||
|
0.90035, 0.90082, 0.90129, 0.90176, 0.90222, 0.90269, 0.90316, 0.90362,
|
||||||
|
0.90408, 0.90454, 0.90500, 0.90546, 0.90592, 0.90637, 0.90683, 0.90728,
|
||||||
|
0.90773, 0.90818, 0.90863, 0.90908, 0.90952, 0.90997, 0.91041, 0.91085,
|
||||||
|
0.91130, 0.91173, 0.91217, 0.91261, 0.91305, 0.91348, 0.91392, 0.91435,
|
||||||
|
0.91478, 0.91521, 0.91564, 0.91606, 0.91649, 0.91691, 0.91734, 0.91776,
|
||||||
|
0.91818, 0.91860, 0.91902, 0.91944, 0.91985, 0.92027, 0.92068, 0.92109,
|
||||||
|
0.92150, 0.92191, 0.92232, 0.92273, 0.92314, 0.92354, 0.92395, 0.92435,
|
||||||
|
0.92475, 0.92515, 0.92555, 0.92595, 0.92634, 0.92674, 0.92713, 0.92753,
|
||||||
|
0.92792, 0.92831, 0.92870, 0.92909, 0.92947, 0.92986, 0.93025, 0.93063,
|
||||||
|
0.93101, 0.93139, 0.93177, 0.93215, 0.93253, 0.93291, 0.93328, 0.93366,
|
||||||
|
0.93403, 0.93440, 0.93478, 0.93515, 0.93551, 0.93588, 0.93625, 0.93661,
|
||||||
|
0.93698, 0.93734, 0.93770, 0.93807, 0.93843, 0.93878, 0.93914, 0.93950,
|
||||||
|
0.93986, 0.94021, 0.94056, 0.94092, 0.94127, 0.94162, 0.94197, 0.94231,
|
||||||
|
0.94266, 0.94301, 0.94335, 0.94369, 0.94404, 0.94438, 0.94472, 0.94506,
|
||||||
|
0.94540, 0.94573, 0.94607, 0.94641, 0.94674, 0.94707, 0.94740, 0.94774,
|
||||||
|
0.94807, 0.94839, 0.94872, 0.94905, 0.94937, 0.94970, 0.95002, 0.95035,
|
||||||
|
0.95067, 0.95099, 0.95131, 0.95163, 0.95194, 0.95226, 0.95257, 0.95289,
|
||||||
|
0.95320, 0.95351, 0.95383, 0.95414, 0.95445, 0.95475, 0.95506, 0.95537,
|
||||||
|
0.95567, 0.95598, 0.95628, 0.95658, 0.95688, 0.95718, 0.95748, 0.95778,
|
||||||
|
0.95808, 0.95838, 0.95867, 0.95897, 0.95926, 0.95955, 0.95984, 0.96013,
|
||||||
|
0.96042, 0.96071, 0.96100, 0.96129, 0.96157, 0.96186, 0.96214, 0.96242,
|
||||||
|
0.96271, 0.96299, 0.96327, 0.96355, 0.96382, 0.96410, 0.96438, 0.96465,
|
||||||
|
0.96493, 0.96520, 0.96547, 0.96574, 0.96602, 0.96629, 0.96655, 0.96682,
|
||||||
|
0.96709, 0.96735, 0.96762, 0.96788, 0.96815, 0.96841, 0.96867, 0.96893,
|
||||||
|
0.96919, 0.96945, 0.96971, 0.96996, 0.97022, 0.97047, 0.97073, 0.97098,
|
||||||
|
0.97123, 0.97149, 0.97174, 0.97199, 0.97223, 0.97248, 0.97273, 0.97297,
|
||||||
|
0.97322, 0.97346, 0.97371, 0.97395, 0.97419, 0.97443, 0.97467, 0.97491,
|
||||||
|
0.97515, 0.97539, 0.97562, 0.97586, 0.97609, 0.97633, 0.97656, 0.97679,
|
||||||
|
0.97702, 0.97725, 0.97748, 0.97771, 0.97794, 0.97817, 0.97839, 0.97862,
|
||||||
|
0.97884, 0.97907, 0.97929, 0.97951, 0.97973, 0.97995, 0.98017, 0.98039,
|
||||||
|
0.98061, 0.98082, 0.98104, 0.98125, 0.98147, 0.98168, 0.98189, 0.98211,
|
||||||
|
0.98232, 0.98253, 0.98274, 0.98295, 0.98315, 0.98336, 0.98357, 0.98377,
|
||||||
|
0.98398, 0.98418, 0.98438, 0.98458, 0.98478, 0.98498, 0.98518, 0.98538,
|
||||||
|
0.98558, 0.98578, 0.98597, 0.98617, 0.98636, 0.98656, 0.98675, 0.98694,
|
||||||
|
0.98714, 0.98733, 0.98752, 0.98771, 0.98789, 0.98808, 0.98827, 0.98845,
|
||||||
|
0.98864, 0.98882, 0.98901, 0.98919, 0.98937, 0.98955, 0.98973, 0.98991,
|
||||||
|
0.99009, 0.99027, 0.99045, 0.99063, 0.99080, 0.99098, 0.99115, 0.99133,
|
||||||
|
0.99150, 0.99167, 0.99184, 0.99201, 0.99218, 0.99235, 0.99252, 0.99269,
|
||||||
|
0.99285, 0.99302, 0.99319, 0.99335, 0.99351, 0.99368, 0.99384, 0.99400,
|
||||||
|
0.99416, 0.99432, 0.99448, 0.99464, 0.99480, 0.99495, 0.99511, 0.99527,
|
||||||
|
0.99542, 0.99558, 0.99573, 0.99588, 0.99603, 0.99619, 0.99634, 0.99649,
|
||||||
|
0.99664, 0.99678, 0.99693, 0.99708, 0.99722, 0.99737, 0.99751, 0.99766,
|
||||||
|
0.99780, 0.99794, 0.99809, 0.99823, 0.99837, 0.99851, 0.99865, 0.99879,
|
||||||
|
0.99892, 0.99906, 0.99920, 0.99933, 0.99947, 0.99960, 0.99974, 0.99987,
|
||||||
|
1.00000,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// A profile's `ProfileToneCurve` — `(x, y)` pairs in `[0, 1]` — resampled
|
||||||
|
/// onto [`TONE_SAMPLES`] even points with a natural cubic spline, the DNG
|
||||||
|
/// SDK's `dng_spline_solver`.
|
||||||
|
///
|
||||||
|
/// `None` where the pairs do not describe a curve (fewer than two points,
|
||||||
|
/// x not increasing, values outside `[0, 1]`) or describe the identity,
|
||||||
|
/// which RawTherapee also treats as no curve.
|
||||||
|
pub fn resample_tone_curve(pairs: &[f32]) -> Option<Vec<f32>> {
|
||||||
|
if pairs.len() < 4 || !pairs.len().is_multiple_of(2) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let xs: Vec<f64> = pairs.iter().step_by(2).map(|&v| f64::from(v)).collect();
|
||||||
|
let ys: Vec<f64> = pairs
|
||||||
|
.iter()
|
||||||
|
.skip(1)
|
||||||
|
.step_by(2)
|
||||||
|
.map(|&v| f64::from(v))
|
||||||
|
.collect();
|
||||||
|
let sane = xs.windows(2).all(|w| w[1] > w[0])
|
||||||
|
&& xs
|
||||||
|
.iter()
|
||||||
|
.chain(&ys)
|
||||||
|
.all(|v| v.is_finite() && (-1e-6..=1.0 + 1e-6).contains(v));
|
||||||
|
if !sane {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if xs.iter().zip(&ys).all(|(x, y)| (x - y).abs() < 1e-6) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let n = xs.len();
|
||||||
|
// Natural cubic spline: second derivatives zero at both ends, solved by
|
||||||
|
// the tridiagonal (Thomas) algorithm.
|
||||||
|
let mut m = vec![0.0f64; n];
|
||||||
|
if n > 2 {
|
||||||
|
let h: Vec<f64> = xs.windows(2).map(|w| w[1] - w[0]).collect();
|
||||||
|
let mut a = vec![0.0; n];
|
||||||
|
let mut b = vec![1.0; n];
|
||||||
|
let mut c = vec![0.0; n];
|
||||||
|
let mut d = vec![0.0; n];
|
||||||
|
for i in 1..n - 1 {
|
||||||
|
a[i] = h[i - 1];
|
||||||
|
b[i] = 2.0 * (h[i - 1] + h[i]);
|
||||||
|
c[i] = h[i];
|
||||||
|
d[i] = 6.0 * ((ys[i + 1] - ys[i]) / h[i] - (ys[i] - ys[i - 1]) / h[i - 1]);
|
||||||
|
}
|
||||||
|
for i in 1..n {
|
||||||
|
let w = a[i] / b[i - 1];
|
||||||
|
b[i] -= w * c[i - 1];
|
||||||
|
d[i] -= w * d[i - 1];
|
||||||
|
}
|
||||||
|
m[n - 1] = d[n - 1] / b[n - 1];
|
||||||
|
for i in (0..n - 1).rev() {
|
||||||
|
m[i] = (d[i] - c[i] * m[i + 1]) / b[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let eval = |x: f64| -> f64 {
|
||||||
|
if x <= xs[0] {
|
||||||
|
return ys[0];
|
||||||
|
}
|
||||||
|
if x >= xs[n - 1] {
|
||||||
|
return ys[n - 1];
|
||||||
|
}
|
||||||
|
let i = xs.windows(2).position(|w| x <= w[1]).unwrap_or(n - 2);
|
||||||
|
let h = xs[i + 1] - xs[i];
|
||||||
|
let t = (x - xs[i]) / h;
|
||||||
|
let u = 1.0 - t;
|
||||||
|
u * ys[i]
|
||||||
|
+ t * ys[i + 1]
|
||||||
|
+ ((u * u * u - u) * m[i] + (t * t * t - t) * m[i + 1]) * h * h / 6.0
|
||||||
|
};
|
||||||
|
Some(
|
||||||
|
(0..TONE_SAMPLES)
|
||||||
|
.map(|k| eval(k as f64 / (TONE_SAMPLES - 1) as f64).clamp(0.0, 1.0) as f32)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A resolved curve at `x`, linear interpolation between samples, `x`
|
||||||
|
/// clamped to `[0, 1]` — the shader's lookup, for the CPU reference.
|
||||||
|
pub fn evaluate(curve: &[f32], x: f32) -> f32 {
|
||||||
|
let last = curve.len() - 1;
|
||||||
|
let s = x.clamp(0.0, 1.0) * last as f32;
|
||||||
|
let i = (s as usize).min(last - 1);
|
||||||
|
let f = s - i as f32;
|
||||||
|
curve[i] + (curve[i + 1] - curve[i]) * f
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_default_curve_is_rawtherapees() {
|
||||||
|
assert_eq!(ACR3_DEFAULT.len(), 1025);
|
||||||
|
assert_eq!(ACR3_DEFAULT[0], 0.0);
|
||||||
|
assert_eq!(ACR3_DEFAULT[1024], 1.0);
|
||||||
|
assert_eq!(ACR3_DEFAULT[1], 0.00078);
|
||||||
|
assert_eq!(ACR3_DEFAULT[256], 0.52069);
|
||||||
|
assert_eq!(ACR3_DEFAULT[512], 0.80486);
|
||||||
|
assert_eq!(ACR3_DEFAULT[768], 0.93986);
|
||||||
|
assert!(ACR3_DEFAULT.windows(2).all(|w| w[1] >= w[0]), "monotone");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_resampled_curve_passes_through_its_points() {
|
||||||
|
let c = resample_tone_curve(&[0.0, 0.0, 0.5, 0.6, 1.0, 1.0]).unwrap();
|
||||||
|
assert_eq!(c.len(), TONE_SAMPLES);
|
||||||
|
assert!((evaluate(&c, 0.5) - 0.6).abs() < 1e-4);
|
||||||
|
assert!(evaluate(&c, 0.0).abs() < 1e-6 && (evaluate(&c, 1.0) - 1.0).abs() < 1e-6);
|
||||||
|
assert!(
|
||||||
|
evaluate(&c, 0.25) > 0.25,
|
||||||
|
"a lifted curve lifts between its points"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_identity_or_broken_curve_is_no_curve() {
|
||||||
|
assert!(resample_tone_curve(&[0.0, 0.0, 1.0, 1.0]).is_none());
|
||||||
|
assert!(resample_tone_curve(&[0.0, 0.0, 0.5]).is_none());
|
||||||
|
assert!(resample_tone_curve(&[0.0, 0.0, 0.6, 0.5, 0.4, 0.9, 1.0, 1.0]).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -299,7 +299,7 @@ fi
|
|||||||
rm -rf "${OUT}/staging/assets/models"
|
rm -rf "${OUT}/staging/assets/models"
|
||||||
mkdir -p "${OUT}/staging/assets/models"
|
mkdir -p "${OUT}/staging/assets/models"
|
||||||
_bundled=""
|
_bundled=""
|
||||||
for _dir in face scene inpaint; do
|
for _dir in face scene inpaint denoise; do
|
||||||
ASSETS="${REPO}/models/${_dir}"
|
ASSETS="${REPO}/models/${_dir}"
|
||||||
compgen -G "${ASSETS}/*.onnx" >/dev/null || continue
|
compgen -G "${ASSETS}/*.onnx" >/dev/null || continue
|
||||||
# An LFS pointer is ~130 bytes and looks exactly like a model to `cp`. Left
|
# An LFS pointer is ~130 bytes and looks exactly like a model to `cp`. Left
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# DarkRoom — macOS link check
|
||||||
|
#
|
||||||
|
# Compiles and links for macOS from Linux, with zig as the linker
|
||||||
|
# (cargo-zigbuild). Zig carries macOS's libSystem stubs and C headers, so the
|
||||||
|
# crates that need only libSystem — the inference engine, dr-plat — build,
|
||||||
|
# link and produce Mach-O test binaries here. Nothing runs: there is no macOS
|
||||||
|
# to run them on (docs/dev/macos.md §2). The desktop app needs Apple's
|
||||||
|
# framework headers (AppKit, Metal, Security), which only the Xcode SDK
|
||||||
|
# carries, so it does not link here.
|
||||||
|
#
|
||||||
|
# Build: docker build -t darkroom-macos:latest docker/macos
|
||||||
|
# Use: ./docker/macos/build.sh cargo zigbuild --target aarch64-apple-darwin -p dr-inference-engine --all-targets
|
||||||
|
|
||||||
|
FROM docker.io/library/debian:trixie-slim
|
||||||
|
|
||||||
|
# Pinned, like the Windows and Android images. Rust matches rust-toolchain.toml.
|
||||||
|
ARG RUST_VERSION=1.92.0
|
||||||
|
ARG ZIG_VERSION=0.15.2
|
||||||
|
ARG ZIG_SHA256=02aa270f183da276e5b5920b1dac44a63f1a49e55050ebde3aecc9eb82f93239
|
||||||
|
ARG CARGO_ZIGBUILD_VERSION=0.23.4
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive \
|
||||||
|
CARGO_HOME=/opt/cargo \
|
||||||
|
RUSTUP_HOME=/opt/rustup \
|
||||||
|
PATH=/opt/zig:/opt/cargo/bin:$PATH
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates curl git xz-utils \
|
||||||
|
# A host C compiler: build scripts and proc-macros are Linux binaries.
|
||||||
|
gcc libc6-dev \
|
||||||
|
# `file` says Mach-O; the smoke check in build.sh reads it.
|
||||||
|
file \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-x86_64-linux-${ZIG_VERSION}.tar.xz" -o /tmp/zig.tar.xz \
|
||||||
|
&& echo "${ZIG_SHA256} /tmp/zig.tar.xz" | sha256sum -c - \
|
||||||
|
&& mkdir /opt/zig && tar xJf /tmp/zig.tar.xz -C /opt/zig --strip-components=1 \
|
||||||
|
&& rm /tmp/zig.tar.xz && zig version
|
||||||
|
|
||||||
|
# The components rust-toolchain.toml lists, baked in so rustup does not fetch
|
||||||
|
# them inside every run.
|
||||||
|
RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal \
|
||||||
|
--default-toolchain "${RUST_VERSION}" \
|
||||||
|
--component rustfmt,clippy,rust-analyzer \
|
||||||
|
--target aarch64-apple-darwin,x86_64-apple-darwin \
|
||||||
|
&& cargo install --locked "cargo-zigbuild@${CARGO_ZIGBUILD_VERSION}" \
|
||||||
|
&& rm -rf /opt/cargo/registry \
|
||||||
|
&& chmod -R a+rwX /opt/cargo /opt/rustup
|
||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run a command inside the DarkRoom macOS link-check container.
|
||||||
|
#
|
||||||
|
# ./docker/macos/build.sh cargo zigbuild --target aarch64-apple-darwin -p dr-inference-engine --all-targets
|
||||||
|
# ./docker/macos/build.sh # interactive shell
|
||||||
|
#
|
||||||
|
# Builds the image on first use; `--rebuild` after editing the Dockerfile.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
IMAGE="darkroom-macos:latest"
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO="$(cd "${HERE}/../.." && pwd)"
|
||||||
|
|
||||||
|
if command -v podman >/dev/null 2>&1; then
|
||||||
|
ENGINE=podman
|
||||||
|
elif command -v docker >/dev/null 2>&1; then
|
||||||
|
ENGINE=docker
|
||||||
|
else
|
||||||
|
echo "error: neither podman nor docker found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--rebuild" ]]; then
|
||||||
|
shift
|
||||||
|
"${ENGINE}" build -t "${IMAGE}" "${HERE}"
|
||||||
|
elif ! "${ENGINE}" image inspect "${IMAGE}" >/dev/null 2>&1; then
|
||||||
|
echo "==> building ${IMAGE} (first run; a few minutes)"
|
||||||
|
"${ENGINE}" build -t "${IMAGE}" "${HERE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Registry, target and zig's own cache persist across runs.
|
||||||
|
CACHE="${XDG_CACHE_HOME:-${HOME}/.cache}/darkroom-macos"
|
||||||
|
mkdir -p "${CACHE}/registry" "${CACHE}/target" "${CACHE}/home"
|
||||||
|
|
||||||
|
ARGS=(
|
||||||
|
--rm
|
||||||
|
-v "${REPO}:/work:z"
|
||||||
|
-v "${CACHE}/registry:/opt/cargo/registry:z"
|
||||||
|
-v "${CACHE}/target:/work/target-macos:z"
|
||||||
|
-v "${CACHE}/home:/tmp/home:z"
|
||||||
|
-e HOME=/tmp/home
|
||||||
|
-e CARGO_TARGET_DIR=/work/target-macos
|
||||||
|
-w /work
|
||||||
|
)
|
||||||
|
|
||||||
|
# Capped for the same reason as the Windows image: a cross build otherwise
|
||||||
|
# takes every thread on the host.
|
||||||
|
JOBS="${DARKROOM_BUILD_JOBS:-8}"
|
||||||
|
if [[ "${JOBS}" != "0" ]]; then
|
||||||
|
ARGS+=(--cpus "${JOBS}" -e "CARGO_BUILD_JOBS=${JOBS}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${ENGINE}" == "docker" ]]; then
|
||||||
|
ARGS+=(--user "$(id -u):$(id -g)")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $# -eq 0 ]]; then
|
||||||
|
ARGS+=(-it)
|
||||||
|
set -- /bin/bash
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "${ENGINE}" run "${ARGS[@]}" "${IMAGE}" "$@"
|
||||||
@@ -51,10 +51,10 @@ sed 's/$/\r/' "${REPO}/LICENSE" > "${STAGE}/LICENSE"
|
|||||||
#
|
#
|
||||||
# The directories are the ones the APK stages (assemble-apk.sh) and the Arch
|
# The directories are the ones the APK stages (assemble-apk.sh) and the Arch
|
||||||
# package installs: the face pair and its eye-state models, the scene model
|
# package installs: the face pair and its eye-state models, the scene model
|
||||||
# with its two descriptors, and the panorama border filler. The installer
|
# with its two descriptors, the panorama border filler and the denoiser. The installer
|
||||||
# smoke test counts the same directories, so a model added here is expected
|
# smoke test counts the same directories, so a model added here is expected
|
||||||
# there without a number to update.
|
# there without a number to update.
|
||||||
for dir in face scene inpaint; do
|
for dir in face scene inpaint denoise; do
|
||||||
for f in "${REPO}/models/${dir}"/*; do
|
for f in "${REPO}/models/${dir}"/*; do
|
||||||
case "$(basename "${f}")" in
|
case "$(basename "${f}")" in
|
||||||
README.md) continue ;;
|
README.md) continue ;;
|
||||||
|
|||||||
+145
-12
@@ -95,7 +95,7 @@ Two departures, both forced by D19's unbounded scene-linear values (the SDK runs
|
|||||||
both tables, which is where the second reason matters:
|
both tables, which is where the second reason matters:
|
||||||
- **The look sees exposure.** The SDK applies `LookTable` after its exposure ramp, so a look that
|
- **The look sees exposure.** The SDK applies `LookTable` after its exposure ramp, so a look that
|
||||||
desaturates highlights finds the highlights the photographer chose. At 25 it does too. Contrast,
|
desaturates highlights finds the highlights the photographer chose. At 25 it does too. Contrast,
|
||||||
tone and the colour controls come after it, as they do in Camera Raw.
|
tone and the colour controls come after it, as they do in the DNG SDK's reference rendering.
|
||||||
|
|
||||||
**Tables are per source, like the matrix.** They are decoded with the raw, interpolated once at
|
**Tables are per source, like the matrix.** They are decoded with the raw, interpolated once at
|
||||||
decode (the HueSatMap blend uses the as-shot neutral, as the matrix does) and carried on
|
decode (the HueSatMap blend uses the as-shot neutral, as the matrix does) and carried on
|
||||||
@@ -151,7 +151,7 @@ reads a buffer no declaration can name:
|
|||||||
| Parameter | Kind | Default | Meaning |
|
| Parameter | Kind | Default | Meaning |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `apply` | Bool | on | Use the profile's tables, or the matrix alone |
|
| `apply` | Bool | on | Use the profile's tables, or the matrix alone |
|
||||||
| `look` | Scalar 0–200 | 100 | Strength of the `LookTable`, as Lightroom's *Amount* |
|
| `look` | Scalar 0–200 | 100 | Strength of the `LookTable` |
|
||||||
|
|
||||||
`look` scales the look's deltas: `hueShift · a`, `1 + (satScale − 1)·a`, `1 + (valScale − 1)·a`,
|
`look` scales the look's deltas: `hueShift · a`, `1 + (satScale − 1)·a`, `1 + (valScale − 1)·a`,
|
||||||
with `a = look/100`, scales floored at 0. At 200 the look is twice as strong, which is the
|
with `a = look/100`, scales floored at 0. At 200 the look is twice as strong, which is the
|
||||||
@@ -172,22 +172,17 @@ action sits on that line.
|
|||||||
|
|
||||||
## 6. Not done, and why
|
## 6. Not done, and why
|
||||||
|
|
||||||
- **`ProfileToneCurve` is read and ignored.** D19 gives tone to the view transform, one for every
|
- ~~**`ProfileToneCurve` is read and ignored.**~~ *Done after 0.20.0: §12, D21.* It came back as
|
||||||
body, and rejected per-body curves as its defaults. A profile's curve is a per-body curve. If it
|
an option of the view transform, as this bullet said it would, and that option is the default
|
||||||
comes back, it comes back as an option of the view transform, not as a stage here. The 6D's
|
for raws.
|
||||||
Adobe Standard has none, so the case that matters today loses nothing.
|
- ~~**`BaselineExposure` is not applied.**~~ *Done after 0.20.0: §11.*
|
||||||
- **`BaselineExposure` is not applied** (it is not today either). Adobe Standard was tuned with it,
|
|
||||||
and the 6D's is +0.25 EV. Separate change; it moves every photograph's brightness.
|
|
||||||
- **The interpolation follows the as-shot neutral, not the white-balance slider**, as the matrix
|
- **The interpolation follows the as-shot neutral, not the white-balance slider**, as the matrix
|
||||||
does. Camera Raw re-blends on every temperature change; doing so here means the matrix moves too,
|
does. Camera Raw re-blends on every temperature change; doing so here means the matrix moves too,
|
||||||
which is its own change.
|
which is its own change.
|
||||||
- **Masks select on the matrix's colour.** A colour-range mask sees colour before the profile, as
|
- **Masks select on the matrix's colour.** A colour-range mask sees colour before the profile, as
|
||||||
it sees colour before every other operation. Deterministic, and a mask is drawn on the picture
|
it sees colour before every other operation. Deterministic, and a mask is drawn on the picture
|
||||||
the user sees only approximately anyway.
|
the user sees only approximately anyway.
|
||||||
- **The profiles directory does not sync.** A CR2 rendered on a desktop with a copied 6D profile
|
- ~~**The profiles directory does not sync.**~~ *Done after 0.20.0: §13.*
|
||||||
and on a tablet without one will differ. The panel line says which profile each device used, so
|
|
||||||
the difference is visible rather than silent. Syncing the directory with the library is the
|
|
||||||
follow-up.
|
|
||||||
- **Rec.2020 working primaries** stay deferred (D19); nothing here depends on them.
|
- **Rec.2020 working primaries** stay deferred (D19); nothing here depends on them.
|
||||||
|
|
||||||
## 7. What it costs
|
## 7. What it costs
|
||||||
@@ -256,3 +251,141 @@ is inside its control's range, and every preset changes something.
|
|||||||
4. `dr-ui`: profile line, copy-out action, labels; the profiles directory set at start-up on
|
4. `dr-ui`: profile line, copy-out action, labels; the profiles directory set at start-up on
|
||||||
desktop and Android.
|
desktop and Android.
|
||||||
5. The *Vivid* presets.
|
5. The *Vivid* presets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# After 0.20.0: tone, exposure and sync
|
||||||
|
|
||||||
|
0.20.0 shipped the tables and §1's measurement showed they were not the gap to Lightroom's colour.
|
||||||
|
The three items §6 left open are closed here. Draft of 2026-10-03.
|
||||||
|
|
||||||
|
## 11. Baseline exposure
|
||||||
|
|
||||||
|
`BaselineExposure` (DNG tag 50730) is the stops a converter adds so a camera's middle grey lands
|
||||||
|
where its maker meant it; the 6D's DNGs say +0.25. `BaselineExposureOffset` (51109) is a profile's
|
||||||
|
correction to it. The DNG SDK's total is their sum, and so is this one's.
|
||||||
|
|
||||||
|
- **Applied as a gain on the camera matrix** in `dr-gpu` when a raw is uploaded:
|
||||||
|
`cam_to_srgb · 2^total`. A uniform gain commutes with every scene operation before the view
|
||||||
|
transform, and the camera-space tap and the white-balance probe read camera RGB before the
|
||||||
|
matrix, so neither changes. `RawImage::color_matrix` itself stays the file's: a merge writes a
|
||||||
|
linear DNG from it and must not bake a gain into the pixels it also declares in a tag.
|
||||||
|
- **A copied profile carries the DNG's baseline.** When an embedded profile is saved as a `.dcp`
|
||||||
|
(§4) its `BaselineExposureOffset` is written as the DNG's `BaselineExposure` plus the profile's
|
||||||
|
own offset. A CR2 has no baseline of its own, so its total is then the DNG's: the two files of
|
||||||
|
one body render at one brightness. The cost: a DNG that embeds no profile, carries its own
|
||||||
|
baseline, and matches a copied `.dcp` counts the baseline twice. Every Adobe-written DNG embeds
|
||||||
|
its profile, so that DNG is a hand-made one.
|
||||||
|
|
||||||
|
## 12. DNG reference tone (D21)
|
||||||
|
|
||||||
|
The DNG SDK's reference rendering runs a raw through the profile's
|
||||||
|
`ProfileToneCurve`, or, for a profile that has none — Adobe Standard among them — through the
|
||||||
|
*ACR3 default curve*, a 1025-point table published in the DNG SDK and carried by RawTherapee
|
||||||
|
(GPLv3) as `adobe_camera_raw_default_curve`.
|
||||||
|
|
||||||
|
**How it is applied** is half of what it does. The reference does not run the curve on each channel:
|
||||||
|
`RefBaselineRGBTone` runs it on the largest and smallest channel and places the middle one at the
|
||||||
|
same fraction between them as before. Hue is kept; saturation rises where the curve is steep —
|
||||||
|
the shadows and midtones — which is exactly where Adobe Standard's look table desaturated to
|
||||||
|
compensate. It runs in linear ProPhoto, on values clipped to `[0, 1]`, and its output is linear.
|
||||||
|
|
||||||
|
**As the view transform**, not as a stage. D19 has one rendering, last; this is a second kind of
|
||||||
|
that rendering, chosen by a new parameter on `view_transform`:
|
||||||
|
|
||||||
|
| `curve` | What it is | Default for |
|
||||||
|
|---|---|---|
|
||||||
|
| Sigmoid | D19's log-logistic curve | — (a choice) |
|
||||||
|
| DNG reference | the profile's curve, else ACR3, via RGBTone in ProPhoto | every raw (D21) |
|
||||||
|
|
||||||
|
*Decided 2026-10-03:* the DNG reference is the default, at contrast 1.5 — measured against
|
||||||
|
Lightroom exports of photographs with neutral look settings (D21 has the table).
|
||||||
|
|
||||||
|
*Amended earlier on 2026-10-03:* the DNG reference was the default in the first draft. The measurement it rested on
|
||||||
|
compared against Lightroom renders of *edited* photographs; see D21. The default is decided by
|
||||||
|
measuring against Lightroom exports of unedited ones.
|
||||||
|
|
||||||
|
A JPEG is still not rendered again (FR-DEV-3j). Film simulation still replaces the view transform
|
||||||
|
when a stock is chosen.
|
||||||
|
|
||||||
|
**The two sliders keep meaning something** under the DNG reference curve:
|
||||||
|
|
||||||
|
- `white` (stops above grey at which the scene reaches display white) sets the input scale:
|
||||||
|
`2^(4 − white)`. At its default of 4 the scale is 1 — sensor white is display white, as in
|
||||||
|
the SDK's reference.
|
||||||
|
- `contrast` bends the input about middle grey before the curve, as a power of
|
||||||
|
`contrast / 1.4`: 1 at its default, so the curve is the reference's untouched.
|
||||||
|
|
||||||
|
**What the ACR curve gives up** is D19's shoulder. Values above display white clip, as they do
|
||||||
|
in the SDK's reference; highlight recovery is the highlights slider's job before it. Sigmoid stays one
|
||||||
|
click away for a photograph that wants the shoulder.
|
||||||
|
|
||||||
|
**A profile's own curve** is a list of `(x, y)` pairs. It is resampled at decode onto the same
|
||||||
|
1025 points with a natural cubic spline, the DNG SDK's `dng_spline_solver`. A curve that is the
|
||||||
|
identity is treated as absent, as RawTherapee does.
|
||||||
|
|
||||||
|
**On the GPU** the curve rides in the profile buffer (`@binding(8)`) after the tables: a third
|
||||||
|
header entry gives its length, then the samples. The placeholder bound for a source without a
|
||||||
|
profile carries the ACR3 curve, so a CR2 with no `.dcp` renders through the reference tone when that curve is chosen.
|
||||||
|
|
||||||
|
## 13. Profiles sync
|
||||||
|
|
||||||
|
The profiles directory travels with the library, in the server folder that already carries what
|
||||||
|
every device must agree on: `<library root>/.darkroom-derived/profiles/`. The scanner excludes
|
||||||
|
its parent, as it excludes the trash.
|
||||||
|
|
||||||
|
- **A step of the derived sync pass** (`derived_sync::run`), after the catalog and before the
|
||||||
|
place file, and like the place file it never fails the pass. It lists the server folder and the
|
||||||
|
local one; uploads every local `.dcp` the server lacks, or holds at a different size; downloads
|
||||||
|
every one the device lacks, reading through a placeholder where the library is a synced folder,
|
||||||
|
and writing `.tmp` then renaming so a half-written file is never parsed. If it fetched
|
||||||
|
anything, it reloads the profile set.
|
||||||
|
- **Files are immutable and named for what they hold** (`<camera> <profile>.dcp`), so a name and a
|
||||||
|
size say whether two copies are the same. Two devices that copy the same profile write the same
|
||||||
|
name; neither wins over anything.
|
||||||
|
- **Not a catalog table.** A schema change stops an older peer merging the catalog at all
|
||||||
|
(see the memory of 0.13.3), and a blob of ~120 KB would ride in every catalog upload.
|
||||||
|
- **One directory per install**, the union of every library's profiles. A profile describes a
|
||||||
|
camera, not a library, so a profile one library brought is right for the same camera in
|
||||||
|
another.
|
||||||
|
- **Not handled: deleting.** There is no way to remove a profile from the app; a file removed by
|
||||||
|
hand on one device comes back from the server on the next pass. A tombstone list is the
|
||||||
|
follow-up if deleting is added.
|
||||||
|
|
||||||
|
## 14. Acceptance for §11–§13
|
||||||
|
|
||||||
|
- The ACR3 table is 1025 points from 0 to 1, monotone, and matches RawTherapee's values.
|
||||||
|
- RGBTone: grey goes through the curve unchanged in hue; a colour keeps its hue (the middle
|
||||||
|
channel's fraction between the outer two is unchanged); a curve that is the identity changes
|
||||||
|
nothing; the shader agrees with the CPU reference on a device.
|
||||||
|
- Sigmoid is the default curve; at its defaults it renders to the bit what 0.20.0 rendered,
|
||||||
|
apart from baseline exposure.
|
||||||
|
- A DNG with `BaselineExposure` +0.25 renders a flat grey 0.25 EV brighter than the same pixels
|
||||||
|
with none; a `.dcp` copied from it carries `BaselineExposureOffset` 0.25 and gives a CR2 the same
|
||||||
|
total.
|
||||||
|
- A profile resampled from `(0,0) (0.5,0.6) (1,1)` passes through its points.
|
||||||
|
- Sync: a `.dcp` present only locally is uploaded; one present only on the server is downloaded,
|
||||||
|
parsed and matched on the next decode; a file of the same name and size is left alone.
|
||||||
|
- Measured again on `_MG_9080`: mean saturation at defaults closer to Lightroom's 0.49 than 0.20.0's
|
||||||
|
0.35.
|
||||||
|
|
||||||
|
## 15. The photographer's earlier edit
|
||||||
|
|
||||||
|
What §1 and D21 first took for a difference in rendering is an edit. Every DNG in the library
|
||||||
|
carries, in its embedded XMP, the develop settings it was given before it came to DarkRoom — a
|
||||||
|
consistent house style: per-colour saturation (blue +58, aqua +50, yellow and purple +23, orange
|
||||||
|
+13, green +10), highlights −40, blacks −20, with a second variant (vibrance −10, blue +31). Those
|
||||||
|
settings, not the profile and not the tone curve, are why the same photographs looked richer
|
||||||
|
before.
|
||||||
|
|
||||||
|
- **Translated on open** (`dr_preset_xmp::read_embedded`), the HSL bands onto the colour mixer —
|
||||||
|
aqua to cyan and purple to violet, the nearest of its twelve by hue — and the rest as the preset
|
||||||
|
importer already did.
|
||||||
|
- **Applied only to a photograph DarkRoom has no edit of**, and only on positive evidence: no
|
||||||
|
sidecar beside a local file, or a server that answered "no such file" with nothing cached
|
||||||
|
(`FetchedSidecar::absent`). An edit that failed to arrive is not an absent one, and this would
|
||||||
|
otherwise be saved over it.
|
||||||
|
- **One undoable step, "Earlier Edit"**, then an ordinary edit, saved with the photograph. Export
|
||||||
|
applies it the same way, so a photograph never opened exports as opening it would show.
|
||||||
|
- **The translation is one for one for now.** Measurement against the earlier exports (the
|
||||||
|
`lr-fit` work) may scale individual bands.
|
||||||
|
|||||||
+74
-9
@@ -1,8 +1,9 @@
|
|||||||
# Learned denoise — joint demosaic and denoise on the mosaic
|
# Learned denoise — joint demosaic and denoise on the mosaic
|
||||||
|
|
||||||
Design for **FR-DEV-3g** ([requirements.md](requirements.md)), the learned stage
|
Design for **FR-DEV-3g** ([requirements.md](requirements.md)), the learned stage
|
||||||
[outstanding.md §3](outstanding.md) says is missing. Draft of 2026-09-27: nothing here is built,
|
[outstanding.md §3](outstanding.md) says is missing. Drafted 2026-09-27; a first version shipped
|
||||||
and every figure marked *estimate* is waiting for the measurement that replaces it.
|
in 0.21.0, and §11 records what was built and measured. Figures still marked *estimate* are
|
||||||
|
waiting for the measurement that replaces them.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -288,17 +289,27 @@ is ~120 MB, and a derived file inside a synced tree is exactly what
|
|||||||
with progress over the canvas — the same pattern as a photograph that is only on the server.
|
with progress over the canvas — the same pattern as a photograph that is only on the server.
|
||||||
- Export needs the result and computes it if the cache has lost it.
|
- Export needs the result and computes it if the cache has lost it.
|
||||||
|
|
||||||
### 7.2 The Amount control
|
### 7.2 The grain control
|
||||||
|
|
||||||
A Denoise toggle and one Amount slider in develop. Moving the slider runs inference on the
|
What shipped is a switch and a **Keep grain** slider, not the Amount described first. The slider
|
||||||
**visible viewport only** (~1 MP, a fraction of a second — *estimate*) so the photographer judges
|
blends the two demosaics per pixel — but only the *brightness* of their difference: `out =
|
||||||
on the real result; releasing it queues the whole frame. There is no per-frame blend between the
|
denoised + grain · ΔY / wb`, with `ΔY` the luminance of `wb · (classical − denoised)`. Taken after
|
||||||
two paths: blending the classical output back in re-adds the noise the network removed.
|
the as-shot balance and handed back divided by it, the grain is neutral in the finished picture.
|
||||||
|
|
||||||
|
The objection that stood here — that blending the classical output back in re-adds the noise —
|
||||||
|
holds for a plain mix, which also brings back the classical path's colour speckle and false
|
||||||
|
colour. A luminance-only blend returns film-like grain and nothing else, and it needs no
|
||||||
|
inference: one elementwise GPU pass (`dr_gpu::GrainBlend`) per slider value, producing a new
|
||||||
|
source the adjust pass draws. Comparing the two on real 6D frames, the user chose this one.
|
||||||
|
|
||||||
|
The σ-map Amount (§3.3) still works — `NoiseModel::scaled` — and stays available for a later
|
||||||
|
"strength" control; its cost is a re-run of the network.
|
||||||
|
|
||||||
### 7.3 Runtime
|
### 7.3 Runtime
|
||||||
|
|
||||||
Through `dr-inference-engine`, as the other models run ([inference.md](inference.md)): TensorRT or
|
Through `dr-inference-engine`, as the other models run ([inference.md](inference.md)), as
|
||||||
CUDA fp16 on the laptop, MIGraphX on the desktop, ORT CPU everywhere, QNN on the tablet. Work is
|
`Role::Denoiser`: TensorRT or CUDA fp16 on the laptop, MIGraphX on the desktop, ORT CPU everywhere.
|
||||||
|
**Not the Hexagon** — see §11 — so the tablet runs it on its CPU. Work is
|
||||||
scheduled in the `Background` class so a slider never waits on it (architecture §5.3).
|
scheduled in the `Background` class so a slider never waits on it (architecture §5.3).
|
||||||
|
|
||||||
## 8. Speed and the tablet
|
## 8. Speed and the tablet
|
||||||
@@ -356,3 +367,57 @@ MIT architecture, so this model adds no third-party licence to D13.
|
|||||||
4. Whether a Lightroom or DxO comparison is available for §6.2.
|
4. Whether a Lightroom or DxO comparison is available for §6.2.
|
||||||
5. A borrowed X-Trans body, or X-Trans experimental in v1.
|
5. A borrowed X-Trans body, or X-Trans experimental in v1.
|
||||||
|
|
||||||
|
## 11. What shipped in 0.21.0, and what was measured
|
||||||
|
|
||||||
|
**Data.** 500 distinct ISO 50–100 6D frames from the library, over 121 shooting days (bursts and
|
||||||
|
near-duplicate perceptual hashes dropped; 55 frames from held-out days for validation). Read through
|
||||||
|
`dr-gpu`'s `mosaic_dump` example — `dr-decode` and the app's own hot-pixel pass — so the network's
|
||||||
|
input is the mosaic the classical demosaic reads. Truth by 2×2 binning with a Catmull-Rom quarter-pixel
|
||||||
|
shift of red and blue (§4.2). Training lives in `darkroom-denoise`, beside `darkroom-infill`.
|
||||||
|
|
||||||
|
**Noise model (§5), from the library instead of a capture.** Shot gain and read variance per ISO from
|
||||||
|
Adobe's `NoiseProfile` in the converted DNGs; read noise checked against each frame's masked border
|
||||||
|
(agreement within 2–3 % from ISO 125 to 25600); read-noise *shape* taken from the border as quantiles
|
||||||
|
on a tail-dense grid (excess kurtosis up to ~10 at high ISO), with only the photosites the app's
|
||||||
|
hot-pixel rule would remove left out; row noise from the border's row means; **column noise** from
|
||||||
|
the masked rows above the image — about a third of its variance is this sensor's fixed pattern.
|
||||||
|
Third stops are their own rows: ISO 160, 320 and 640 are quieter than their neighbours, as §5.1
|
||||||
|
expected. Training without the column noise left the 6D's vertical stripes in (0.90 DN of 1.01);
|
||||||
|
with it, 0.17 DN.
|
||||||
|
|
||||||
|
**Model.** Not NAFNet: its channel attention averages over the whole input, which breaks exact
|
||||||
|
tiling. A U-Net of 3×3 convolutions, ReLU, strided and transposed convolutions and additive skips —
|
||||||
|
3.2 M parameters, 48 GMAC per raw megapixel, receptive field 185 photosites (counted from the
|
||||||
|
layers; a perturbation probe under-read it as 157 because a switched-off ReLU hides a path).
|
||||||
|
Tiles of 1408 keep their central 1024 behind a 192 halo, exactly.
|
||||||
|
|
||||||
|
**Results.** PSNR after the display transform, held-out days, step 60 000:
|
||||||
|
|
||||||
|
| ISO | Network | Bilinear | Bilinear on a clean mosaic |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 400 | 41.6 | 36.8 | 40.0 |
|
||||||
|
| 1600 | 40.8 | 33.8 | 40.0 |
|
||||||
|
| 6400 | 39.5 | 29.3 | 40.0 |
|
||||||
|
| 25600 | 37.8 | 24.6 | 40.0 |
|
||||||
|
|
||||||
|
Unbiased in linear light on real frames (shadow level within 1 % of a heavily averaged bilinear).
|
||||||
|
Checked against the app's own render for channel and axis order (`tools/check_against_app.py`).
|
||||||
|
|
||||||
|
**Precision (§8).** fp16: 0.00 dB at every ISO. int8 QDQ, calibrated on training tiles: −6 to −9 dB
|
||||||
|
— the shadow steps §8 feared losing are lost. So the Hexagon refuses the role and the tablet runs f32
|
||||||
|
on its CPU; the residual head of §8 is the route back.
|
||||||
|
|
||||||
|
**Noise for any Bayer body (§3.3).** Table, then `NoiseProfile`, then the frame itself: read, row and
|
||||||
|
column noise from its masked border, the shot gain alone estimated from the quietest flat patches.
|
||||||
|
On 130 6D frames the estimate is within ±10 % of the table from ISO 1000 up and scattered below. The
|
||||||
|
network loses under 0.3 dB for σ off by 15–20 % and twice as much for under- as for over-estimating;
|
||||||
|
the estimate leans high. Every Bayer body is offered the switch; develop says which source was used.
|
||||||
|
|
||||||
|
**Speed, a whole 6D frame (20 MP).** TensorRT fp16 3.1 s, ONNX Runtime CPU 14.4 s, on the laptop —
|
||||||
|
measured while the GPU sat power-capped at an 810 MHz memory clock; uncapped is expected to be
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
|||||||
+15
-3
@@ -151,10 +151,14 @@ winning:
|
|||||||
| Linux / Windows, NVIDIA GPU | TensorRT, f32 model, fp16 engine | CUDA provider, f32 | ORT CPU, f32 | tract |
|
| Linux / Windows, NVIDIA GPU | TensorRT, f32 model, fp16 engine | CUDA provider, f32 | ORT CPU, f32 | tract |
|
||||||
| Linux, AMD GPU with ROCm | MIGraphX, f32 model, fp16 program | ORT CPU, f32 | — | tract |
|
| Linux, AMD GPU with ROCm | MIGraphX, f32 model, fp16 program | ORT CPU, f32 | — | tract |
|
||||||
| Linux / Windows, no GPU stack | ORT CPU, f32 | — | — | tract |
|
| Linux / Windows, no GPU stack | ORT CPU, f32 | — | — | tract |
|
||||||
| macOS ⁵ | ORT CPU, f32 | — | — | tract |
|
| macOS ⁵ | CoreML, f32 model, ML Program | ORT CPU, f32 | — | tract |
|
||||||
|
|
||||||
⁵ CoreML is the obvious rung and is unmeasured; it is listed so its absence is a gap and not an
|
⁵ **Unmeasured**, and the one exception to the rule below: nobody here has a Mac. The rung is on
|
||||||
oversight.
|
the ladder because the probe makes a wrong guess cheap — a CoreML that is slower than the CPU is
|
||||||
|
rejected by §4's clock, one that errors is recorded as failed, and one that takes the process
|
||||||
|
down is refused on the third launch (§4, `attempt`). The embedder stays on the CPU (§7). The first
|
||||||
|
macOS log that shows a probe line is this row's measurement; [macos.md](macos.md) says what to
|
||||||
|
ask for.
|
||||||
|
|
||||||
Deliberately **not** on any ladder, with the measurement that excluded each: NNAPI (no driver),
|
Deliberately **not** on any ladder, with the measurement that excluded each: NNAPI (no driver),
|
||||||
XNNPACK (slower than CPU, aborts on SCRFD), WebGPU (slower than CPU), the Adreno through QNN (works,
|
XNNPACK (slower than CPU, aborts on SCRFD), WebGPU (slower than CPU), the Adreno through QNN (works,
|
||||||
@@ -270,6 +274,14 @@ What the probe may not do:
|
|||||||
- **Retry a rung that failed within a session.** A failed probe is cached as a failure with the
|
- **Retry a rung that failed within a session.** A failed probe is cached as a failure with the
|
||||||
same inputs; the rung is tried again when an input changes. Otherwise a wedged driver means a
|
same inputs; the rung is tried again when an input changes. Otherwise a wedged driver means a
|
||||||
thirty-second stall on every launch.
|
thirty-second stall on every launch.
|
||||||
|
- **Crash the app twice for the same reason.** The probe runs in the app's process, and a provider
|
||||||
|
can fail by aborting rather than by returning an error (XNNPACK on SCRFD, §2). Every session build
|
||||||
|
on a rung above the CPU — the probe's, and each background compile of §6 — writes what it is
|
||||||
|
attempting to `attempt` in the cache directory first and removes it after. A launch that finds the
|
||||||
|
file knows the last one died inside that attempt; after two such launches in a row the attempt is
|
||||||
|
refused and recorded like any other failure (a rung in `failed`, an engine in `refused`), until
|
||||||
|
the fingerprint changes. Two, not one, because quitting during a forty-second TensorRT compile
|
||||||
|
leaves the same file.
|
||||||
- **Choose for the user without saying so.** Settings gains one row, *Inference backend*, showing
|
- **Choose for the user without saying so.** Settings gains one row, *Inference backend*, showing
|
||||||
what was chosen and why in one line ("Hexagon NPU · int8 · QNN 2.42"; "CPU · ONNX Runtime 1.30 ·
|
what was chosen and why in one line ("Hexagon NPU · int8 · QNN 2.42"; "CPU · ONNX Runtime 1.30 ·
|
||||||
TensorRT probe failed: cuDNN 8 required"), with an override to force any lower rung. The about
|
TensorRT probe failed: cuDNN 8 required"), with an override to force any lower rung. The about
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# macOS
|
||||||
|
|
||||||
|
macOS is out of scope for v1 ([requirements.md](requirements.md)), and nobody working on
|
||||||
|
DarkRoom has a Mac. This page records what exists anyway, and how a macOS build is set up so
|
||||||
|
that someone who does have one can send back enough to fix what they hit.
|
||||||
|
|
||||||
|
## 1. What exists
|
||||||
|
|
||||||
|
- **Inference** ([inference.md §2](inference.md)). The macOS ladder is CoreML, then ONNX
|
||||||
|
Runtime's CPU provider, then tract. CoreML is unmeasured. The probe decides whether it is used,
|
||||||
|
and the crash guard (§4, `attempt`) covers the case where the provider takes the process down.
|
||||||
|
The device fingerprint is the chip (`machdep.cpu.brand_string`) and the OS release, because
|
||||||
|
CoreML ships with the OS.
|
||||||
|
- **Where files go** ([`dr_plat::dirs`](../../platform/dr-plat/src/dirs.rs)). The Unix rules,
|
||||||
|
except the state directory (the log and crash records), which is `~/Library/Logs/darkroom`.
|
||||||
|
- **A diagnostic build**, described in §3.
|
||||||
|
|
||||||
|
The rest is not built, packaged or run on macOS by anyone here. This covers the window,
|
||||||
|
Metal through wgpu, the display profile (FR-DSP-8 asks X11 and Wayland), the keyring, the
|
||||||
|
bundle, and signing. `dr-plat` sends every non-Android Unix to the X11/Wayland dependencies.
|
||||||
|
|
||||||
|
## 2. Building
|
||||||
|
|
||||||
|
`docker/macos` compiles and links for macOS from Linux, using zig as the linker
|
||||||
|
(`cargo-zigbuild`). Zig carries libSystem's stubs and the C headers, so tract's SIMD kernels
|
||||||
|
compile and anything that needs only libSystem links:
|
||||||
|
|
||||||
|
./docker/macos/build.sh cargo zigbuild --target aarch64-apple-darwin -p dr-inference-engine --features native --all-targets
|
||||||
|
./docker/macos/build.sh cargo-zigbuild clippy --target aarch64-apple-darwin -p dr-inference-engine --features native --all-targets -- -D warnings
|
||||||
|
|
||||||
|
That produces Mach-O arm64 test binaries and the `ladder` and `ep_probe` examples. Nothing runs
|
||||||
|
them. Anything that links an Apple framework needs the Xcode SDK, which zig does not carry. That
|
||||||
|
includes `dr-plat` (through the keyring's Security and CoreFoundation) and so the desktop app, and
|
||||||
|
its link fails with `unable to find framework`. `cargo check` for those still works in the
|
||||||
|
container.
|
||||||
|
|
||||||
|
Linking the app needs Apple's SDK, which means a Mac. On one:
|
||||||
|
|
||||||
|
cargo build --profile diagnostic -p darkroom-desktop
|
||||||
|
./tools/fetch-desktop-runtime.sh # ONNX Runtime 1.29.0 with CoreML, Apple silicon only
|
||||||
|
|
||||||
|
The fetch script puts `libonnxruntime.dylib` in the user's `runtime/` directory, next to the
|
||||||
|
models. The app also looks in `Contents/Frameworks` of its own bundle, and in Homebrew's
|
||||||
|
`/opt/homebrew/lib` and `/usr/local/lib`. Homebrew's build may not include CoreML; the probe
|
||||||
|
reports that as a failed rung and uses the CPU.
|
||||||
|
|
||||||
|
**For whoever packages it.** A notarised app runs with the hardened runtime, whose library
|
||||||
|
validation refuses to `dlopen` a library signed by another team. A bundled
|
||||||
|
`Contents/Frameworks/libonnxruntime.dylib` must be signed with the app. A runtime the user
|
||||||
|
fetched needs the `com.apple.security.cs.disable-library-validation` entitlement, or it will not
|
||||||
|
load, and the app will be the tract build without saying why beyond one log line.
|
||||||
|
|
||||||
|
## 3. The diagnostic build
|
||||||
|
|
||||||
|
Every macOS build is in the hands of someone who can send a log but cannot attach a debugger,
|
||||||
|
so it is set up to log like a debug build while running at release speed.
|
||||||
|
|
||||||
|
- **The log says more.** With no `RUST_LOG`, the desktop's default filter is `debug` for every
|
||||||
|
`dr_*` crate, for `darkroom_desktop`, and for `onnxruntime`. That last one is ONNX Runtime's
|
||||||
|
own session log, which the engine forwards into `log` on every platform (`session.rs`,
|
||||||
|
`with_runtime_log`). At `debug` it includes how many nodes each provider took. At `trace`
|
||||||
|
(`RUST_LOG=onnxruntime=trace`) it lists every node's placement, which is long. The log cap is
|
||||||
|
the same as everywhere (two files of 4 MiB).
|
||||||
|
- **Backtraces have line numbers.** `--profile diagnostic` is release plus line tables. On
|
||||||
|
macOS the tables go into a `.dSYM` beside the executable, and the backtrace in a crash record
|
||||||
|
finds them only if the `.dSYM` stays next to the binary. Keep it in the bundle.
|
||||||
|
|
||||||
|
## 4. What to ask a Mac user for
|
||||||
|
|
||||||
|
`~/Library/Logs/darkroom/darkroom.log`, plus `darkroom.log.1` if present, after the first launch
|
||||||
|
and after the first scan with faces. Console.app lists it under *Log Reports*. The Settings
|
||||||
|
diagnostics bundle collects the same files. The lines that answer the open questions are:
|
||||||
|
|
||||||
|
| Line | What it tells us |
|
||||||
|
|---|---|
|
||||||
|
| `inference: ONNX Runtime … from …` / `inference: runtime tract` | Whether a runtime was found, and which one |
|
||||||
|
| `inference: floor … ms on the CPU provider` | The CPU number for §2's table |
|
||||||
|
| `inference: CoreML session built in … s` | CoreML's first compile of the probe model |
|
||||||
|
| `inference: CoreML rejected: …` / `failed: …` | Why the CPU was kept |
|
||||||
|
| `onnxruntime` lines naming `CoreMLExecutionProvider::GetCapability` | How much of the graph CoreML took |
|
||||||
|
| `inference: the app died during …` | The crash guard fired, and on what |
|
||||||
|
| `inference: compiling … for CoreML` / `ready on CoreML in … s` | Each model's compile, and any that CoreML refused |
|
||||||
|
|
||||||
|
Also ask for the settings row (*Settings › About › Inference*), which is one line and says the
|
||||||
|
same in short. When one of these logs comes back with CoreML numbers, they go into
|
||||||
|
[inference.md §1–2](inference.md), and footnote ⁵ becomes a measurement.
|
||||||
@@ -2437,6 +2437,7 @@ Rationale, evidence, and the eliminated alternatives are recorded in
|
|||||||
| D12 | Scope versus pace | **DECIDED 2026-09-19** — settled by events; full scope stands, no v1 date |
|
| D12 | Scope versus pace | **DECIDED 2026-09-19** — settled by events; full scope stands, no v1 date |
|
||||||
| D18 | Derived images | **DECIDED 2026-09-19** — a merge writes a new source file; no multi-source Version |
|
| D18 | Derived images | **DECIDED 2026-09-19** — a merge writes a new source file; no multi-source Version |
|
||||||
| D19 | Scene-referred pipeline | **DECIDED 2026-09-27** — edits on unbounded scene-linear colour; one view transform, last; per-body base curves retired |
|
| D19 | Scene-referred pipeline | **DECIDED 2026-09-27** — edits on unbounded scene-linear colour; one view transform, last; per-body base curves retired |
|
||||||
|
| D21 | DNG reference tone for raws | **DECIDED 2026-10-03** — the view transform's DNG reference curve (profile's, else ACR3 default, via RGBTone in ProPhoto) is the default for every raw, at contrast 1.5 (a ×1.07 power about grey); measured against Lightroom exports of photographs with neutral look settings; the sigmoid stays a choice |
|
||||||
| D20 | DCP camera profiles | **DECIDED 2026-10-02** — HueSatMap and LookTable as a scene operation after exposure; embedded profile first, then a matched `.dcp`; tone curve not applied; none shipped |
|
| D20 | DCP camera profiles | **DECIDED 2026-10-02** — HueSatMap and LookTable as a scene operation after exposure; embedded profile first, then a matched `.dcp`; tone curve not applied; none shipped |
|
||||||
|
|
||||||
### D11 — product positioning
|
### D11 — product positioning
|
||||||
@@ -2772,6 +2773,57 @@ matrix does.
|
|||||||
*What it costs.* Every DNG with an embedded profile renders differently; previews refresh only when
|
*What it costs.* Every DNG with an embedded profile renders differently; previews refresh only when
|
||||||
rendered again; tablet and desktop release together. The profiles directory does not sync yet.
|
rendered again; tablet and desktop release together. The profiles directory does not sync yet.
|
||||||
|
|
||||||
|
### D21 — DNG reference tone for raws · **DECIDED 2026-10-03**
|
||||||
|
|
||||||
|
*Decided by measurement, later the same day.* The library's photo gallery holds Lightroom 6
|
||||||
|
exports of raws that are in the library, each carrying its Camera Raw settings. Clustered by
|
||||||
|
those settings, 663 exports had none of the house look (Linear curve, no HSL, no parametric
|
||||||
|
curve, no split toning); 60 of them with their raws, two thirds fitted and one third held out,
|
||||||
|
rendered by DarkRoom against Lightroom's JPEG (MSE, sRGB 8-bit):
|
||||||
|
|
||||||
|
| Rendering | Held-out MSE |
|
||||||
|
|---|---|
|
||||||
|
| 0.20.0's sigmoid at its defaults | ~1200 — about 0.7 EV darker, and flatter |
|
||||||
|
| Sigmoid, exposure, contrast and white fitted | ~150 (contrast 1.73, +0.73 EV) |
|
||||||
|
| DNG reference curve after baseline exposure, at contrast 1.4 | 224 |
|
||||||
|
| DNG reference curve, contrast 1.5 | ~150 |
|
||||||
|
| DNG reference curve, exposure, contrast and white fitted | 143 |
|
||||||
|
|
||||||
|
So the DNG reference curve is the default for every raw, and the default contrast is 1.5 — under that
|
||||||
|
curve a power of 1.5/1.4 about grey (`REFERENCE_CONTRAST` is where the curve is untouched). The
|
||||||
|
brightness needs nothing: baseline exposure and the curve together land where the earlier exports do. The
|
||||||
|
profile's look strength, vibrance and saturation bought nothing measurable on those exports. The
|
||||||
|
user chose to change every photograph rather than keep edited ones on the old rendering. The
|
||||||
|
fitting tools live outside the repository (`darkroom-lrfit`).
|
||||||
|
|
||||||
|
*Amended earlier the same day:* the default was **not** decided. The measurement below was against
|
||||||
|
Lightroom previews of photographs carrying the user's Lightroom edits — a house look of HSL
|
||||||
|
saturation (Blue +58, Aqua +50, …), Highlights −40 and Blacks −20 in every DNG's XMP — so it said
|
||||||
|
nothing about Camera Raw's base rendering. Under the DNG reference curve `_MG_9080` renders brighter
|
||||||
|
than its Lightroom preview (mean 0.39 against 0.31). The sigmoid stays the default; the curve
|
||||||
|
below is a choice; the default is decided by measurement against Lightroom exports of unedited
|
||||||
|
photographs (the `lr-fit` work). What follows is the original text.
|
||||||
|
|
||||||
|
**The view transform has two curves, and Camera Raw's is the default for every raw.** It is the
|
||||||
|
profile's `ProfileToneCurve`, or the ACR3 default curve where the profile has none or there is no
|
||||||
|
profile, applied Camera Raw's way — on the largest and smallest channel in linear ProPhoto, the
|
||||||
|
middle placed proportionally — after `BaselineExposure`. D19's sigmoid stays as the other choice.
|
||||||
|
Design: [camera-profiles.md](camera-profiles.md) §11–§13.
|
||||||
|
|
||||||
|
*Why.* Measured on the library's 6D DNGs after D20 (camera-profiles.md §1): the profile tables
|
||||||
|
lowered saturation, because Adobe's look tables were tuned to sit under this curve. The user's
|
||||||
|
complaint was that Lightroom's rendering is more colourful, and this curve is most of the reason.
|
||||||
|
Chosen by the user over limiting it to raws with a profile, or making it opt-in.
|
||||||
|
|
||||||
|
*What it reverses in D19.* D19 rejected per-body curves as defaults because their provenance was
|
||||||
|
unknown. A DCP's curve and the ACR3 table have known provenance — Adobe's, published — and so the
|
||||||
|
objection that retired the base curves does not apply. D19's other half stands: nothing before
|
||||||
|
the view transform clamps, and the curve is the view transform, last.
|
||||||
|
|
||||||
|
*What it costs.* Every raw renders differently again, and highlights above display white clip
|
||||||
|
where the sigmoid rolled them off; Sigmoid is one click away. Previews refresh only when rendered
|
||||||
|
again; tablet and desktop release together.
|
||||||
|
|
||||||
### D16 — plugin licensing · **OPEN, post-v1**
|
### D16 — plugin licensing · **OPEN, post-v1**
|
||||||
|
|
||||||
> Deferred with §3.10 on 2026-09-19. Still to be answered before the format is published as
|
> Deferred with §3.10 on 2026-09-19. Still to be answered before the format is published as
|
||||||
|
|||||||
+96
-96
File diff suppressed because one or more lines are too long
@@ -205,6 +205,35 @@ the sensor recorded.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### AI denoise
|
||||||
|
|
||||||
|
For a photograph taken in poor light at a high ISO. `AI Denoise`, in the
|
||||||
|
Detail group, replaces how the camera's raw data is turned into colour: a
|
||||||
|
network trained on this library's own photographs removes the noise and
|
||||||
|
the blotches of colour that come with it, while keeping the fine detail.
|
||||||
|
Look at it at 1:1, where noise lives.
|
||||||
|
|
||||||
|
Switch it on with `Apply`. The photograph keeps showing as it was while
|
||||||
|
the network works, with its progress in the bar at the top, and changes
|
||||||
|
when it is done — a few seconds on a computer with a graphics card, about
|
||||||
|
fifteen on its processor alone, longer on the tablet. `Keep grain` puts
|
||||||
|
back some of what was removed, as grain without colour, for a picture that
|
||||||
|
does not look too smooth.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Before | After |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
It works on raw files from any camera with the usual colour pattern of
|
||||||
|
red, green and blue squares — not on JPEGs, and not yet on Fujifilm's
|
||||||
|
X-Trans. How noisy the camera is at each ISO was measured for the Canon
|
||||||
|
EOS 6D; for other cameras it is read from a DNG's own figures or
|
||||||
|
estimated from the photograph, and the finished job in the activity list
|
||||||
|
says which. An export uses
|
||||||
|
it whenever the photograph has it switched on.
|
||||||
|
|
||||||
### Moving between photographs
|
### Moving between photographs
|
||||||
|
|
||||||
The roll along the foot of the canvas holds the photographs the grid was
|
The roll along the foot of the canvas holds the photographs the grid was
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ th { color: var(--ink-dim); font-weight: 600; }
|
|||||||
<ul>
|
<ul>
|
||||||
<li><a href="#light">Light</a></li>
|
<li><a href="#light">Light</a></li>
|
||||||
<li><a href="#looking-closer">Looking closer</a></li>
|
<li><a href="#looking-closer">Looking closer</a></li>
|
||||||
|
<li><a href="#ai-denoise">AI denoise</a></li>
|
||||||
<li><a href="#moving-between-photographs">Moving between photographs</a></li>
|
<li><a href="#moving-between-photographs">Moving between photographs</a></li>
|
||||||
<li><a href="#white-balance-from-the-photograph">White balance from the photograph</a></li>
|
<li><a href="#white-balance-from-the-photograph">White balance from the photograph</a></li>
|
||||||
<li><a href="#composing">Composing</a></li>
|
<li><a href="#composing">Composing</a></li>
|
||||||
@@ -287,6 +288,29 @@ wheel zooms to any amount in between. Past 1:1 the file's own pixels are
|
|||||||
drawn as hard-edged blocks rather than smoothed, so what you see is what
|
drawn as hard-edged blocks rather than smoothed, so what you see is what
|
||||||
the sensor recorded.</p>
|
the sensor recorded.</p>
|
||||||
<figure><img loading="lazy" src="media/develop-zoom.gif" alt="Zooming to 1:1 with a double-click, panning, then further in with the wheel"><figcaption>Zooming to 1:1 with a double-click, panning, then further in with the wheel</figcaption></figure>
|
<figure><img loading="lazy" src="media/develop-zoom.gif" alt="Zooming to 1:1 with a double-click, panning, then further in with the wheel"><figcaption>Zooming to 1:1 with a double-click, panning, then further in with the wheel</figcaption></figure>
|
||||||
|
<h3 id="ai-denoise">AI denoise</h3>
|
||||||
|
<p>For a photograph taken in poor light at a high ISO. <code>AI Denoise</code>, in the
|
||||||
|
Detail group, replaces how the camera's raw data is turned into colour: a
|
||||||
|
network trained on this library's own photographs removes the noise and
|
||||||
|
the blotches of colour that come with it, while keeping the fine detail.
|
||||||
|
Look at it at 1:1, where noise lives.</p>
|
||||||
|
<p>Switch it on with <code>Apply</code>. The photograph keeps showing as it was while
|
||||||
|
the network works, with its progress in the bar at the top, and changes
|
||||||
|
when it is done — a few seconds on a computer with a graphics card, about
|
||||||
|
fifteen on its processor alone, longer on the tablet. <code>Keep grain</code> puts
|
||||||
|
back some of what was removed, as grain without colour, for a picture that
|
||||||
|
does not look too smooth.</p>
|
||||||
|
<figure><img loading="lazy" src="media/develop-denoise.gif" alt="An ISO 8000 night frame at 1:1, AI Denoise switched on, then some grain kept"><figcaption>An ISO 8000 night frame at 1:1, AI Denoise switched on, then some grain kept</figcaption></figure>
|
||||||
|
<table><thead><tr><th>Before</th><th>After</th></tr></thead><tbody>
|
||||||
|
<tr><td><img src="media/develop-denoise-before.png" alt="The railing and the lamp at ISO 8000, as the camera recorded them" /></td><td><img src="media/develop-denoise-after.png" alt="The same, with AI Denoise" /></td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
<p>It works on raw files from any camera with the usual colour pattern of
|
||||||
|
red, green and blue squares — not on JPEGs, and not yet on Fujifilm's
|
||||||
|
X-Trans. How noisy the camera is at each ISO was measured for the Canon
|
||||||
|
EOS 6D; for other cameras it is read from a DNG's own figures or
|
||||||
|
estimated from the photograph, and the finished job in the activity list
|
||||||
|
says which. An export uses
|
||||||
|
it whenever the photograph has it switched on.</p>
|
||||||
<h3 id="moving-between-photographs">Moving between photographs</h3>
|
<h3 id="moving-between-photographs">Moving between photographs</h3>
|
||||||
<p>The roll along the foot of the canvas holds the photographs the grid was
|
<p>The roll along the foot of the canvas holds the photographs the grid was
|
||||||
showing; click one to open it. The right arrow, <code>D</code> or space opens the next,
|
showing; click one to open it. The right arrow, <code>D</code> or space opens the next,
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -122,3 +122,16 @@ position of any model here. The training set is Places2, a research dataset,
|
|||||||
but the weights are released under the repository's licence without a
|
but the weights are released under the repository's licence without a
|
||||||
data-derived restriction (contrast the gaze models §7 of the requirements
|
data-derived restriction (contrast the gaze models §7 of the requirements
|
||||||
declined, and the InsightFace grant of D13).
|
declined, and the InsightFace grant of D13).
|
||||||
|
|
||||||
|
## `denoise/` — the mosaic denoiser, the project's own
|
||||||
|
|
||||||
|
| 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) |
|
||||||
|
|
||||||
|
A U-Net 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
|
||||||
|
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.
+10
-1
@@ -4,7 +4,7 @@
|
|||||||
# makes `makepkg -si` in this directory install what you are actually working
|
# makes `makepkg -si` in this directory install what you are actually working
|
||||||
# on. Swap `source` for a tagged tarball when there is something to release.
|
# on. Swap `source` for a tagged tarball when there is something to release.
|
||||||
pkgname=darkroom
|
pkgname=darkroom
|
||||||
pkgver=0.20.0
|
pkgver=0.21.0
|
||||||
# Back to 1 with the version: a new pkgver is a new archive name, so there is
|
# Back to 1 with the version: a new pkgver is a new archive name, so there is
|
||||||
# nothing for makepkg to reuse and nothing for a release number to disambiguate.
|
# nothing for makepkg to reuse and nothing for a release number to disambiguate.
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
@@ -123,4 +123,13 @@ package() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
install -Dm644 "${_src}" "${pkgdir}/usr/share/darkroom/models/migan-512.onnx"
|
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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ pub fn set_state_dir(dir: PathBuf) {
|
|||||||
|
|
||||||
/// Where this application keeps state that is neither configuration nor cache.
|
/// Where this application keeps state that is neither configuration nor cache.
|
||||||
///
|
///
|
||||||
/// `$XDG_STATE_HOME/darkroom`, falling back to `~/.local/state/darkroom`.
|
/// `$XDG_STATE_HOME/darkroom`, falling back to `~/.local/state/darkroom` —
|
||||||
|
/// `~/Library/Logs/darkroom` on macOS (`dirs`).
|
||||||
/// State rather than cache because a crash record must survive the sweep that
|
/// State rather than cache because a crash record must survive the sweep that
|
||||||
/// a cache directory exists to permit, and rather than config because it is
|
/// a cache directory exists to permit, and rather than config because it is
|
||||||
/// not something the user edits.
|
/// not something the user edits.
|
||||||
|
|||||||
@@ -46,6 +46,8 @@
|
|||||||
//!
|
//!
|
||||||
//! * Linux: `$XDG_STATE_HOME/darkroom/darkroom.log`, else
|
//! * Linux: `$XDG_STATE_HOME/darkroom/darkroom.log`, else
|
||||||
//! `~/.local/state/darkroom/darkroom.log`.
|
//! `~/.local/state/darkroom/darkroom.log`.
|
||||||
|
//! * macOS: `$XDG_STATE_HOME/darkroom/darkroom.log`, else
|
||||||
|
//! `~/Library/Logs/darkroom/darkroom.log`, where Console.app lists it.
|
||||||
//! * Android: `/sdcard/Android/data/paris.tourolle.darkroom/files/darkroom.log`,
|
//! * Android: `/sdcard/Android/data/paris.tourolle.darkroom/files/darkroom.log`,
|
||||||
//! which `adb pull` reads from an ordinary release build. See
|
//! which `adb pull` reads from an ordinary release build. See
|
||||||
//! [`crate::state`] for why not the internal directory, and
|
//! [`crate::state`] for why not the internal directory, and
|
||||||
|
|||||||
@@ -17,6 +17,11 @@
|
|||||||
//! | Unix default | `~/.config` | `~/.local/share` | `~/.local/state` |
|
//! | Unix default | `~/.config` | `~/.local/share` | `~/.local/state` |
|
||||||
//! | Windows | `%APPDATA%` | `%LOCALAPPDATA%` | `%LOCALAPPDATA%`, then `state` |
|
//! | Windows | `%APPDATA%` | `%LOCALAPPDATA%` | `%LOCALAPPDATA%`, then `state` |
|
||||||
//! | Windows default | `%USERPROFILE%\AppData\Roaming` | `…\AppData\Local` | `…\AppData\Local` |
|
//! | Windows default | `%USERPROFILE%\AppData\Roaming` | `…\AppData\Local` | `…\AppData\Local` |
|
||||||
|
//! | macOS default | `~/.config` | `~/.local/share` | `~/Library/Logs` |
|
||||||
|
//!
|
||||||
|
//! macOS follows the Unix rules except for the one directory a user is asked
|
||||||
|
//! to find by hand: the log. Finder hides `~/.local`, and `~/Library/Logs`
|
||||||
|
//! is where Console.app and a Mac user already look (docs/dev/macos.md).
|
||||||
//!
|
//!
|
||||||
//! then `darkroom` under each. Config roams on Windows and the rest does not,
|
//! then `darkroom` under each. Config roams on Windows and the rest does not,
|
||||||
//! which is the same split XDG makes between config and everything else, and
|
//! which is the same split XDG makes between config and everything else, and
|
||||||
@@ -100,11 +105,18 @@ fn resolve(kind: Base, env: impl Fn(&str) -> Option<OsString>) -> PathBuf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where state goes under `$HOME` when `XDG_STATE_HOME` does not say.
|
||||||
|
const STATE_UNDER_HOME: &str = if cfg!(target_os = "macos") {
|
||||||
|
"Library/Logs"
|
||||||
|
} else {
|
||||||
|
".local/state"
|
||||||
|
};
|
||||||
|
|
||||||
fn xdg_base(kind: Base, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
fn xdg_base(kind: Base, env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||||
let (var, under_home) = match kind {
|
let (var, under_home) = match kind {
|
||||||
Base::Config => ("XDG_CONFIG_HOME", ".config"),
|
Base::Config => ("XDG_CONFIG_HOME", ".config"),
|
||||||
Base::Data => ("XDG_DATA_HOME", ".local/share"),
|
Base::Data => ("XDG_DATA_HOME", ".local/share"),
|
||||||
Base::State => ("XDG_STATE_HOME", ".local/state"),
|
Base::State => ("XDG_STATE_HOME", STATE_UNDER_HOME),
|
||||||
};
|
};
|
||||||
absolute(env(var)).or_else(|| absolute(env("HOME")).map(|h| h.join(under_home)))
|
absolute(env(var)).or_else(|| absolute(env("HOME")).map(|h| h.join(under_home)))
|
||||||
}
|
}
|
||||||
@@ -150,7 +162,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
xdg_base(Base::State, &e),
|
xdg_base(Base::State, &e),
|
||||||
Some(PathBuf::from("/home/someone/.local/state"))
|
Some(PathBuf::from("/home/someone").join(STATE_UNDER_HOME))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +174,7 @@ mod tests {
|
|||||||
let e = env(&[("XDG_STATE_HOME", "state"), ("HOME", "/home/someone")]);
|
let e = env(&[("XDG_STATE_HOME", "state"), ("HOME", "/home/someone")]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
xdg_base(Base::State, &e),
|
xdg_base(Base::State, &e),
|
||||||
Some(PathBuf::from("/home/someone/.local/state"))
|
Some(PathBuf::from("/home/someone").join(STATE_UNDER_HOME))
|
||||||
);
|
);
|
||||||
assert_eq!(xdg_base(Base::State, &env(&[("HOME", "")])), None);
|
assert_eq!(xdg_base(Base::State, &env(&[("HOME", "")])), None);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,32 @@
|
|||||||
# directory (docs/inference.md §1.3).
|
# directory (docs/inference.md §1.3).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
DEST="${1:-${XDG_DATA_HOME:-${HOME}/.local/share}/darkroom/runtime}"
|
DEST="${1:-${XDG_DATA_HOME:-${HOME}/.local/share}/darkroom/runtime}"
|
||||||
|
|
||||||
|
# macOS: Microsoft's release archive, which carries the CoreML provider in the
|
||||||
|
# one library. Pinned, because the CoreML options the engine sets were read
|
||||||
|
# from this version's source (docs/dev/macos.md, CLAUDE.md "Providers").
|
||||||
|
# Apple silicon only: no Intel archive is published since 1.29; an Intel Mac
|
||||||
|
# takes Homebrew's `onnxruntime` or stays on tract.
|
||||||
|
if [[ "$(uname -s)" == Darwin ]]; then
|
||||||
|
ORT_VERSION=1.29.0
|
||||||
|
[[ "$(uname -m)" == arm64 ]] || {
|
||||||
|
echo "error: no ONNX Runtime ${ORT_VERSION} archive for $(uname -m); try: brew install onnxruntime" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
NAME="onnxruntime-osx-arm64-${ORT_VERSION}"
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${WORK}"' EXIT
|
||||||
|
echo "==> downloading ${NAME}"
|
||||||
|
curl -fsSL "https://github.com/microsoft/onnxruntime/releases/download/v${ORT_VERSION}/${NAME}.tgz" \
|
||||||
|
| tar xz -C "${WORK}"
|
||||||
|
mkdir -p "${DEST}"
|
||||||
|
cp "${WORK}/${NAME}/lib/libonnxruntime.dylib" "${WORK}/${NAME}/LICENSE" "${DEST}/"
|
||||||
|
echo "==> runtime in ${DEST}:"
|
||||||
|
ls -1 "${DEST}" | sed 's/^/ /'
|
||||||
|
echo " (the app finds it on its next launch; Settings › About › Inference says what it chose)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
WORK="$(mktemp -d -p /var/tmp fetch-desktop-runtime.XXXXXX)"
|
WORK="$(mktemp -d -p /var/tmp fetch-desktop-runtime.XXXXXX)"
|
||||||
trap 'rm -rf "${WORK}"' EXIT
|
trap 'rm -rf "${WORK}"' EXIT
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ ALPS_SKY = '_MG_8330' # an upright alpine frame, its top third sky and clo
|
|||||||
TOWER = '_MG_8693' # towers shot looking up: verticals that converge
|
TOWER = '_MG_8693' # towers shot looking up: verticals that converge
|
||||||
ROAD = '_MG_8672' # a road under a sky, with marks on it: masks and repair
|
ROAD = '_MG_8672' # a road under a sky, with marks on it: masks and repair
|
||||||
ROAD_MARK = (0.305, 0.794) # a dark mark on its tarmac
|
ROAD_MARK = (0.305, 0.794) # a dark mark on its tarmac
|
||||||
|
DENOISE = '_MG_8862' # Brooklyn Bridge at ISO 8000: a lit railing, a lamp and a camera against the night
|
||||||
|
|
||||||
# --- the registry -----------------------------------------------------------
|
# --- the registry -----------------------------------------------------------
|
||||||
SCENES = {}
|
SCENES = {}
|
||||||
@@ -841,6 +842,68 @@ def develop_zoom():
|
|||||||
pause(1.2)
|
pause(1.2)
|
||||||
|
|
||||||
|
|
||||||
|
@scene(media=['develop-denoise.gif', 'develop-denoise-before.png', 'develop-denoise-after.png'],
|
||||||
|
sources=DEVELOP_SRC + ['ui/dr-ui/src/develop/denoise.rs', 'core/dr-denoise/**',
|
||||||
|
'core/dr-gpu/src/grain.rs', 'models/denoise/**'])
|
||||||
|
def develop_denoise():
|
||||||
|
"""A night frame at ISO 8000 at 1:1, AI Denoise switched on and landed,
|
||||||
|
then some grain kept. Waits for the network rather than for a fixed
|
||||||
|
time: on the CPU it takes several times what it does on a GPU."""
|
||||||
|
at_develop(DENOISE)
|
||||||
|
a = dr.photo(0.45, 0.55) # the lit lamp, the railing and the skyline over the water
|
||||||
|
dr.move(*a)
|
||||||
|
pause(0.3)
|
||||||
|
dr.x('click', '--repeat', 2, '--delay', 80, 1)
|
||||||
|
pause(1.5)
|
||||||
|
group('Detail')
|
||||||
|
in_column('AI Denoise@Text')
|
||||||
|
shot('develop-denoise-before')
|
||||||
|
rec('develop-denoise')
|
||||||
|
pause(0.8)
|
||||||
|
mark = log_size()
|
||||||
|
dr.click(*denoise_switch())
|
||||||
|
t0 = time.time()
|
||||||
|
while time.time() - t0 < 300 and not denoise_landed(mark):
|
||||||
|
pause(0.5)
|
||||||
|
pause(1.5)
|
||||||
|
shot('develop-denoise-after')
|
||||||
|
slide('Keep grain', 60)
|
||||||
|
pause(2.0)
|
||||||
|
cut()
|
||||||
|
undo_all()
|
||||||
|
dr.move(*a)
|
||||||
|
dr.x('click', '--repeat', 2, '--delay', 80, 1)
|
||||||
|
pause(1.2)
|
||||||
|
|
||||||
|
|
||||||
|
def denoise_switch():
|
||||||
|
"""The `Apply` box under the AI Denoise heading — the lens profile's
|
||||||
|
switch is also called Apply, so it is found by where it sits."""
|
||||||
|
head = dr.matches('AI Denoise@Text', within=column())[0]
|
||||||
|
below = [e for e in dr.matches('Apply@CheckBox', within=column()) if e['y'] > head['y']]
|
||||||
|
e = min(below, key=lambda e: e['y'])
|
||||||
|
return int(e['x'] + e['w'] / 2), int(e['y'] + e['h'] / 2)
|
||||||
|
|
||||||
|
|
||||||
|
def log_size():
|
||||||
|
try:
|
||||||
|
return os.path.getsize(f'{dr.HOME}/app.log')
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def denoise_landed(since):
|
||||||
|
"""The app has logged the result landing since `since` bytes into its
|
||||||
|
log. The activity bar's progress has no name to wait on, and the time
|
||||||
|
it takes is the device's — seconds on a GPU, more on the CPU."""
|
||||||
|
try:
|
||||||
|
with open(f'{dr.HOME}/app.log', 'rb') as f:
|
||||||
|
f.seek(since)
|
||||||
|
return b'learned denoise:' in f.read()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@scene(media=['develop-wb.gif'], sources=DEVELOP_SRC + ['ui/dr-ui/src/develop/white_balance.rs'])
|
@scene(media=['develop-wb.gif'], sources=DEVELOP_SRC + ['ui/dr-ui/src/develop/white_balance.rs'])
|
||||||
def develop_wb():
|
def develop_wb():
|
||||||
at_develop()
|
at_develop()
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ dr-export.workspace = true
|
|||||||
# The panorama's geometry and its keypoint detector (§3.11). The detector's
|
# The panorama's geometry and its keypoint detector (§3.11). The detector's
|
||||||
# runtime is the same tract the faces and masks already carry.
|
# runtime is the same tract the faces and masks already carry.
|
||||||
dr-pano = { workspace = true, features = ["xfeat", "embedded-model"] }
|
dr-pano = { workspace = true, features = ["xfeat", "embedded-model"] }
|
||||||
|
# The learned demosaic and denoise (FR-DEV-3g), under the same engine.
|
||||||
|
dr-denoise = { workspace = true, features = ["native"] }
|
||||||
dr-ingest.workspace = true
|
dr-ingest.workspace = true
|
||||||
# The sameness probe of a catalog duplicate (FR-CAT-11a): SHA-256 over the
|
# 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.
|
# ends of each copy, the digest the import already uses for whole files.
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ pub enum Kind {
|
|||||||
/// TRACES: FR-EXP-7
|
/// TRACES: FR-EXP-7
|
||||||
/// Rendering and writing finished files.
|
/// Rendering and writing finished files.
|
||||||
Export,
|
Export,
|
||||||
|
/// TRACES: FR-DEV-3g
|
||||||
|
/// The learned demosaic and denoise working through a photograph.
|
||||||
|
Denoise,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Kind {
|
impl Kind {
|
||||||
@@ -111,6 +114,8 @@ impl Kind {
|
|||||||
// background sweep in the same sentence as a download they are
|
// background sweep in the same sentence as a download they are
|
||||||
// waiting on.
|
// waiting on.
|
||||||
Kind::Index => false,
|
Kind::Index => false,
|
||||||
|
// Local work on a file already open.
|
||||||
|
Kind::Denoise => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ impl Decoder for Stub {
|
|||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: "Stubco".into(),
|
make: "Stubco".into(),
|
||||||
model: "Stubco One".into(),
|
model: "Stubco One".into(),
|
||||||
})
|
})
|
||||||
@@ -198,7 +199,7 @@ fn the_catalog_scan_reads_headers_through_the_trait() {
|
|||||||
&mut found,
|
&mut found,
|
||||||
));
|
));
|
||||||
|
|
||||||
assert!(reached);
|
assert!(matches!(reached, crate::library::DateRead::Reached));
|
||||||
assert_eq!(found.len(), 1, "the stub's header was read");
|
assert_eq!(found.len(), 1, "the stub's header was read");
|
||||||
assert_eq!(found[0].image_id, 7);
|
assert_eq!(found[0].image_id, 7);
|
||||||
assert_eq!(found[0].camera.as_deref(), Some("Stubco One"));
|
assert_eq!(found[0].camera.as_deref(), Some("Stubco One"));
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ pub struct SyncReport {
|
|||||||
/// is a fact only the other device knew.
|
/// is a fact only the other device knew.
|
||||||
pub place_adopted: bool,
|
pub place_adopted: bool,
|
||||||
pub place_uploaded: bool,
|
pub place_uploaded: bool,
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// Camera profiles exchanged with the library's `profiles` folder
|
||||||
|
/// (camera-profiles.md §13).
|
||||||
|
pub profiles_uploaded: usize,
|
||||||
|
pub profiles_downloaded: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SyncReport {
|
impl SyncReport {
|
||||||
@@ -103,6 +108,7 @@ impl SyncReport {
|
|||||||
|| self.face_shards_uploaded > 0
|
|| self.face_shards_uploaded > 0
|
||||||
|| self.face_shards_downloaded > 0
|
|| self.face_shards_downloaded > 0
|
||||||
|| self.place_adopted
|
|| self.place_adopted
|
||||||
|
|| self.profiles_downloaded > 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +230,16 @@ async fn run(
|
|||||||
let _ = tx.send(SyncMessage::Status("checking thumbnails…".into()));
|
let _ = tx.send(SyncMessage::Status("checking thumbnails…".into()));
|
||||||
sync_shards(backend, &base, thumbs_dir, scratch, &mut report).await?;
|
sync_shards(backend, &base, thumbs_dir, scratch, &mut report).await?;
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-3e
|
||||||
|
// The camera profiles, so a profile copied out of a DNG on one device
|
||||||
|
// renders that body's raws on every device (camera-profiles.md §13).
|
||||||
|
// After the catalog and before the place; like the place it never fails
|
||||||
|
// the pass.
|
||||||
|
if let Some(dir) = dr_decode::dcp::profiles_directory() {
|
||||||
|
let _ = tx.send(SyncMessage::Status("checking camera profiles…".into()));
|
||||||
|
sync_profiles(backend, &base, &dir, &mut report).await;
|
||||||
|
}
|
||||||
|
|
||||||
// TRACES: FR-UI-8
|
// TRACES: FR-UI-8
|
||||||
// Last, and it costs one small GET plus at most one small PUT. Last because
|
// Last, and it costs one small GET plus at most one small PUT. Last because
|
||||||
// it is the only thing here that is not derived state and so the only thing
|
// it is the only thing here that is not derived state and so the only thing
|
||||||
@@ -995,6 +1011,117 @@ async fn sync_place(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3e
|
||||||
|
/// Exchange camera profiles with `<derived>/profiles` (camera-profiles.md
|
||||||
|
/// §13).
|
||||||
|
///
|
||||||
|
/// Profiles are immutable and named for what they hold (`<camera>
|
||||||
|
/// <profile>.dcp`, `dcp::save`), so a name and a size say whether two copies
|
||||||
|
/// are the same: upload what the server lacks or holds at another size,
|
||||||
|
/// download what this device lacks. A download is parsed before it is kept,
|
||||||
|
/// and written beside its name then renamed, because the profile loader
|
||||||
|
/// reads every `.dcp` in the folder and a half-written one would be skipped
|
||||||
|
/// with a warning rather than retried. Never fails the pass: a profile that
|
||||||
|
/// did not travel this time travels next time.
|
||||||
|
async fn sync_profiles(
|
||||||
|
backend: &dyn RemoteBackend,
|
||||||
|
base: &RemotePath,
|
||||||
|
local_dir: &Path,
|
||||||
|
report: &mut SyncReport,
|
||||||
|
) {
|
||||||
|
let dir = RemotePath::new(format!("{}/profiles", base.as_str()));
|
||||||
|
let is_profile = |name: &str| {
|
||||||
|
Path::new(name)
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|x| x.eq_ignore_ascii_case("dcp"))
|
||||||
|
};
|
||||||
|
|
||||||
|
let remote: std::collections::HashMap<String, u64> = backend
|
||||||
|
.list(&dir, None)
|
||||||
|
.await
|
||||||
|
.map(|entries| {
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.filter(|e| e.kind == dr_sync::EntryKind::File && is_profile(e.path.name()))
|
||||||
|
.map(|e| (e.path.name().to_string(), e.size))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
// Absent until the first device uploads one; an empty answer.
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let local: std::collections::HashMap<String, PathBuf> = std::fs::read_dir(local_dir)
|
||||||
|
.map(|entries| {
|
||||||
|
entries
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|e| {
|
||||||
|
let name = e.file_name().to_string_lossy().into_owned();
|
||||||
|
is_profile(&name).then(|| (name, e.path()))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// ---- upload ----------------------------------------------------------
|
||||||
|
let to_upload: Vec<(&String, &PathBuf)> = local
|
||||||
|
.iter()
|
||||||
|
.filter(|(name, path)| {
|
||||||
|
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||||
|
remote.get(*name) != Some(&size)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if !to_upload.is_empty() {
|
||||||
|
let _ = backend.create_dir(&dir).await;
|
||||||
|
}
|
||||||
|
for (name, path) in to_upload {
|
||||||
|
let Ok(bytes) = std::fs::read(path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let target = RemotePath::new(format!("{}/{name}", dir.as_str()));
|
||||||
|
match backend.put(&target, bytes, None).await {
|
||||||
|
Ok(_) => report.profiles_uploaded += 1,
|
||||||
|
Err(e) => log::debug!("uploading camera profile {name}: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- download --------------------------------------------------------
|
||||||
|
for name in remote.keys().filter(|n| !local.contains_key(*n)) {
|
||||||
|
let source = RemotePath::new(format!("{}/{name}", dir.as_str()));
|
||||||
|
let bytes = match read_derived(backend, &source).await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("fetching camera profile {name}: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(why) = dr_decode::dcp::Dcp::parse(&bytes) {
|
||||||
|
log::warn!("camera profile {name} on the server is not one: {why}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = local_dir.join(name);
|
||||||
|
let partial = local_dir.join(format!("{name}.part"));
|
||||||
|
let written = std::fs::create_dir_all(local_dir)
|
||||||
|
.and_then(|()| std::fs::write(&partial, &bytes))
|
||||||
|
.and_then(|()| std::fs::rename(&partial, &path));
|
||||||
|
match written {
|
||||||
|
Ok(()) => report.profiles_downloaded += 1,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = std::fs::remove_file(&partial);
|
||||||
|
log::warn!("saving camera profile {name}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if report.profiles_downloaded > 0 {
|
||||||
|
log::info!(
|
||||||
|
"camera profiles: {} fetched from the library",
|
||||||
|
report.profiles_downloaded
|
||||||
|
);
|
||||||
|
// Decodes from now on see them; one already in flight keeps the set
|
||||||
|
// it started with.
|
||||||
|
dr_decode::dcp::set_profiles_directory(local_dir.to_path_buf());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-UI-8
|
/// TRACES: FR-UI-8
|
||||||
/// Fetch just the place, for the handover at launch.
|
/// Fetch just the place, for the handover at launch.
|
||||||
///
|
///
|
||||||
@@ -1734,6 +1861,79 @@ mod derived_guard_tests {
|
|||||||
(puts.load(Ordering::SeqCst), report, after, sent)
|
(puts.load(Ordering::SeqCst), report, after, sent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A small valid profile, as bytes, named for `model`.
|
||||||
|
fn profile_bytes(model: &str) -> Vec<u8> {
|
||||||
|
dr_decode::dcp::Dcp {
|
||||||
|
name: "Test Standard".into(),
|
||||||
|
unique_camera_model: Some(model.into()),
|
||||||
|
copyright: None,
|
||||||
|
calibration_signature: None,
|
||||||
|
embed_policy: 0,
|
||||||
|
illuminants: [Some(21), None],
|
||||||
|
color_matrix: [
|
||||||
|
Some([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]),
|
||||||
|
None,
|
||||||
|
],
|
||||||
|
forward_matrix: [None, None],
|
||||||
|
hue_sat: [None, None],
|
||||||
|
look: dr_types::HueSatTable::new(2, 2, 1, false, vec![[5.0, 1.1, 1.0]; 4]),
|
||||||
|
tone_curve: None,
|
||||||
|
baseline_exposure_offset: 0.0,
|
||||||
|
}
|
||||||
|
.to_bytes()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn camera_profiles_travel_both_ways_and_once() {
|
||||||
|
// TRACES: FR-DEV-3e
|
||||||
|
// camera-profiles.md §13: ours goes up, theirs comes down, and a
|
||||||
|
// second pass with nothing new moves nothing.
|
||||||
|
let root = std::env::temp_dir().join(format!("dr-profile-sync-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
let server = root.join("server");
|
||||||
|
let device = root.join("device");
|
||||||
|
std::fs::create_dir_all(server.join(".darkroom-derived/profiles")).unwrap();
|
||||||
|
std::fs::create_dir_all(&device).unwrap();
|
||||||
|
std::fs::write(device.join("Ours A.dcp"), profile_bytes("Ours A")).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
server.join(".darkroom-derived/profiles/Theirs B.dcp"),
|
||||||
|
profile_bytes("Theirs B"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Not a profile, and must be left alone in both directions.
|
||||||
|
std::fs::write(server.join(".darkroom-derived/profiles/notes.txt"), b"x").unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
server.join(".darkroom-derived/profiles/Broken C.dcp"),
|
||||||
|
b"not a profile",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let backend = dr_sync_folder::FolderBackend::new(&server).unwrap();
|
||||||
|
let base = RemotePath::new(".darkroom-derived");
|
||||||
|
let mut report = SyncReport::default();
|
||||||
|
sync_profiles(&backend, &base, &device, &mut report).await;
|
||||||
|
|
||||||
|
assert_eq!(report.profiles_uploaded, 1);
|
||||||
|
assert_eq!(report.profiles_downloaded, 1, "the broken one is refused");
|
||||||
|
assert!(server
|
||||||
|
.join(".darkroom-derived/profiles/Ours A.dcp")
|
||||||
|
.exists());
|
||||||
|
assert!(device.join("Theirs B.dcp").exists());
|
||||||
|
assert!(!device.join("Broken C.dcp").exists());
|
||||||
|
assert!(!device.join("notes.txt").exists());
|
||||||
|
assert!(
|
||||||
|
dr_decode::dcp::find(Some("Theirs B"), "", "").is_some(),
|
||||||
|
"a fetched profile is matched on the next decode"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut again = SyncReport::default();
|
||||||
|
sync_profiles(&backend, &base, &device, &mut again).await;
|
||||||
|
assert_eq!((again.profiles_uploaded, again.profiles_downloaded), (0, 0));
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_place_that_could_not_be_read_is_never_written_over() {
|
async fn a_place_that_could_not_be_read_is_never_written_over() {
|
||||||
// A dehydrated placeholder, and the record on the server may well be
|
// A dehydrated placeholder, and the record on the server may well be
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
//! 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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(),
|
||||||
|
};
|
||||||
|
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)),
|
||||||
|
};
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Work {
|
||||||
|
fn run(self, progress: &mut dyn FnMut(usize, usize)) -> Result<Finished, String> {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
// 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")?;
|
||||||
|
Ok(Finished {
|
||||||
|
rgb,
|
||||||
|
width: raw.crop.width,
|
||||||
|
height: raw.crop.height,
|
||||||
|
source: noise.source,
|
||||||
|
rung,
|
||||||
|
seconds: started.elapsed().as_secs_f64(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
|
||||||
|
// Off: the classical demosaic, result or no result.
|
||||||
|
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.
|
||||||
|
s.graph
|
||||||
|
.set_param(learned_denoise::ID, learned_denoise::GRAIN, 40.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::GRAIN, 60.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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
//! only methods were ever exported.
|
//! only methods were ever exported.
|
||||||
|
|
||||||
mod curves;
|
mod curves;
|
||||||
|
mod denoise;
|
||||||
mod framing;
|
mod framing;
|
||||||
mod history;
|
mod history;
|
||||||
mod mask_ops;
|
mod mask_ops;
|
||||||
@@ -29,6 +30,7 @@ mod session;
|
|||||||
mod tabs;
|
mod tabs;
|
||||||
mod white_balance;
|
mod white_balance;
|
||||||
|
|
||||||
|
pub use denoise::DenoiseStatus;
|
||||||
pub use framing::CropAspect;
|
pub use framing::CropAspect;
|
||||||
pub use masks::MASK_COLOURS;
|
pub use masks::MASK_COLOURS;
|
||||||
pub use segmentation::{Abandon, RefinedInstance, Segmented, SessionId};
|
pub use segmentation::{Abandon, RefinedInstance, Segmented, SessionId};
|
||||||
|
|||||||
@@ -235,7 +235,10 @@ impl DevelopSession {
|
|||||||
reach: u32,
|
reach: u32,
|
||||||
) -> Result<std::sync::Arc<dr_gpu::DemosaicedImage>, String> {
|
) -> Result<std::sync::Arc<dr_gpu::DemosaicedImage>, String> {
|
||||||
let Some(full) = self.full.clone() else {
|
let Some(full) = self.full.clone() else {
|
||||||
return Ok(self.demosaiced.clone());
|
// TRACES: FR-DEV-3g
|
||||||
|
// The learned demosaic, with its grain, where the edit asks for
|
||||||
|
// it and it has landed; the classical one otherwise.
|
||||||
|
return Ok(self.developed_source());
|
||||||
};
|
};
|
||||||
let frame = self.demosaiced.size();
|
let frame = self.demosaiced.size();
|
||||||
let ratio = self.graph.render_scale(frame, (w, h)).ratio();
|
let ratio = self.graph.render_scale(frame, (w, h)).ratio();
|
||||||
@@ -706,6 +709,10 @@ impl DevelopSession {
|
|||||||
&mut self,
|
&mut self,
|
||||||
space: dr_types::ColourSpace,
|
space: dr_types::ColourSpace,
|
||||||
) -> Result<dr_export::Frame, String> {
|
) -> Result<dr_export::Frame, String> {
|
||||||
|
// TRACES: FR-DEV-3g
|
||||||
|
// A photograph that asks for the learned demosaic is exported with
|
||||||
|
// it, computed now if it is not here (denoise.md §7.1).
|
||||||
|
self.denoise_blocking()?;
|
||||||
let (sw, sh) = self.demosaiced.size();
|
let (sw, sh) = self.demosaiced.size();
|
||||||
let (w, h) = self.graph.output_size(sw, sh);
|
let (w, h) = self.graph.output_size(sw, sh);
|
||||||
|
|
||||||
@@ -1617,6 +1624,7 @@ mod tests {
|
|||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: dr_decode::CropRect {
|
crop: dr_decode::CropRect {
|
||||||
@@ -1871,6 +1879,7 @@ mod tests {
|
|||||||
samples_per_pixel: 3,
|
samples_per_pixel: 3,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: dr_decode::CropRect {
|
crop: dr_decode::CropRect {
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ pub struct DevelopSession {
|
|||||||
/// the info panel offers to save for every photograph from that camera
|
/// the info panel offers to save for every photograph from that camera
|
||||||
/// (D20). `None` once taken up.
|
/// (D20). `None` once taken up.
|
||||||
pub(super) profile_offer: Option<Arc<dr_decode::dcp::Dcp>>,
|
pub(super) profile_offer: Option<Arc<dr_decode::dcp::Dcp>>,
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// The edit the file already carries from the photographer's earlier work, translated, waiting for the
|
||||||
|
/// word that this photograph has no edit of DarkRoom's own
|
||||||
|
/// ([`Self::adopt_earlier_edit`]).
|
||||||
|
pub(super) earlier_edit: Option<Preset>,
|
||||||
/// Kept so the session can build GPU resources after construction.
|
/// Kept so the session can build GPU resources after construction.
|
||||||
///
|
///
|
||||||
/// The distance fields behind a subject mask are made when a layer is
|
/// The distance fields behind a subject mask are made when a layer is
|
||||||
@@ -118,6 +123,10 @@ pub struct DevelopSession {
|
|||||||
/// history still stands on that crop. See [`super::framing::CropNotice`].
|
/// history still stands on that crop. See [`super::framing::CropNotice`].
|
||||||
pub(super) crop_notice: Option<super::framing::CropNotice>,
|
pub(super) crop_notice: Option<super::framing::CropNotice>,
|
||||||
pub(super) demosaiced: Arc<DemosaicedImage>,
|
pub(super) demosaiced: Arc<DemosaicedImage>,
|
||||||
|
/// TRACES: FR-DEV-3g
|
||||||
|
/// The learned demosaic: the kept mosaic, the job, the result. See
|
||||||
|
/// [`super::denoise`].
|
||||||
|
pub(super) denoise: super::denoise::DenoiseState,
|
||||||
/// TRACES: FR-DSP-2 | NFR-RES-2
|
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||||||
/// The photograph at full resolution, when it is too large to hold in one
|
/// The photograph at full resolution, when it is too large to hold in one
|
||||||
/// texture. `demosaiced` is then a reduced copy of it, which is all the
|
/// texture. `demosaiced` is then a reduced copy of it, which is all the
|
||||||
@@ -376,7 +385,12 @@ impl DevelopSession {
|
|||||||
let (w, h) = (raw.crop.width.max(1), raw.crop.height.max(1));
|
let (w, h) = (raw.crop.width.max(1), raw.crop.height.max(1));
|
||||||
let edge = PROXY_EDGE.min(DemosaicedImage::max_dimension(ctx));
|
let edge = PROXY_EDGE.min(DemosaicedImage::max_dimension(ctx));
|
||||||
if raw.samples_per_pixel != 3 || w.max(h) <= edge {
|
if raw.samples_per_pixel != 3 || w.max(h) <= edge {
|
||||||
return Self::open(ctx, &raw, orientation);
|
let mut session = Self::open(ctx, &raw, orientation)?;
|
||||||
|
// TRACES: FR-DEV-3g
|
||||||
|
// The mosaic stays with the session when the learned demosaic
|
||||||
|
// could take it, so asking for it later needs no second decode.
|
||||||
|
session.keep_mosaic(raw);
|
||||||
|
return Ok(session);
|
||||||
}
|
}
|
||||||
let reduce = w.max(h).div_ceil(edge);
|
let reduce = w.max(h).div_ceil(edge);
|
||||||
log::info!("{w}×{h} is larger than one texture; developing from a 1/{reduce} copy");
|
log::info!("{w}×{h} is larger than one texture; developing from a 1/{reduce} copy");
|
||||||
@@ -430,6 +444,7 @@ impl DevelopSession {
|
|||||||
// and not found" — `lens_summary` distinguishes them.
|
// and not found" — `lens_summary` distinguishes them.
|
||||||
lens_profile_found: false,
|
lens_profile_found: false,
|
||||||
profile_offer: None,
|
profile_offer: None,
|
||||||
|
earlier_edit: None,
|
||||||
ctx: ctx.clone(),
|
ctx: ctx.clone(),
|
||||||
graph,
|
graph,
|
||||||
history,
|
history,
|
||||||
@@ -438,6 +453,7 @@ impl DevelopSession {
|
|||||||
compared_snapshot: None,
|
compared_snapshot: None,
|
||||||
crop_notice: None,
|
crop_notice: None,
|
||||||
demosaiced: Arc::new(demosaiced),
|
demosaiced: Arc::new(demosaiced),
|
||||||
|
denoise: Default::default(),
|
||||||
full: None,
|
full: None,
|
||||||
window: None,
|
window: None,
|
||||||
adjust: AdjustPass::new(ctx),
|
adjust: AdjustPass::new(ctx),
|
||||||
@@ -600,6 +616,33 @@ impl DevelopSession {
|
|||||||
.map(Arc::new);
|
.map(Arc::new);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// Remember the earlier edit stored in the file this session opened.
|
||||||
|
pub fn set_earlier_edit(&mut self, edit: Option<Preset>) {
|
||||||
|
self.earlier_edit = edit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// Apply the file's earlier edit, as the photograph's starting point, so it
|
||||||
|
/// keeps the style it was given before it came to DarkRoom.
|
||||||
|
///
|
||||||
|
/// Only for a photograph DarkRoom has no edit of: the caller says so, and
|
||||||
|
/// must know it rather than guess — a stored edit that could not be
|
||||||
|
/// fetched is not an absent one, and the earlier edit would then be
|
||||||
|
/// saved over it on the way out. One undoable step, so undo shows the
|
||||||
|
/// photograph without it; from there it is an ordinary edit, saved with
|
||||||
|
/// the photograph. Taken, so a second call does nothing.
|
||||||
|
pub fn adopt_earlier_edit(&mut self) -> bool {
|
||||||
|
let Some(edit) = self.earlier_edit.take() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let rebake = edit.apply(&mut self.graph, Scope::adjustments());
|
||||||
|
self.pay_film_debt(&rebake);
|
||||||
|
self.history
|
||||||
|
.record(&self.graph, Edit::Action(labels::step::EARLIER_EDIT));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3e
|
/// TRACES: FR-DEV-3e
|
||||||
/// What to tell the photographer about the camera profile (D20).
|
/// What to tell the photographer about the camera profile (D20).
|
||||||
///
|
///
|
||||||
@@ -822,6 +865,7 @@ mod tests {
|
|||||||
samples_per_pixel: 1,
|
samples_per_pixel: 1,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: tables.map(Arc::new),
|
profile_tables: tables.map(Arc::new),
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: "Canon".into(),
|
make: "Canon".into(),
|
||||||
model: "EOS 6D".into(),
|
model: "EOS 6D".into(),
|
||||||
crop: dr_decode::CropRect {
|
crop: dr_decode::CropRect {
|
||||||
@@ -842,6 +886,7 @@ mod tests {
|
|||||||
origin,
|
origin,
|
||||||
hue_sat: None,
|
hue_sat: None,
|
||||||
look: dr_types::HueSatTable::new(2, 2, 1, false, vec![[5.0, 1.1, 1.0]; 4]),
|
look: dr_types::HueSatTable::new(2, 2, 1, false, vec![[5.0, 1.1, 1.0]; 4]),
|
||||||
|
tone_curve: None,
|
||||||
};
|
};
|
||||||
let open = |t| {
|
let open = |t| {
|
||||||
DevelopSession::open(&ctx, &raw_with(t), dr_types::Orientation::NORMAL)
|
DevelopSession::open(&ctx, &raw_with(t), dr_types::Orientation::NORMAL)
|
||||||
@@ -876,6 +921,34 @@ mod tests {
|
|||||||
assert_eq!(jpeg.profile_summary(), "");
|
assert_eq!(jpeg.profile_summary(), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_earlier_edit_is_adopted_once_and_can_be_undone() {
|
||||||
|
// TRACES: FR-DEV-6
|
||||||
|
let Some(ctx) = headless() else { return };
|
||||||
|
let mut s = DevelopSession::open(&ctx, &raw_with(None), dr_types::Orientation::NORMAL)
|
||||||
|
.expect("session");
|
||||||
|
assert!(!s.adopt_earlier_edit(), "nothing to adopt yet");
|
||||||
|
|
||||||
|
let earlier = dr_preset_xmp::read_xmp(
|
||||||
|
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:SaturationAdjustmentBlue="+58" crs:Highlights2012="-40"/></rdf:RDF></x:xmpmeta>"#,
|
||||||
|
)
|
||||||
|
.expect("an edit")
|
||||||
|
.preset;
|
||||||
|
s.set_earlier_edit(Some(earlier));
|
||||||
|
assert!(s.adopt_earlier_edit());
|
||||||
|
let blue = || {
|
||||||
|
s.graph.param(
|
||||||
|
dr_pipeline::ops::colour_mixer::ID,
|
||||||
|
dr_pipeline::ParamId("blue_sat"),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
assert_eq!(blue(), Some(58.0));
|
||||||
|
assert!(!s.adopt_earlier_edit(), "taken: a second call does nothing");
|
||||||
|
assert!(!s.graph.is_neutral());
|
||||||
|
s.undo();
|
||||||
|
assert!(s.graph.is_neutral(), "undo shows the photograph without it");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_a_copyable_profile_is_offered() {
|
fn only_a_copyable_profile_is_offered() {
|
||||||
// TRACES: FR-DEV-3e
|
// TRACES: FR-DEV-3e
|
||||||
@@ -897,6 +970,7 @@ mod tests {
|
|||||||
hue_sat: [None, None],
|
hue_sat: [None, None],
|
||||||
look: dr_types::HueSatTable::new(2, 2, 1, false, vec![[5.0, 1.1, 1.0]; 4]),
|
look: dr_types::HueSatTable::new(2, 2, 1, false, vec![[5.0, 1.1, 1.0]; 4]),
|
||||||
tone_curve: None,
|
tone_curve: None,
|
||||||
|
baseline_exposure_offset: 0.0,
|
||||||
};
|
};
|
||||||
s.set_embedded_profile(Some(profile(0)));
|
s.set_embedded_profile(Some(profile(0)));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ mod tests {
|
|||||||
samples_per_pixel: 3,
|
samples_per_pixel: 3,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: dr_decode::CropRect {
|
crop: dr_decode::CropRect {
|
||||||
@@ -307,6 +308,7 @@ mod tests {
|
|||||||
samples_per_pixel: 3,
|
samples_per_pixel: 3,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: dr_decode::CropRect {
|
crop: dr_decode::CropRect {
|
||||||
@@ -360,6 +362,7 @@ mod tests {
|
|||||||
samples_per_pixel: 3,
|
samples_per_pixel: 3,
|
||||||
profile: None,
|
profile: None,
|
||||||
profile_tables: None,
|
profile_tables: None,
|
||||||
|
baseline_exposure: 0.0,
|
||||||
make: String::new(),
|
make: String::new(),
|
||||||
model: String::new(),
|
model: String::new(),
|
||||||
crop: dr_decode::CropRect {
|
crop: dr_decode::CropRect {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use slint::ComponentHandle as _;
|
|||||||
use crate::activity;
|
use crate::activity;
|
||||||
use crate::collections_ui::CollectionsController;
|
use crate::collections_ui::CollectionsController;
|
||||||
use crate::develop;
|
use crate::develop;
|
||||||
|
use crate::develop::DenoiseStatus;
|
||||||
use crate::library_ui::LibraryController;
|
use crate::library_ui::LibraryController;
|
||||||
use crate::masks_ui;
|
use crate::masks_ui;
|
||||||
use crate::peaking;
|
use crate::peaking;
|
||||||
@@ -132,6 +133,85 @@ pub(crate) fn wire(
|
|||||||
wire_rotation_flips_straighten(window, &w, &crop_aspect, &crop_portrait);
|
wire_rotation_flips_straighten(window, &w, &crop_aspect, &crop_portrait);
|
||||||
wire_navigation(window, &w);
|
wire_navigation(window, &w);
|
||||||
wire_peaking(window, &w);
|
wire_peaking(window, &w);
|
||||||
|
wire_learned_denoise(window, &w);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3g
|
||||||
|
/// Keep the learned denoise in step with the edit, and show it working.
|
||||||
|
///
|
||||||
|
/// A poll rather than a hook on each way an edit can change — a slider,
|
||||||
|
/// undo, a preset, a version opened, a sidecar merged from another device:
|
||||||
|
/// [`DevelopSession::reconcile_denoise`] costs two comparisons when nothing
|
||||||
|
/// changed, and a hook that one of those paths forgot would leave a
|
||||||
|
/// photograph that asks for the learned demosaic showing the classical one.
|
||||||
|
fn wire_learned_denoise(window: &AppWindow, w: &DevelopWiring) {
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let session = w.session.clone();
|
||||||
|
let redraw = w.redraw.clone();
|
||||||
|
let rows = w.rows.clone();
|
||||||
|
let activity = w.activity.clone();
|
||||||
|
let row: Rc<RefCell<Option<activity::Activity>>> = Rc::new(RefCell::new(None));
|
||||||
|
let timer = slint::Timer::default();
|
||||||
|
timer.start(
|
||||||
|
slint::TimerMode::Repeated,
|
||||||
|
std::time::Duration::from_millis(250),
|
||||||
|
move || {
|
||||||
|
let Some(win) = weak.upgrade() else { return };
|
||||||
|
let status = {
|
||||||
|
let Ok(mut guard) = session.try_borrow_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(s) = guard.as_mut() else {
|
||||||
|
// The photograph closed: whatever was running went with it.
|
||||||
|
if let Some(r) = row.borrow_mut().take() {
|
||||||
|
r.finish_quietly();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
s.reconcile_denoise();
|
||||||
|
s.poll_denoise()
|
||||||
|
};
|
||||||
|
match status {
|
||||||
|
DenoiseStatus::Idle => {
|
||||||
|
// Stopped without landing: switched off, or a new photograph.
|
||||||
|
if let Some(r) = row.borrow_mut().take() {
|
||||||
|
r.finish_quietly();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DenoiseStatus::Running(done, total) => {
|
||||||
|
let mut r = row.borrow_mut();
|
||||||
|
let r = r.get_or_insert_with(|| {
|
||||||
|
activity.begin(activity::Kind::Denoise, "AI denoise")
|
||||||
|
});
|
||||||
|
r.progress(done, total);
|
||||||
|
}
|
||||||
|
DenoiseStatus::Landed => {
|
||||||
|
// Kept in the list with where its noise figures came
|
||||||
|
// from: measured for the body, the DNG's, or estimated.
|
||||||
|
let source = session
|
||||||
|
.borrow()
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| s.denoise_source())
|
||||||
|
.map(|s| format!("noise {}", s.label()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let r = row.borrow_mut().take();
|
||||||
|
let r =
|
||||||
|
r.unwrap_or_else(|| activity.begin(activity::Kind::Denoise, "AI denoise"));
|
||||||
|
r.finish(source);
|
||||||
|
sync_rows(&win, &rows, &session);
|
||||||
|
redraw(&win);
|
||||||
|
}
|
||||||
|
DenoiseStatus::Failed(e) => {
|
||||||
|
let r = row.borrow_mut().take();
|
||||||
|
let r =
|
||||||
|
r.unwrap_or_else(|| activity.begin(activity::Kind::Denoise, "AI denoise"));
|
||||||
|
r.fail(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// For the life of the window, like the screen it serves.
|
||||||
|
std::mem::forget(timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ---- copying settings between photographs (FR-DEV-6) ----------------
|
/// ---- copying settings between photographs (FR-DEV-6) ----------------
|
||||||
|
|||||||
+10
-5
@@ -1293,15 +1293,15 @@ fn render_from_library(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let sidecar = match wait_for(&sidecar_rx, cancel) {
|
let fetched = match wait_for(&sidecar_rx, cancel) {
|
||||||
Waited::Got(sidecar) => sidecar,
|
Waited::Got(fetched) => fetched,
|
||||||
Waited::Cancelled => return None,
|
Waited::Cancelled => return None,
|
||||||
// An unedited photograph has no sidecar and a fetch that died looks
|
// A fetch that died. Exporting at defaults is what opening it would
|
||||||
// identical from here. Exporting at defaults is what opening it would
|
|
||||||
// do, and refusing the image over a missing edit it may never have had
|
// do, and refusing the image over a missing edit it may never have had
|
||||||
// would fail the common case.
|
// would fail the common case.
|
||||||
Waited::Silent => None,
|
Waited::Silent => crate::library::FetchedSidecar::default(),
|
||||||
};
|
};
|
||||||
|
let sidecar = fetched.sidecar;
|
||||||
|
|
||||||
let (meta, mut session) = match open_for_export(gpu, request.decoder, &bytes) {
|
let (meta, mut session) = match open_for_export(gpu, request.decoder, &bytes) {
|
||||||
Ok(opened) => opened,
|
Ok(opened) => opened,
|
||||||
@@ -1318,6 +1318,11 @@ fn render_from_library(
|
|||||||
.and_then(dr_pipeline::Sidecar::default_version)
|
.and_then(dr_pipeline::Sidecar::default_version)
|
||||||
{
|
{
|
||||||
session.apply_version(version);
|
session.apply_version(version);
|
||||||
|
} else if fetched.absent {
|
||||||
|
// TRACES: FR-DEV-6
|
||||||
|
// No edit of DarkRoom's, by the server's word: export what opening it
|
||||||
|
// would show — the photograph's earlier edit, where it carries one.
|
||||||
|
session.adopt_earlier_edit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The last cheap place to stop. Everything past here is a full-resolution
|
// The last cheap place to stop. Everything past here is a full-resolution
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ pub fn init(runtime_dirs: Vec<PathBuf>) {
|
|||||||
(Role::EyeClassifier, crate::library::EYE_MODEL),
|
(Role::EyeClassifier, crate::library::EYE_MODEL),
|
||||||
(Role::EyeClassifier, crate::library::SUNGLASSES_MODEL),
|
(Role::EyeClassifier, crate::library::SUNGLASSES_MODEL),
|
||||||
(Role::Inpainter, crate::library::INPAINT_MODEL),
|
(Role::Inpainter, crate::library::INPAINT_MODEL),
|
||||||
|
(Role::Denoiser, crate::library::DENOISE_MODEL),
|
||||||
]);
|
]);
|
||||||
let models: Vec<(Role, PathBuf)> = wanted
|
let models: Vec<(Role, PathBuf)> = wanted
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
+16
-4
@@ -27,6 +27,9 @@ pub mod step {
|
|||||||
use dr_pipeline::LocalizedKey;
|
use dr_pipeline::LocalizedKey;
|
||||||
|
|
||||||
pub const PASTE: LocalizedKey = LocalizedKey("history.paste");
|
pub const PASTE: LocalizedKey = LocalizedKey("history.paste");
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// The edit a photograph already carried, brought across on first opening.
|
||||||
|
pub const EARLIER_EDIT: LocalizedKey = LocalizedKey("history.earlier_edit");
|
||||||
pub const FILM: LocalizedKey = LocalizedKey("history.film");
|
pub const FILM: LocalizedKey = LocalizedKey("history.film");
|
||||||
/// TRACES: FR-DEV-5
|
/// TRACES: FR-DEV-5
|
||||||
/// The photograph put back to a named snapshot, as one step.
|
/// The photograph put back to a named snapshot, as one step.
|
||||||
@@ -100,6 +103,7 @@ pub mod step {
|
|||||||
dr_pipeline::history::OPENED,
|
dr_pipeline::history::OPENED,
|
||||||
dr_pipeline::history::UNNAMED,
|
dr_pipeline::history::UNNAMED,
|
||||||
PASTE,
|
PASTE,
|
||||||
|
EARLIER_EDIT,
|
||||||
SNAPSHOT_RESTORED,
|
SNAPSHOT_RESTORED,
|
||||||
FILM,
|
FILM,
|
||||||
SAMPLED_NEUTRAL,
|
SAMPLED_NEUTRAL,
|
||||||
@@ -203,14 +207,14 @@ fn catalogued(key: &str) -> Option<&'static str> {
|
|||||||
// it is a tick box, and a checkbox labelled with a verb reads as a
|
// it is a tick box, and a checkbox labelled with a verb reads as a
|
||||||
// button that does something once rather than a state that is on.
|
// button that does something once rather than a state that is on.
|
||||||
"op.lens_profile" => "Lens Profile",
|
"op.lens_profile" => "Lens Profile",
|
||||||
|
"op.learned_denoise" => "AI Denoise",
|
||||||
// The view transform (FR-DEV-3j). "Tone Mapping" rather than the
|
// The view transform (FR-DEV-3j). "Tone Mapping" rather than the
|
||||||
// "View Transform" `derive` would give: the id names where it sits in
|
// "View Transform" `derive` would give: the id names where it sits in
|
||||||
// the pipeline, and the photographer is choosing how the scene's range
|
// the pipeline, and the photographer is choosing how the scene's range
|
||||||
// is fitted onto the screen.
|
// is fitted onto the screen.
|
||||||
"op.view_transform" => "Tone Mapping",
|
"op.view_transform" => "Tone Mapping",
|
||||||
// The DNG camera profile's tables (D20). "Camera Profile", as Camera
|
// The DNG camera profile's tables (D20). "Camera Profile", the DNG
|
||||||
// Raw calls the same thing, so a photographer arriving from Lightroom
|
// specification's own name for what the file carries.
|
||||||
// finds it under the name they know.
|
|
||||||
"op.camera_profile" => "Camera Profile",
|
"op.camera_profile" => "Camera Profile",
|
||||||
|
|
||||||
// Parameters
|
// Parameters
|
||||||
@@ -218,12 +222,19 @@ fn catalogued(key: &str) -> Option<&'static str> {
|
|||||||
// parameter is titled by its operation and this one never reaches the
|
// parameter is titled by its operation and this one never reaches the
|
||||||
// panel under its own name — see `rows_filtered`.
|
// panel under its own name — see `rows_filtered`.
|
||||||
"param.lens_profile.apply" => "Apply",
|
"param.lens_profile.apply" => "Apply",
|
||||||
|
"param.learned_denoise.apply" => "Apply",
|
||||||
|
"param.learned_denoise.grain" => "Keep grain",
|
||||||
"param.camera_profile.apply" => "Use Profile",
|
"param.camera_profile.apply" => "Use Profile",
|
||||||
// The LookTable's strength, as Lightroom's profile "Amount".
|
// The LookTable's strength.
|
||||||
"param.camera_profile.look" => "Look Amount",
|
"param.camera_profile.look" => "Look Amount",
|
||||||
"param.view_transform.contrast" => "Contrast",
|
"param.view_transform.contrast" => "Contrast",
|
||||||
// In stops above middle grey: where the scene reaches display white.
|
// In stops above middle grey: where the scene reaches display white.
|
||||||
"param.view_transform.white" => "White Point",
|
"param.view_transform.white" => "White Point",
|
||||||
|
// The view transform's curve (D21): the DNG SDK's reference
|
||||||
|
// rendering, or D19's sigmoid with its highlight shoulder.
|
||||||
|
"param.view_transform.curve" => "Curve",
|
||||||
|
"param.view_transform.curve.camera_raw" => "DNG Reference",
|
||||||
|
"param.view_transform.curve.sigmoid" => "Sigmoid",
|
||||||
"param.temperature" => "Temperature",
|
"param.temperature" => "Temperature",
|
||||||
"param.tint" => "Tint",
|
"param.tint" => "Tint",
|
||||||
"param.exposure" => "Exposure",
|
"param.exposure" => "Exposure",
|
||||||
@@ -314,6 +325,7 @@ fn catalogued(key: &str) -> Option<&'static str> {
|
|||||||
"history.opened" => "Opened",
|
"history.opened" => "Opened",
|
||||||
"history.edit" => "Edit",
|
"history.edit" => "Edit",
|
||||||
"history.paste" => "Paste Settings",
|
"history.paste" => "Paste Settings",
|
||||||
|
"history.earlier_edit" => "Earlier Edit",
|
||||||
"history.snapshot_restored" => "Restore A Snapshot",
|
"history.snapshot_restored" => "Restore A Snapshot",
|
||||||
"history.film" => "Film Stock",
|
"history.film" => "Film Stock",
|
||||||
"history.sampled_neutral" => "Sample Neutral",
|
"history.sampled_neutral" => "Sample Neutral",
|
||||||
|
|||||||
+51
-8
@@ -324,6 +324,9 @@ pub(crate) fn open_session(
|
|||||||
// the capture date because of this line; before it, the same photograph
|
// the capture date because of this line; before it, the same photograph
|
||||||
// exported from the grid kept them and exported from develop did not.
|
// exported from the grid kept them and exported from develop did not.
|
||||||
session.set_source_metadata(meta.clone());
|
session.set_source_metadata(meta.clone());
|
||||||
|
// TRACES: FR-DEV-3g
|
||||||
|
// The DNG's measured noise and the ISO, for the learned denoise.
|
||||||
|
session.prepare_denoise(bytes, meta);
|
||||||
// TRACES: FR-DEV-3e
|
// TRACES: FR-DEV-3e
|
||||||
// The embedded camera profile, read again from the header, for the info
|
// The embedded camera profile, read again from the header, for the info
|
||||||
// panel's offer to copy it. Only a DNG carries one; the read is the
|
// panel's offer to copy it. Only a DNG carries one; the read is the
|
||||||
@@ -331,6 +334,10 @@ pub(crate) fn open_session(
|
|||||||
if dr_decode::probe(bytes) != Some(dr_types::Format::Jpeg) {
|
if dr_decode::probe(bytes) != Some(dr_types::Format::Jpeg) {
|
||||||
session.set_embedded_profile(dr_decode::dcp::embedded_in(bytes));
|
session.set_embedded_profile(dr_decode::dcp::embedded_in(bytes));
|
||||||
}
|
}
|
||||||
|
// TRACES: FR-DEV-6
|
||||||
|
// And the earlier edit stored in it, applied by the caller only once it
|
||||||
|
// knows DarkRoom has no edit of this photograph.
|
||||||
|
session.set_earlier_edit(dr_preset_xmp::read_embedded(bytes).map(|i| i.preset));
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -817,7 +824,7 @@ pub(crate) fn refresh_export_label(window: &AppWindow) {
|
|||||||
/// whose session this sidecar must not be applied to.
|
/// whose session this sidecar must not be applied to.
|
||||||
fn apply_when_ready(
|
fn apply_when_ready(
|
||||||
window: &AppWindow,
|
window: &AppWindow,
|
||||||
rx: Rc<std::sync::mpsc::Receiver<Option<dr_pipeline::Sidecar>>>,
|
rx: Rc<std::sync::mpsc::Receiver<library::FetchedSidecar>>,
|
||||||
session: &Rc<RefCell<Option<DevelopSession>>>,
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||||||
rows: &Rc<slint::VecModel<ParamRow>>,
|
rows: &Rc<slint::VecModel<ParamRow>>,
|
||||||
redraw: &Rc<dyn Fn(&AppWindow)>,
|
redraw: &Rc<dyn Fn(&AppWindow)>,
|
||||||
@@ -827,9 +834,7 @@ fn apply_when_ready(
|
|||||||
|
|
||||||
// Already here — the common case.
|
// Already here — the common case.
|
||||||
if let Ok(got) = rx.try_recv() {
|
if let Ok(got) = rx.try_recv() {
|
||||||
if let Some(sidecar) = got {
|
apply_fetched(window, got, session, rows);
|
||||||
presets::apply_stored_edit(window, &sidecar, session, rows);
|
|
||||||
}
|
|
||||||
redraw(window);
|
redraw(window);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -864,10 +869,7 @@ fn apply_when_ready(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
held.stop();
|
held.stop();
|
||||||
let applied = match got {
|
let applied = apply_fetched(&w, got, &session, &rows);
|
||||||
Some(sidecar) => presets::apply_stored_edit(&w, &sidecar, &session, &rows),
|
|
||||||
None => false,
|
|
||||||
};
|
|
||||||
if applied || !drawn.get() {
|
if applied || !drawn.get() {
|
||||||
redraw(&w);
|
redraw(&w);
|
||||||
}
|
}
|
||||||
@@ -875,6 +877,42 @@ fn apply_when_ready(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-8 | FR-DEV-6
|
||||||
|
/// Apply what the stored-edit fetch answered: the edit, or — only where the
|
||||||
|
/// server said there is none — the photograph's earlier edit. An answer
|
||||||
|
/// that could not be had (offline, unreachable, unreadable) applies nothing,
|
||||||
|
/// so a real edit that failed to arrive is never saved over.
|
||||||
|
fn apply_fetched(
|
||||||
|
window: &AppWindow,
|
||||||
|
got: library::FetchedSidecar,
|
||||||
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||||||
|
rows: &Rc<slint::VecModel<ParamRow>>,
|
||||||
|
) -> bool {
|
||||||
|
match got.sidecar {
|
||||||
|
Some(sidecar) => presets::apply_stored_edit(window, &sidecar, session, rows),
|
||||||
|
None if got.absent => adopt_earlier_edit(window, session, rows),
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-6
|
||||||
|
/// Start the open photograph from its earlier edit, if it has one.
|
||||||
|
fn adopt_earlier_edit(
|
||||||
|
window: &AppWindow,
|
||||||
|
session: &Rc<RefCell<Option<DevelopSession>>>,
|
||||||
|
rows: &Rc<slint::VecModel<ParamRow>>,
|
||||||
|
) -> bool {
|
||||||
|
let adopted = session
|
||||||
|
.borrow_mut()
|
||||||
|
.as_mut()
|
||||||
|
.is_some_and(DevelopSession::adopt_earlier_edit);
|
||||||
|
if adopted {
|
||||||
|
log::info!("started from the photograph's earlier edit");
|
||||||
|
sync_rows(window, rows, session);
|
||||||
|
}
|
||||||
|
adopted
|
||||||
|
}
|
||||||
|
|
||||||
/// TRACES: FR-DEV-3f
|
/// TRACES: FR-DEV-3f
|
||||||
/// Push the film choice out to the panel.
|
/// Push the film choice out to the panel.
|
||||||
///
|
///
|
||||||
@@ -2620,6 +2658,11 @@ fn build_show(
|
|||||||
*open_image.borrow_mut() = presets::Stored::Local(path.to_path_buf());
|
*open_image.borrow_mut() = presets::Stored::Local(path.to_path_buf());
|
||||||
if let Some(sidecar) = presets::load_local(path) {
|
if let Some(sidecar) = presets::load_local(path) {
|
||||||
presets::apply_stored_edit(window, &sidecar, &session, &rows);
|
presets::apply_stored_edit(window, &sidecar, &session, &rows);
|
||||||
|
} else {
|
||||||
|
// TRACES: FR-DEV-6
|
||||||
|
// No edit of DarkRoom's beside the file: start
|
||||||
|
// from the earlier edit stored in it.
|
||||||
|
adopt_earlier_edit(window, &session, &rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TRACES: FR-UI-4
|
// TRACES: FR-UI-4
|
||||||
|
|||||||
@@ -1015,6 +1015,7 @@ mod tests {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
rgba,
|
rgba,
|
||||||
|
class: dr_thumbs::ThumbSize::Grid,
|
||||||
from_cache: true,
|
from_cache: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -375,6 +375,16 @@ pub fn inpaint_model() -> Option<PathBuf> {
|
|||||||
shared_model(INPAINT_MODEL)
|
shared_model(INPAINT_MODEL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3g
|
||||||
|
/// The learned demosaic and denoise, as shipped in `models/denoise/`.
|
||||||
|
pub const DENOISE_MODEL: &str = "mosaic-1408.onnx";
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ pub(super) fn flush_metadata(
|
|||||||
/// shards synced from another device, which carry pixels but no metadata.
|
/// shards synced from another device, which carry pixels but no metadata.
|
||||||
/// Read a header for its date.
|
/// Read a header for its date.
|
||||||
///
|
///
|
||||||
/// Returns whether the file was **reached**, which the caller needs and cannot
|
/// Reports whether the file was **reached**, which the caller needs and cannot
|
||||||
/// otherwise tell: a header that carried no EXIF and a fetch that never
|
/// otherwise tell: a header that carried no EXIF and a fetch that never
|
||||||
/// happened both leave `found` untouched, and recording the second as "this
|
/// happened both leave `found` untouched, and recording the second as "this
|
||||||
/// image has no date" would let one lock mark it dateless for good.
|
/// image has no date" would let one lock mark it dateless for good.
|
||||||
@@ -92,7 +92,7 @@ pub(crate) async fn read_metadata_only(
|
|||||||
decoder: &dyn dr_decode::Decoder,
|
decoder: &dyn dr_decode::Decoder,
|
||||||
req: &ThumbnailRequest,
|
req: &ThumbnailRequest,
|
||||||
found: &mut Vec<MetadataFound>,
|
found: &mut Vec<MetadataFound>,
|
||||||
) -> bool {
|
) -> DateRead {
|
||||||
let id = RemoteId::Path(RemotePath::new(&req.path));
|
let id = RemoteId::Path(RemotePath::new(&req.path));
|
||||||
|
|
||||||
// Retried, because one failure here is usually a lock rather than a
|
// Retried, because one failure here is usually a lock rather than a
|
||||||
@@ -108,7 +108,16 @@ pub(crate) async fn read_metadata_only(
|
|||||||
match backend.get(&id, Some(0..decoder.header_bytes())).await {
|
match backend.get(&id, Some(0..decoder.header_bytes())).await {
|
||||||
Ok(header) => {
|
Ok(header) => {
|
||||||
collect_metadata(backend, decoder, &id, &header, req, found).await;
|
collect_metadata(backend, decoder, &id, &header, req, found).await;
|
||||||
return true;
|
return DateRead::Reached;
|
||||||
|
}
|
||||||
|
// Not retried, unlike a lock. A dead server answers the second
|
||||||
|
// attempt exactly as it answered the first, after the same 15 s
|
||||||
|
// connect timeout — three of those per image turned a window of
|
||||||
|
// cached-but-undated cells into minutes of "reading dates"
|
||||||
|
// against nothing, with no banner, because nothing said why.
|
||||||
|
Err(e) if e.indicates_offline() => {
|
||||||
|
log::debug!("reading date for {}: {e}", req.path);
|
||||||
|
return DateRead::Offline(e.to_string());
|
||||||
}
|
}
|
||||||
Err(e) if e.is_transient() && attempt < ATTEMPTS => {
|
Err(e) if e.is_transient() && attempt < ATTEMPTS => {
|
||||||
// Backing off at all matters more than the exact interval: the
|
// Backing off at all matters more than the exact interval: the
|
||||||
@@ -121,11 +130,23 @@ pub(crate) async fn read_metadata_only(
|
|||||||
// timeline rather than breaking anything, and the next sweep
|
// timeline rather than breaking anything, and the next sweep
|
||||||
// retries it regardless.
|
// retries it regardless.
|
||||||
log::debug!("reading date for {} ({attempt} attempts): {e}", req.path);
|
log::debug!("reading date for {} ({attempt} attempts): {e}", req.path);
|
||||||
return false;
|
return DateRead::Failed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
DateRead::Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one header read for a date came to.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum DateRead {
|
||||||
|
/// The header arrived. Whatever EXIF it held is in `found`.
|
||||||
|
Reached,
|
||||||
|
/// This file could not be read; the next pass tries it again.
|
||||||
|
Failed,
|
||||||
|
/// The server could not be reached, so no read after this one will be
|
||||||
|
/// either. The caller stops rather than paying a timeout per image.
|
||||||
|
Offline(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write capture metadata read during the thumbnail pass.
|
/// Write capture metadata read during the thumbnail pass.
|
||||||
@@ -381,8 +402,12 @@ pub fn spawn_sweep(conn: Connection, catalog_path: PathBuf) -> Receiver<SweepMes
|
|||||||
let mut found = Vec::new();
|
let mut found = Vec::new();
|
||||||
let mut reached = Vec::new();
|
let mut reached = Vec::new();
|
||||||
for req in lane {
|
for req in lane {
|
||||||
if read_metadata_only(backend, decoder, req, &mut found).await {
|
match read_metadata_only(backend, decoder, req, &mut found).await {
|
||||||
reached.push(req.image_id);
|
DateRead::Reached => reached.push(req.image_id),
|
||||||
|
DateRead::Failed => {}
|
||||||
|
// The other lanes find the same, each after
|
||||||
|
// one timeout rather than one per image.
|
||||||
|
DateRead::Offline(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(found, reached)
|
(found, reached)
|
||||||
@@ -901,6 +926,130 @@ pub(super) fn thumbnails_outstanding(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// A backend whose every read fails the same way, counting the attempts.
|
||||||
|
struct Refusing {
|
||||||
|
error: fn() -> dr_sync::RemoteError,
|
||||||
|
gets: std::sync::atomic::AtomicUsize,
|
||||||
|
caps: dr_sync::Capabilities,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl RemoteBackend for Refusing {
|
||||||
|
fn capabilities(&self) -> &dr_sync::Capabilities {
|
||||||
|
&self.caps
|
||||||
|
}
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"refusing"
|
||||||
|
}
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
_dir: &RemotePath,
|
||||||
|
_since: Option<&dr_sync::Validator>,
|
||||||
|
) -> Result<Vec<dr_sync::RemoteEntry>, dr_sync::RemoteError> {
|
||||||
|
Err((self.error)())
|
||||||
|
}
|
||||||
|
async fn dir_validator(
|
||||||
|
&self,
|
||||||
|
_dir: &RemotePath,
|
||||||
|
) -> Result<dr_sync::Validator, dr_sync::RemoteError> {
|
||||||
|
Err((self.error)())
|
||||||
|
}
|
||||||
|
async fn delta(
|
||||||
|
&self,
|
||||||
|
_c: &dr_sync::Cursor,
|
||||||
|
) -> Result<(Vec<dr_sync::RemoteChange>, dr_sync::Cursor), dr_sync::RemoteError> {
|
||||||
|
Err((self.error)())
|
||||||
|
}
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
_id: &RemoteId,
|
||||||
|
_r: Option<std::ops::Range<u64>>,
|
||||||
|
) -> Result<Vec<u8>, dr_sync::RemoteError> {
|
||||||
|
self.gets.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
Err((self.error)())
|
||||||
|
}
|
||||||
|
async fn put(
|
||||||
|
&self,
|
||||||
|
_p: &RemotePath,
|
||||||
|
_b: Vec<u8>,
|
||||||
|
_c: Option<dr_sync::Precondition>,
|
||||||
|
) -> Result<dr_sync::Validator, dr_sync::RemoteError> {
|
||||||
|
Err((self.error)())
|
||||||
|
}
|
||||||
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
_id: &RemoteId,
|
||||||
|
_c: Option<dr_sync::Precondition>,
|
||||||
|
) -> Result<(), dr_sync::RemoteError> {
|
||||||
|
Err((self.error)())
|
||||||
|
}
|
||||||
|
async fn move_to(
|
||||||
|
&self,
|
||||||
|
_f: &RemoteId,
|
||||||
|
_t: &RemotePath,
|
||||||
|
) -> Result<(), dr_sync::RemoteError> {
|
||||||
|
Err((self.error)())
|
||||||
|
}
|
||||||
|
async fn create_dir(&self, _p: &RemotePath) -> Result<(), dr_sync::RemoteError> {
|
||||||
|
Err((self.error)())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_date_against(error: fn() -> dr_sync::RemoteError) -> (DateRead, usize) {
|
||||||
|
let backend = Refusing {
|
||||||
|
error,
|
||||||
|
gets: Default::default(),
|
||||||
|
caps: dr_sync::Capabilities::minimal(),
|
||||||
|
};
|
||||||
|
let req = ThumbnailRequest {
|
||||||
|
row: 0,
|
||||||
|
path: "a.CR2".into(),
|
||||||
|
file_id: Some(1),
|
||||||
|
size: 0,
|
||||||
|
image_id: 1,
|
||||||
|
thumb_size: dr_thumbs::ThumbSize::Grid,
|
||||||
|
needs_metadata: true,
|
||||||
|
full_resolution: false,
|
||||||
|
};
|
||||||
|
let rt = crate::net_runtime::build().unwrap();
|
||||||
|
let outcome = rt.block_on(read_metadata_only(
|
||||||
|
&backend,
|
||||||
|
dr_decode::default(),
|
||||||
|
&req,
|
||||||
|
&mut Vec::new(),
|
||||||
|
));
|
||||||
|
(
|
||||||
|
outcome,
|
||||||
|
backend.gets.load(std::sync::atomic::Ordering::SeqCst),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_date_read_against_a_dead_server_asks_once_and_says_so() {
|
||||||
|
// Each attempt waited out a 15 s connect timeout, three per image, for
|
||||||
|
// every cached-but-undated cell in the window — minutes of "reading
|
||||||
|
// dates" with no banner, because the caller could not tell a dead
|
||||||
|
// server from a missing file.
|
||||||
|
let (outcome, gets) =
|
||||||
|
read_date_against(|| dr_sync::RemoteError::Network("connection refused".into()));
|
||||||
|
|
||||||
|
assert!(matches!(outcome, DateRead::Offline(_)), "{outcome:?}");
|
||||||
|
assert_eq!(gets, 1, "retrying a dead server buys another timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_date_read_that_hits_a_lock_still_retries() {
|
||||||
|
// The reason the retry exists: Nextcloud answers a read with 423 under
|
||||||
|
// our own concurrency, and the same range succeeds moments later.
|
||||||
|
let (outcome, gets) = read_date_against(|| dr_sync::RemoteError::Server {
|
||||||
|
status: 423,
|
||||||
|
detail: "locked".into(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(outcome, DateRead::Failed);
|
||||||
|
assert_eq!(gets, 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_header_gives_the_size_the_photograph_is_seen_at() {
|
fn a_header_gives_the_size_the_photograph_is_seen_at() {
|
||||||
let mut md = dr_decode::Metadata {
|
let mut md = dr_decode::Metadata {
|
||||||
|
|||||||
@@ -440,7 +440,7 @@ pub fn spawn_sidecar_fetch(
|
|||||||
image_path: String,
|
image_path: String,
|
||||||
cache_dir: PathBuf,
|
cache_dir: PathBuf,
|
||||||
offline: bool,
|
offline: bool,
|
||||||
) -> Receiver<Option<dr_pipeline::Sidecar>> {
|
) -> Receiver<FetchedSidecar> {
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
|
||||||
executors::spawn(Executor::Network, "sidecars", move || {
|
executors::spawn(Executor::Network, "sidecars", move || {
|
||||||
@@ -455,7 +455,7 @@ pub fn spawn_sidecar_fetch(
|
|||||||
// which the next save would then write back over the top of.
|
// which the next save would then write back over the top of.
|
||||||
if cache.is_pending(&path_str) {
|
if cache.is_pending(&path_str) {
|
||||||
log::debug!("{path_str} has queued local edits; opening from the cache");
|
log::debug!("{path_str} has queued local edits; opening from the cache");
|
||||||
let _ = tx.send(cache.load(&path_str));
|
let _ = tx.send(FetchedSidecar::unknown(cache.load(&path_str)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,7 +472,7 @@ pub fn spawn_sidecar_fetch(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let Some(rt) = rt else {
|
let Some(rt) = rt else {
|
||||||
let _ = tx.send(cache.load(&path_str));
|
let _ = tx.send(FetchedSidecar::unknown(cache.load(&path_str)));
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -481,7 +481,7 @@ pub fn spawn_sidecar_fetch(
|
|||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::debug!("sidecar fetch backend: {e}");
|
log::debug!("sidecar fetch backend: {e}");
|
||||||
let _ = tx.send(cache.load(&path_str));
|
let _ = tx.send(FetchedSidecar::unknown(cache.load(&path_str)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -491,12 +491,21 @@ pub fn spawn_sidecar_fetch(
|
|||||||
|
|
||||||
// A 404 is the normal case on a library that has never been
|
// A 404 is the normal case on a library that has never been
|
||||||
// edited, so this is `ok()` rather than an error path.
|
// edited, so this is `ok()` rather than an error path.
|
||||||
let Ok(bytes) = backend.get(&id, None).await else {
|
let bytes = match backend.get(&id, None).await {
|
||||||
// Unreachable, or no such file. The cache cannot tell those
|
Ok(bytes) => bytes,
|
||||||
// apart and does not need to: either way it holds the best
|
Err(e) => {
|
||||||
// answer this device has.
|
// Unreachable, or no such file. The cache holds the best
|
||||||
let _ = tx.send(cache.load(&path_str));
|
// answer this device has either way — but only a server
|
||||||
return;
|
// that said "no such file", with nothing in the cache,
|
||||||
|
// is the word that there is no edit (FR-DEV-6).
|
||||||
|
let cached = cache.load(&path_str);
|
||||||
|
let absent = cached.is_none() && matches!(e, dr_sync::RemoteError::NotFound(_));
|
||||||
|
let _ = tx.send(FetchedSidecar {
|
||||||
|
sidecar: cached,
|
||||||
|
absent,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||||||
@@ -530,13 +539,36 @@ pub fn spawn_sidecar_fetch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = tx.send(parsed);
|
let _ = tx.send(FetchedSidecar::unknown(parsed));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
rx
|
rx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-CAT-8 | FR-DEV-6
|
||||||
|
/// What a stored-edit fetch answered.
|
||||||
|
///
|
||||||
|
/// `absent` is true only when the server said there is no such file and no
|
||||||
|
/// cached copy stood in — the one answer that means "DarkRoom has no edit of
|
||||||
|
/// this photograph", and so the only one that lets its earlier edit in.
|
||||||
|
/// Every other way of arriving at no sidecar — offline, unreachable, a file
|
||||||
|
/// that would not parse — leaves it false.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct FetchedSidecar {
|
||||||
|
pub sidecar: Option<dr_pipeline::Sidecar>,
|
||||||
|
pub absent: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FetchedSidecar {
|
||||||
|
fn unknown(sidecar: Option<dr_pipeline::Sidecar>) -> Self {
|
||||||
|
Self {
|
||||||
|
sidecar,
|
||||||
|
absent: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch one file in full, for opening it in develop.
|
/// Fetch one file in full, for opening it in develop.
|
||||||
///
|
///
|
||||||
/// Deliberately *not* the preview path. Browsing fetches a range and decodes
|
/// Deliberately *not* the preview path. Browsing fetches a range and decodes
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use dr_thumbs::ThumbStore;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::mpsc::Receiver;
|
use std::sync::mpsc::Receiver;
|
||||||
|
|
||||||
use super::sweep::{flush_metadata, read_metadata_only, MetadataFound};
|
use super::sweep::{flush_metadata, read_metadata_only, DateRead, MetadataFound};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use super::sweep::{thumbnails_outstanding, SWEEP_THUMB_SIZE};
|
use super::sweep::{thumbnails_outstanding, SWEEP_THUMB_SIZE};
|
||||||
use super::thumbnails_fetch::{ThumbnailRequest, MAX_PREVIEW_BYTES};
|
use super::thumbnails_fetch::{ThumbnailRequest, MAX_PREVIEW_BYTES};
|
||||||
@@ -33,6 +33,11 @@ pub struct ThumbnailReady {
|
|||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
pub rgba: Vec<u8>,
|
pub rgba: Vec<u8>,
|
||||||
|
/// Which class these pixels are, which is not always the class asked for:
|
||||||
|
/// the store answers a miss with the other class where it holds one. The
|
||||||
|
/// grid records this, so a stand-in is replaced once the real class can be
|
||||||
|
/// fetched rather than counted as served.
|
||||||
|
pub class: dr_thumbs::ThumbSize,
|
||||||
/// Whether these pixels came off local disk rather than the server.
|
/// Whether these pixels came off local disk rather than the server.
|
||||||
///
|
///
|
||||||
/// The grid paints both identically, so this exists solely for
|
/// The grid paints both identically, so this exists solely for
|
||||||
@@ -61,6 +66,9 @@ pub enum ThumbnailMessage {
|
|||||||
cached: usize,
|
cached: usize,
|
||||||
fetching: usize,
|
fetching: usize,
|
||||||
dating: usize,
|
dating: usize,
|
||||||
|
/// Of `cached`, how many are the other class standing in for one
|
||||||
|
/// still being fetched. Each of those rows is delivered twice.
|
||||||
|
standing_in: usize,
|
||||||
},
|
},
|
||||||
/// One header-only date read is starting.
|
/// One header-only date read is starting.
|
||||||
///
|
///
|
||||||
@@ -115,46 +123,12 @@ pub fn spawn_thumbnails(
|
|||||||
|
|
||||||
// Split the batch before delivering anything, so the plan can be
|
// Split the batch before delivering anything, so the plan can be
|
||||||
// reported first and the UI knows the shape of the work up front.
|
// reported first and the UI knows the shape of the work up front.
|
||||||
// Decoding happens here rather than in the split, because a corrupt
|
let Split {
|
||||||
// blob turns a hit into a miss.
|
hits,
|
||||||
let mut hits = Vec::new();
|
to_fetch,
|
||||||
let mut to_fetch = Vec::new();
|
metadata_only,
|
||||||
// Images whose thumbnail is cached but whose date is still unknown.
|
standing_in,
|
||||||
//
|
} = split_by_store(store.as_ref(), wanted);
|
||||||
// These need a header read even though no pixels are wanted. Without
|
|
||||||
// this pass an image is dated *only* on the one visit that produced
|
|
||||||
// its thumbnail — so a library browsed once before the EXIF code
|
|
||||||
// existed, or synced from another device's shards, stays permanently
|
|
||||||
// undated and never appears on the timeline.
|
|
||||||
let mut metadata_only = Vec::new();
|
|
||||||
|
|
||||||
for req in wanted {
|
|
||||||
let stored = req
|
|
||||||
.file_id
|
|
||||||
.zip(store.as_ref())
|
|
||||||
.and_then(|(id, s)| s.get(id, req.thumb_size).ok().flatten());
|
|
||||||
|
|
||||||
match stored.map(|t| dr_thumbs::decode_rgba(&t.bytes)) {
|
|
||||||
Some(Ok((width, height, rgba))) => {
|
|
||||||
if req.needs_metadata {
|
|
||||||
metadata_only.push(req.clone());
|
|
||||||
}
|
|
||||||
hits.push(ThumbnailReady {
|
|
||||||
row: req.row,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
rgba,
|
|
||||||
from_cache: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// A corrupt stored blob is a miss, not a failure.
|
|
||||||
Some(Err(e)) => {
|
|
||||||
log::debug!("stored thumbnail unreadable, refetching: {e}");
|
|
||||||
to_fetch.push(req);
|
|
||||||
}
|
|
||||||
None => to_fetch.push(req),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"thumbnails: {} from store, {} to fetch{}",
|
"thumbnails: {} from store, {} to fetch{}",
|
||||||
@@ -171,6 +145,7 @@ pub fn spawn_thumbnails(
|
|||||||
cached: hits.len(),
|
cached: hits.len(),
|
||||||
fetching: to_fetch.len(),
|
fetching: to_fetch.len(),
|
||||||
dating: metadata_only.len(),
|
dating: metadata_only.len(),
|
||||||
|
standing_in,
|
||||||
})
|
})
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
@@ -251,7 +226,14 @@ pub fn spawn_thumbnails(
|
|||||||
if tx.send(ThumbnailMessage::DateProgress).is_err() {
|
if tx.send(ThumbnailMessage::DateProgress).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
read_metadata_only(&*backend, decoder, &req, &mut found).await;
|
if let DateRead::Offline(reason) =
|
||||||
|
read_metadata_only(&*backend, decoder, &req, &mut found).await
|
||||||
|
{
|
||||||
|
// Said once, as the fetch loop does, so the banner goes
|
||||||
|
// up and the bar stops rather than sweeping for ever.
|
||||||
|
let _ = tx.send(ThumbnailMessage::Offline { reason });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if found.len() >= FLUSH_EVERY {
|
if found.len() >= FLUSH_EVERY {
|
||||||
flush_metadata(&catalog_path, &mut found, &tx);
|
flush_metadata(&catalog_path, &mut found, &tx);
|
||||||
@@ -269,6 +251,105 @@ pub fn spawn_thumbnails(
|
|||||||
rx
|
rx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A batch divided by what the store can answer.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Split {
|
||||||
|
/// Decoded off local disk, ready to deliver.
|
||||||
|
hits: Vec<ThumbnailReady>,
|
||||||
|
/// Needing the network: a miss, or a smaller class standing in.
|
||||||
|
to_fetch: Vec<ThumbnailRequest>,
|
||||||
|
/// Images whose thumbnail is cached but whose date is still unknown.
|
||||||
|
///
|
||||||
|
/// These need a header read even though no pixels are wanted. Without
|
||||||
|
/// this pass an image is dated *only* on the one visit that produced
|
||||||
|
/// its thumbnail — so a library browsed once before the EXIF code
|
||||||
|
/// existed, or synced from another device's shards, stays permanently
|
||||||
|
/// undated and never appears on the timeline.
|
||||||
|
metadata_only: Vec<ThumbnailRequest>,
|
||||||
|
/// Rows in both `hits` and `to_fetch`: the grid class shown while the
|
||||||
|
/// large one is fetched.
|
||||||
|
standing_in: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Divide a batch into what the store holds and what must be fetched.
|
||||||
|
///
|
||||||
|
/// Decoding happens here rather than after, because a corrupt blob turns a
|
||||||
|
/// hit into a miss.
|
||||||
|
fn split_by_store(store: Option<&ThumbStore>, wanted: Vec<ThumbnailRequest>) -> Split {
|
||||||
|
let mut split = Split::default();
|
||||||
|
|
||||||
|
for req in wanted {
|
||||||
|
if let Some(hit) = read_stored(store, &req, req.thumb_size) {
|
||||||
|
if req.needs_metadata {
|
||||||
|
split.metadata_only.push(req.clone());
|
||||||
|
}
|
||||||
|
split.hits.push(hit);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other class, before the network. The sweep stores only the
|
||||||
|
// grid class, so a zoomed grid asked the server for every cell it had
|
||||||
|
// not zoomed over before — and offline, left each one blank with its
|
||||||
|
// 256px thumbnail sitting in the store. A softer cell is better than
|
||||||
|
// an empty one, and a sharper one is simply better.
|
||||||
|
// A wide panorama cell falls back to the grid class too: the sweep
|
||||||
|
// always stores it, and smaller, it only stands in while the wide
|
||||||
|
// class is fetched.
|
||||||
|
let other = match req.thumb_size {
|
||||||
|
dr_thumbs::ThumbSize::Grid => dr_thumbs::ThumbSize::Large,
|
||||||
|
dr_thumbs::ThumbSize::Large
|
||||||
|
| dr_thumbs::ThumbSize::Wide2
|
||||||
|
| dr_thumbs::ThumbSize::Wide3
|
||||||
|
| dr_thumbs::ThumbSize::Wide4 => dr_thumbs::ThumbSize::Grid,
|
||||||
|
};
|
||||||
|
match read_stored(store, &req, other) {
|
||||||
|
// Larger than asked for: nothing a fetch would improve on.
|
||||||
|
Some(hit) if other > req.thumb_size => {
|
||||||
|
if req.needs_metadata {
|
||||||
|
split.metadata_only.push(req.clone());
|
||||||
|
}
|
||||||
|
split.hits.push(hit);
|
||||||
|
}
|
||||||
|
// Smaller: shown now, and the fetch still goes out to replace it.
|
||||||
|
// The fetch reads the header, so it dates the image too.
|
||||||
|
Some(stand_in) => {
|
||||||
|
split.standing_in += 1;
|
||||||
|
split.hits.push(stand_in);
|
||||||
|
split.to_fetch.push(req);
|
||||||
|
}
|
||||||
|
None => split.to_fetch.push(req),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
split
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stored thumbnail of one class, decoded for the grid.
|
||||||
|
///
|
||||||
|
/// `None` for a miss, for a request with no file id to key on, and for a
|
||||||
|
/// corrupt blob — which is a miss, not a failure, and gets refetched.
|
||||||
|
fn read_stored(
|
||||||
|
store: Option<&ThumbStore>,
|
||||||
|
req: &ThumbnailRequest,
|
||||||
|
class: dr_thumbs::ThumbSize,
|
||||||
|
) -> Option<ThumbnailReady> {
|
||||||
|
let stored = store?.get(req.file_id?, class).ok().flatten()?;
|
||||||
|
match dr_thumbs::decode_rgba(&stored.bytes) {
|
||||||
|
Ok((width, height, rgba)) => Some(ThumbnailReady {
|
||||||
|
row: req.row,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rgba,
|
||||||
|
class,
|
||||||
|
from_cache: true,
|
||||||
|
}),
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("stored thumbnail unreadable, refetching: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) async fn fetch_one(
|
pub(super) async fn fetch_one(
|
||||||
backend: &dyn RemoteBackend,
|
backend: &dyn RemoteBackend,
|
||||||
decoder: &dyn dr_decode::Decoder,
|
decoder: &dyn dr_decode::Decoder,
|
||||||
@@ -302,6 +383,7 @@ pub(super) async fn fetch_one(
|
|||||||
width: preview.width,
|
width: preview.width,
|
||||||
height: preview.height,
|
height: preview.height,
|
||||||
rgba: preview.rgba,
|
rgba: preview.rgba,
|
||||||
|
class: req.thumb_size,
|
||||||
from_cache: false,
|
from_cache: false,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -546,6 +628,86 @@ pub fn camera_label(make: Option<&str>, model: Option<&str>) -> Option<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// A store holding one image at one class, and a request for it.
|
||||||
|
fn store_with(tag: &str, file_id: u64, edge: u32, class: dr_thumbs::ThumbSize) -> ThumbStore {
|
||||||
|
let dir = std::env::temp_dir().join(format!("dr-ui-split-{tag}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
let rgba: Vec<u8> = std::iter::repeat_n([10u8, 20, 30, 255], (edge * edge) as usize)
|
||||||
|
.flatten()
|
||||||
|
.collect();
|
||||||
|
let mut store = ThumbStore::open(&dir).unwrap();
|
||||||
|
let bytes = dr_thumbs::encode_rgba(edge, edge, &rgba).unwrap();
|
||||||
|
store
|
||||||
|
.put(
|
||||||
|
file_id,
|
||||||
|
class,
|
||||||
|
&dr_thumbs::Thumbnail {
|
||||||
|
width: edge,
|
||||||
|
height: edge,
|
||||||
|
bytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
}
|
||||||
|
|
||||||
|
fn asking(file_id: u64, thumb_size: dr_thumbs::ThumbSize) -> ThumbnailRequest {
|
||||||
|
ThumbnailRequest {
|
||||||
|
row: 0,
|
||||||
|
path: "a.CR2".into(),
|
||||||
|
file_id: Some(file_id),
|
||||||
|
size: 0,
|
||||||
|
image_id: 1,
|
||||||
|
thumb_size,
|
||||||
|
needs_metadata: false,
|
||||||
|
full_resolution: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zoomed_cell_is_drawn_from_the_grid_class_while_the_large_one_is_fetched() {
|
||||||
|
// The offline symptom: the sweep stores only the grid class, so a grid
|
||||||
|
// zoomed past 256px found nothing at its class and went to a server
|
||||||
|
// that was not there — every cell blank, its thumbnail in the store.
|
||||||
|
use dr_thumbs::ThumbSize::{Grid, Large};
|
||||||
|
let store = store_with("standin", 7, 8, Grid);
|
||||||
|
|
||||||
|
let split = split_by_store(Some(&store), vec![asking(7, Large)]);
|
||||||
|
|
||||||
|
assert_eq!(split.hits.len(), 1, "drawn from the store now");
|
||||||
|
assert_eq!(split.hits[0].class, Grid, "and recorded as what it is");
|
||||||
|
assert_eq!(split.to_fetch.len(), 1, "the large class is still wanted");
|
||||||
|
assert_eq!(split.standing_in, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_large_thumbnail_answers_a_grid_request_without_a_fetch() {
|
||||||
|
use dr_thumbs::ThumbSize::{Grid, Large};
|
||||||
|
let store = store_with("larger", 7, 16, Large);
|
||||||
|
|
||||||
|
let split = split_by_store(Some(&store), vec![asking(7, Grid)]);
|
||||||
|
|
||||||
|
assert_eq!(split.hits.len(), 1);
|
||||||
|
assert_eq!(split.hits[0].class, Large);
|
||||||
|
assert!(
|
||||||
|
split.to_fetch.is_empty(),
|
||||||
|
"nothing a fetch would improve on"
|
||||||
|
);
|
||||||
|
assert_eq!(split.standing_in, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_exact_hit_is_not_a_stand_in() {
|
||||||
|
use dr_thumbs::ThumbSize::Large;
|
||||||
|
let store = store_with("exact", 7, 16, Large);
|
||||||
|
|
||||||
|
let split = split_by_store(Some(&store), vec![asking(7, Large)]);
|
||||||
|
|
||||||
|
assert_eq!(split.hits.len(), 1);
|
||||||
|
assert!(split.to_fetch.is_empty());
|
||||||
|
assert_eq!(split.standing_in, 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_thumbnail_pass_asks_only_for_what_is_missing() {
|
fn the_thumbnail_pass_asks_only_for_what_is_missing() {
|
||||||
// The work list is the whole point of the pass being resumable and of
|
// The work list is the whole point of the pass being resumable and of
|
||||||
|
|||||||
@@ -96,8 +96,20 @@ fn already_served(
|
|||||||
held: &std::collections::HashMap<i64, Held>,
|
held: &std::collections::HashMap<i64, Held>,
|
||||||
ids: impl Iterator<Item = i64>,
|
ids: impl Iterator<Item = i64>,
|
||||||
) -> std::collections::HashSet<(i64, dr_thumbs::ThumbSize)> {
|
) -> std::collections::HashSet<(i64, dr_thumbs::ThumbSize)> {
|
||||||
ids.filter_map(|id| Some((id, held.get(&id)?.class?)))
|
use dr_thumbs::ThumbSize;
|
||||||
.collect()
|
let mut served = std::collections::HashSet::new();
|
||||||
|
for id in ids {
|
||||||
|
let Some(class) = held.get(&id).and_then(|h| h.class) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
served.insert((id, class));
|
||||||
|
// The large class is everything the grid class would be, and more: a
|
||||||
|
// cell the store answered with it has nothing left to ask for.
|
||||||
|
if class == ThumbSize::Large {
|
||||||
|
served.insert((id, ThumbSize::Grid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
served
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where the loaded window starts for a view whose first visible cell is
|
/// Where the loaded window starts for a view whose first visible cell is
|
||||||
@@ -1014,11 +1026,13 @@ fn drain_thumbnails(
|
|||||||
cached,
|
cached,
|
||||||
fetching,
|
fetching,
|
||||||
dating,
|
dating,
|
||||||
|
standing_in,
|
||||||
} => {
|
} => {
|
||||||
// Date reads produce no cell, so they are counted into
|
// Date reads produce no cell, so they are counted into
|
||||||
// the bar's denominator or it finishes while work is
|
// the bar's denominator or it finishes while work is
|
||||||
// still running.
|
// still running. A stand-in is delivered once from the
|
||||||
job.add_total(dating);
|
// store and again from the fetch, so it counts twice.
|
||||||
|
job.add_total(dating + standing_in);
|
||||||
|
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
if cached > 0 {
|
if cached > 0 {
|
||||||
@@ -1084,10 +1098,11 @@ fn drain_thumbnails(
|
|||||||
row.has_thumb = true;
|
row.has_thumb = true;
|
||||||
model.set_row_data(t.row, row);
|
model.set_row_data(t.row, row);
|
||||||
// What this cell is now showing, so the next reload
|
// What this cell is now showing, so the next reload
|
||||||
// can carry it over and know not to ask again.
|
// can carry it over and know not to ask again. The
|
||||||
if let Some(class) = classes.get(t.row) {
|
// pixels' own class, not the one the row asked for:
|
||||||
record_class(&ctl_cb, t.row, *class);
|
// a stand-in recorded as the class it stands in for
|
||||||
}
|
// would never be replaced.
|
||||||
|
record_class(&ctl_cb, t.row, t.class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ThumbnailMessage::Unavailable { row, reason } => {
|
ThumbnailMessage::Unavailable { row, reason } => {
|
||||||
@@ -1542,6 +1557,22 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_large_thumbnail_satisfies_the_grid_class() {
|
||||||
|
// Zooming out past 256px reloads the window at the grid class. A cell
|
||||||
|
// already showing the large class has every pixel the small one would
|
||||||
|
// give, so asking the store for it again is a read for nothing.
|
||||||
|
let held = hold_thumbnails(
|
||||||
|
&model_of(&[5], &[5]),
|
||||||
|
&[5],
|
||||||
|
&[Some(dr_thumbs::ThumbSize::Large)],
|
||||||
|
);
|
||||||
|
let served = already_served(&held, [5i64].into_iter());
|
||||||
|
|
||||||
|
assert!(served.contains(&(5, dr_thumbs::ThumbSize::Large)));
|
||||||
|
assert!(served.contains(&(5, dr_thumbs::ThumbSize::Grid)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_cell_whose_class_was_never_recorded_is_fetched_again() {
|
fn a_cell_whose_class_was_never_recorded_is_fetched_again() {
|
||||||
// Pixels with no class are pixels from before this bookkeeping existed
|
// Pixels with no class are pixels from before this bookkeeping existed
|
||||||
|
|||||||
Reference in New Issue
Block a user