Compare commits
59
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
2fad846cd1 | ||
|
|
5c26dc5033 | ||
|
|
abefb94daa | ||
|
|
54772f94d6 | ||
|
|
65c1f1a468 | ||
|
|
cb7ad0bbe7 | ||
|
|
eae720ce75 | ||
|
|
c02b401a9a | ||
|
|
f6a3f3f4e2 | ||
|
|
05ac2416c6 | ||
|
|
0b06e31bf3 | ||
|
|
d5c93ae795 | ||
|
|
379dd1afcc | ||
|
|
825c5af20a | ||
|
|
23a2f13b46 | ||
|
|
2c947430e6 | ||
|
|
36556729f1 | ||
|
|
6f33517b35 | ||
|
|
1d7115437b | ||
|
|
caae65c78d | ||
|
|
fcccc2c2e0 | ||
|
|
1bc04870c3 | ||
|
|
4da2ec39b3 | ||
|
|
9558b77759 |
Generated
+43
-25
@@ -1265,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-android"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"dr-plat",
|
||||
@@ -1278,7 +1278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-desktop"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-plat",
|
||||
@@ -1454,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dr-bench"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-catalog",
|
||||
@@ -1471,7 +1471,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-catalog"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-face",
|
||||
"dr-plat",
|
||||
@@ -1486,7 +1486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-decode"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"env_logger",
|
||||
@@ -1498,9 +1498,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "dr-export"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-gpu",
|
||||
@@ -1519,7 +1536,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-face"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
@@ -1532,7 +1549,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-film"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1541,7 +1558,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-gpu"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dr-decode",
|
||||
@@ -1559,7 +1576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-inference-engine"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"libloading",
|
||||
@@ -1574,7 +1591,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ingest"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-plat",
|
||||
"dr-types",
|
||||
@@ -1586,7 +1603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-lens"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"lensfun",
|
||||
"log",
|
||||
@@ -1594,7 +1611,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pano"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-inference-engine",
|
||||
@@ -1608,7 +1625,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pipeline"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -1617,7 +1634,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-plat"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"android-native-keyring-store",
|
||||
"dr-types",
|
||||
@@ -1633,7 +1650,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-preset-xmp"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-pipeline",
|
||||
"log",
|
||||
@@ -1643,7 +1660,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-segment"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
@@ -1656,7 +1673,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-plat",
|
||||
@@ -1670,7 +1687,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-folder"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-sync",
|
||||
@@ -1682,7 +1699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-nextcloud"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-decode",
|
||||
@@ -1704,7 +1721,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-thumbs"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"jpeg-encoder",
|
||||
@@ -1716,7 +1733,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-types"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1725,12 +1742,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ui"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"dr-catalog",
|
||||
"dr-decode",
|
||||
"dr-denoise",
|
||||
"dr-export",
|
||||
"dr-face",
|
||||
"dr-film",
|
||||
@@ -1773,7 +1791,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-xmp"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -7107,7 +7125,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "traceability"
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"proc-macro2",
|
||||
|
||||
+15
-1
@@ -5,6 +5,7 @@ members = [
|
||||
"core/dr-catalog",
|
||||
"core/dr-thumbs",
|
||||
"core/dr-decode",
|
||||
"core/dr-denoise",
|
||||
"core/dr-export",
|
||||
"core/dr-face",
|
||||
"core/dr-film",
|
||||
@@ -32,7 +33,7 @@ members = [
|
||||
exclude = ["third_party"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.19.1"
|
||||
version = "0.21.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.92"
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -44,6 +45,7 @@ dr-types = { path = "core/dr-types" }
|
||||
dr-catalog = { path = "core/dr-catalog" }
|
||||
dr-thumbs = { path = "core/dr-thumbs" }
|
||||
dr-decode = { path = "core/dr-decode" }
|
||||
dr-denoise = { path = "core/dr-denoise" }
|
||||
dr-export = { path = "core/dr-export" }
|
||||
# Stated explicitly for the same reason as `dr-segment` below: no dependant
|
||||
# should drag in an ONNX runtime by accident. Members opt in with
|
||||
@@ -276,6 +278,18 @@ opt-level = 0
|
||||
lto = "thin"
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -83,28 +83,125 @@ texture directly — no readback between the GPU and the screen.
|
||||
| Windows | `DarkRoom-<version>-x86_64-setup.exe`, cross-built by CI ([windows.md](docs/dev/windows.md)) | Verified under Wine only; unsigned |
|
||||
| Flatpak | [`packaging/flatpak/`](packaging/flatpak/) | Manifest in tree; folders are chosen through the portal, but no Flatpak has been built to prove it |
|
||||
|
||||
Or build it. Git LFS is required for the model weights, and the toolchain
|
||||
pins itself to 1.92.0:
|
||||
## Building from source
|
||||
|
||||
**Before anything.** Git LFS holds the model weights and the manual's
|
||||
pictures; a clone without it has ~130-byte pointers in their place, and every
|
||||
packager below refuses to ship one. The Rust toolchain pins itself to 1.92.0
|
||||
through `rust-toolchain.toml`, so rustup is all you install. Slint needs a few
|
||||
system headers, and the app needs a Vulkan driver at runtime:
|
||||
|
||||
```bash
|
||||
git clone https://gitea.tourolle.paris/dtourolle/DarkRoom.git && cd DarkRoom
|
||||
git lfs install && git lfs pull
|
||||
|
||||
# Debian / Ubuntu
|
||||
sudo apt-get install pkg-config libfontconfig1-dev libxkbcommon-dev libvulkan1
|
||||
# Arch
|
||||
sudo pacman -S --needed pkgconf fontconfig libxkbcommon vulkan-icd-loader
|
||||
```
|
||||
|
||||
**To try it** from the checkout, without installing anything:
|
||||
|
||||
```bash
|
||||
cargo run --release -p darkroom-desktop
|
||||
```
|
||||
|
||||
Android, through the containerised toolchain ([docker/android](docker/android/README.md)):
|
||||
This is for development. The binary under `target/` finds no face, scene or
|
||||
panorama-fill models, and a release build does not find the manual either:
|
||||
it looks for all of them in the system data directories an install creates
|
||||
(`$XDG_DATA_DIRS/darkroom`, by default `/usr/local/share/darkroom` and
|
||||
`/usr/share/darkroom`), never in the checkout. Those features show as
|
||||
unavailable until it is installed.
|
||||
|
||||
### Linux: build and install
|
||||
|
||||
**On Arch**, build a package from the checkout and install it with pacman,
|
||||
so it can be upgraded and removed like any other:
|
||||
|
||||
```bash
|
||||
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
|
||||
cd packaging && makepkg -si
|
||||
```
|
||||
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) has the system packages, the four
|
||||
**Elsewhere**, build the release binary and install it under `/usr/local`
|
||||
by hand. These are the same files, in the same places, as the Arch package
|
||||
([`packaging/PKGBUILD`](packaging/PKGBUILD)'s `package()` is the reference):
|
||||
|
||||
```bash
|
||||
cargo build --release --locked -p darkroom-desktop
|
||||
# -> target/release/darkroom-desktop
|
||||
|
||||
P=/usr/local
|
||||
sudo install -Dm755 target/release/darkroom-desktop $P/bin/darkroom-desktop
|
||||
|
||||
# The models: faces and eye state, scene categories, panorama border fill
|
||||
sudo install -d $P/share/darkroom/models
|
||||
sudo install -m644 models/face/*.onnx models/scene/* models/inpaint/*.onnx \
|
||||
$P/share/darkroom/models/
|
||||
|
||||
# The offline manual the Help menu opens
|
||||
sudo install -Dm644 docs/manual/index.html $P/share/darkroom/manual/index.html
|
||||
sudo install -Dm644 -t $P/share/darkroom/manual/media docs/manual/media/*
|
||||
|
||||
# Launcher entry, icon and software-centre description
|
||||
sudo install -Dm644 packaging/paris.tourolle.darkroom.desktop \
|
||||
$P/share/applications/paris.tourolle.darkroom.desktop
|
||||
sudo install -Dm644 ui/dr-ui/ui/app-icon.png \
|
||||
$P/share/icons/hicolor/256x256/apps/paris.tourolle.darkroom.png
|
||||
sudo install -Dm644 packaging/paris.tourolle.darkroom.metainfo.xml \
|
||||
$P/share/metainfo/paris.tourolle.darkroom.metainfo.xml
|
||||
```
|
||||
|
||||
Then run `darkroom-desktop`, or open it from the application menu. To
|
||||
uninstall, remove those files and `/usr/local/share/darkroom`. Your catalog,
|
||||
settings and thumbnails live in `darkroom/` under your own XDG data, config
|
||||
and cache directories (`~/.local/share`, `~/.config`, `~/.cache`) and are
|
||||
not touched by either.
|
||||
|
||||
Optional at runtime: `gnome-keyring` or `kwallet` to remember Nextcloud
|
||||
credentials, and an ONNX Runtime in `/usr/lib` (CPU, or ROCm on an AMD GPU) to
|
||||
run the models on every core rather than on the built-in engine.
|
||||
|
||||
### Windows: build the installer
|
||||
|
||||
The `.exe` is cross-built from Linux in a container (podman or docker), with
|
||||
no Windows machine involved. Two steps — the executable, then the NSIS
|
||||
installer that carries it with its models and manual:
|
||||
|
||||
```bash
|
||||
./docker/windows/build.sh cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
|
||||
./docker/windows/build.sh docker/windows/package.sh
|
||||
```
|
||||
|
||||
Both land in the container's cache on the host, `~/.cache/darkroom-windows/target/`:
|
||||
the bare executable under `x86_64-pc-windows-gnu/release/darkroom-desktop.exe`,
|
||||
the installer under `installer/DarkRoom-<version>-x86_64-setup.exe`.
|
||||
Copy that to the Windows machine and run it — it installs per user, needs no
|
||||
administrator rights, and adds an uninstaller. Run on its own, the bare
|
||||
`.exe` looks for `models\` and `manual\` beside itself, so use the installer.
|
||||
[docker/windows](docker/windows/README.md) has the details.
|
||||
|
||||
### Android: build the APK
|
||||
|
||||
Also containerised ([docker/android](docker/android/README.md)). This
|
||||
builds, packages and debug-signs the APK, and with `--install` puts it on a
|
||||
device connected over adb:
|
||||
|
||||
```bash
|
||||
./docker/android/package.sh --install
|
||||
```
|
||||
|
||||
A debug-signed APK cannot replace one installed from a release; uninstall
|
||||
that first.
|
||||
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) has the four
|
||||
commands CI runs against what you send, and the shortest useful
|
||||
contribution — a develop operation is one YAML file, and it arrives with its
|
||||
controls, its place in the chain and its tests.
|
||||
|
||||
## Where it stands
|
||||
|
||||
**0.19.1**, thirty 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);
|
||||
the rest are written down rather than merely absent.
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
|
||||
Deliberately minimal: this packages the viewer for on-device testing (spike
|
||||
S2 needs Adreno and Mali hardware, which no emulator represents). Nothing
|
||||
here is a distribution manifest yet. Only network access is declared: file
|
||||
access needs no manifest permission because the library grid reads through
|
||||
SAF, which grants per-tree at runtime (ARCH §6.9).
|
||||
here is a distribution manifest yet. The library grid needs no storage
|
||||
permission, because it reads through SAF, which grants per-tree at runtime
|
||||
(ARCH §6.9); the one storage permission declared is for importing from a
|
||||
camera card, which is read by path.
|
||||
|
||||
Minimal is not the same as empty, and the entries below that are not the
|
||||
activity are the difference. A manifest is the only place a component can be
|
||||
@@ -21,13 +22,29 @@
|
||||
WebDAV listing, thumbnail and image fetches. Without it Android refuses
|
||||
socket creation outright, and the failure is invisible — no panic to
|
||||
catch, no log line, just a worker thread that stops. Storage is the
|
||||
separate case that genuinely needs no permission here, because SAF
|
||||
grants per-tree at runtime (ARCH §6.9). -->
|
||||
separate case: the library and album folders need no permission
|
||||
here, because SAF grants per-tree at runtime (ARCH §6.9). -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Read before deciding whether a sync may run: FR-NC-6 gates background
|
||||
work on unmetered-and-charging, which means knowing the network type. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- FR-CAT-10: importing from a camera card. The importer reads the card
|
||||
as files, and "all files access" is what makes an SD card or a USB
|
||||
card reader readable by path on API 30 and up (see Cards.java). It is
|
||||
granted on a system settings page, not a dialog; the import page
|
||||
sends the user there when it is missing. READ_EXTERNAL_STORAGE is the
|
||||
same thing for API 28 and 29, and means nothing above them; on 29 it
|
||||
reads by path only with requestLegacyExternalStorage, which is why
|
||||
<application> carries that flag.
|
||||
|
||||
Google Play limits MANAGE_EXTERNAL_STORAGE to a short list of app
|
||||
kinds. DarkRoom is not distributed through Play. -->
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
|
||||
<!-- Vulkan 1.1 is what wgpu needs; the API 28 floor is where support is
|
||||
dependable (NFR-COMPAT-1). Marked required so an unsupported device
|
||||
fails at install rather than at first frame. -->
|
||||
@@ -53,6 +70,7 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:hasCode="true"
|
||||
android:allowBackup="false"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<!-- NativeActivity rather than a Kotlin Activity: android-activity's
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package paris.tourolle.darkroom;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Environment;
|
||||
import android.os.storage.StorageManager;
|
||||
import android.os.storage.StorageVolume;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Finding a camera card, and the permission that makes it readable (FR-CAT-10).
|
||||
*
|
||||
* <p>An import reads the card as files: the survey walks it, the probe reads
|
||||
* each header and the copy streams each original, all through the same
|
||||
* {@code std::fs} code the desktop uses. Android hands out such paths —
|
||||
* {@code /storage/9C33-6BBD/DCIM} — to an app holding "all files access"
|
||||
* ({@code MANAGE_EXTERNAL_STORAGE}, API 30), which covers the root of an SD
|
||||
* card and of a USB card reader. Below API 30 the same paths are readable
|
||||
* with {@code READ_EXTERNAL_STORAGE}.
|
||||
*
|
||||
* <p>Not the folder picker {@link FolderPicker} uses for albums. A tree
|
||||
* granted through SAF is {@code content://} URIs, not paths, and since API 30
|
||||
* the picker refuses the root of a card outright; reading a card through it
|
||||
* would mean a second storage implementation under the importer, where this
|
||||
* needs none.
|
||||
*
|
||||
* <p>Google Play restricts this permission to file managers and the like.
|
||||
* DarkRoom is not distributed through Play, so the restriction does not
|
||||
* apply; it would need revisiting if that changed.
|
||||
*/
|
||||
public final class Cards {
|
||||
private static final String TAG = "DarkRoom";
|
||||
|
||||
private Cards() {
|
||||
}
|
||||
|
||||
/** Whether this app may read a card's files by path. */
|
||||
public static boolean hasAccess(Context context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
return Environment.isExternalStorageManager();
|
||||
}
|
||||
return context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
== PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the system page where the user grants it.
|
||||
*
|
||||
* <p>A settings page rather than a permission dialog because there is no
|
||||
* dialog for this one on API 30 and up: the user flips "Allow access to
|
||||
* manage all files" for this app. Below 30 the context is the application
|
||||
* context, which cannot raise a runtime permission request (that needs an
|
||||
* Activity's result), so the app's own settings page is the route there
|
||||
* too. Either way the app learns of the grant by asking
|
||||
* {@link #hasAccess} again.
|
||||
*/
|
||||
public static void requestAccess(Context context) {
|
||||
Uri self = Uri.parse("package:" + context.getPackageName());
|
||||
Intent intent;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, self);
|
||||
} else {
|
||||
intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, self);
|
||||
}
|
||||
// The context is not an Activity; see FolderPicker.start.
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
try {
|
||||
context.startActivity(intent);
|
||||
} catch (RuntimeException e) {
|
||||
// Some builds ship without the per-app page; the list of every
|
||||
// app holding the permission is the fallback that always exists.
|
||||
Log.w(TAG, "no per-app all-files page; opening the list", e);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Intent list = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
|
||||
list.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
context.startActivity(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every mounted volume other than the device's own storage.
|
||||
*
|
||||
* <p>One string per volume, {@code path \t description \t removable},
|
||||
* where removable is {@code 1} or {@code 0}: the reason {@link Intents}
|
||||
* gives for keeping the JNI surface to strings. The primary volume is left
|
||||
* out — it is the device's internal storage, never a card — and so is
|
||||
* anything not mounted, which is a card being ejected or one the system
|
||||
* could not read.
|
||||
*/
|
||||
public static String[] volumes(Context context) {
|
||||
List<String> out = new ArrayList<String>();
|
||||
StorageManager manager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
|
||||
if (manager == null) {
|
||||
return new String[0];
|
||||
}
|
||||
for (StorageVolume volume : manager.getStorageVolumes()) {
|
||||
if (volume.isPrimary()) {
|
||||
continue;
|
||||
}
|
||||
String state = volume.getState();
|
||||
if (!Environment.MEDIA_MOUNTED.equals(state)
|
||||
&& !Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
|
||||
continue;
|
||||
}
|
||||
String path = path(volume);
|
||||
if (path == null) {
|
||||
Log.w(TAG, "a mounted volume with no path: " + volume);
|
||||
continue;
|
||||
}
|
||||
String description = volume.getDescription(context);
|
||||
if (description == null) {
|
||||
description = new File(path).getName();
|
||||
}
|
||||
out.add(path + "\t" + description.replace('\t', ' ') + "\t"
|
||||
+ (volume.isRemovable() ? "1" : "0"));
|
||||
}
|
||||
return out.toArray(new String[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the volume is mounted.
|
||||
*
|
||||
* <p>{@code getDirectory} is API 30. Below it the same answer is the
|
||||
* hidden {@code getPath}, which every release from 24 to 29 has, reached by
|
||||
* reflection because android.jar does not declare it.
|
||||
*/
|
||||
private static String path(StorageVolume volume) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
File dir = volume.getDirectory();
|
||||
return dir == null ? null : dir.getPath();
|
||||
}
|
||||
try {
|
||||
Object path = StorageVolume.class.getMethod("getPath").invoke(volume);
|
||||
return path == null ? null : path.toString();
|
||||
} catch (ReflectiveOperationException e) {
|
||||
Log.w(TAG, "StorageVolume.getPath", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,7 +335,7 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
|
||||
// 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
|
||||
// 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.int8.onnx",
|
||||
@@ -360,6 +360,7 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
|
||||
(c"models/categories.txt", "categories.txt"),
|
||||
// The panorama border filler (FR-MRG-4); MIT, 28 MB.
|
||||
(c"models/migan-512.onnx", "migan-512.onnx"),
|
||||
(c"models/mosaic-1408.onnx", "mosaic-1408.onnx"),
|
||||
];
|
||||
|
||||
let dir = dr_ui::shared_face_models_dir();
|
||||
|
||||
@@ -15,6 +15,22 @@ use std::path::PathBuf;
|
||||
|
||||
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<()> {
|
||||
// TRACES: FR-PLAT-WIN-3
|
||||
// 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
|
||||
// have overridden the default below, and the file must not be quieter than
|
||||
// the terminal.
|
||||
let console = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(
|
||||
"info,wgpu_core=warn,wgpu_hal=warn,zbus=warn,tracing=warn,calloop=warn,rawler=warn",
|
||||
))
|
||||
.build();
|
||||
let console =
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(DEFAULT_LOG))
|
||||
.build();
|
||||
let level = console.filter();
|
||||
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"),
|
||||
]);
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use dr_catalog::{keywords, rating, schema, Catalog};
|
||||
use dr_catalog::{keywords, name_dates, rating, schema, Catalog};
|
||||
|
||||
fn main() {
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
@@ -108,6 +108,9 @@ fn main() {
|
||||
time(" keywords::adopt_orphan_terms", 20, || {
|
||||
keywords::adopt_orphan_terms(conn).unwrap();
|
||||
});
|
||||
time(" name_dates::fill", 20, || {
|
||||
name_dates::fill(conn, None).unwrap();
|
||||
});
|
||||
|
||||
interactive(conn);
|
||||
|
||||
|
||||
@@ -306,6 +306,48 @@ pub fn record_exports(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every file name an album records, for an export choosing a name to know
|
||||
/// what it would land on.
|
||||
///
|
||||
/// A server album cannot be asked while the export is queued offline, and
|
||||
/// the names this app put there are the ones a second export of the same
|
||||
/// photographs will collide with. One read of the album's rows, not one per
|
||||
/// candidate name.
|
||||
pub fn file_names(
|
||||
conn: &Connection,
|
||||
id: AlbumId,
|
||||
) -> Result<std::collections::HashSet<String>, CatalogError> {
|
||||
ensure_tables(conn)?;
|
||||
let mut stmt = conn.prepare("SELECT file_name FROM album_exports WHERE album_id = ?1")?;
|
||||
let rows = stmt
|
||||
.query_map([id.0 as i64], |r| r.get(0))?
|
||||
.collect::<Result<_, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// A file the upload had to give another name: the server held one by the
|
||||
/// name the export recorded, put there by something this catalog never
|
||||
/// saw. The album row follows the file to the name it was given.
|
||||
///
|
||||
/// By the album's server folder, because that is all an outbox entry knows.
|
||||
/// `folder` is spelled as [`Place::Server`] spells it, without slashes at
|
||||
/// either end.
|
||||
pub fn rename_export(
|
||||
conn: &Connection,
|
||||
folder: &str,
|
||||
from: &str,
|
||||
to: &str,
|
||||
) -> Result<(), CatalogError> {
|
||||
ensure_tables(conn)?;
|
||||
conn.execute(
|
||||
"UPDATE OR REPLACE album_exports SET file_name = ?3
|
||||
WHERE file_name = ?2
|
||||
AND album_id IN (SELECT id FROM albums WHERE server_path = ?1 AND deleted = 0)",
|
||||
rusqlite::params![folder.trim_matches('/'), from, to],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The photographs behind an album's files, most recently exported first —
|
||||
/// what the grid shows when the album is opened.
|
||||
pub fn sources(conn: &Connection, id: AlbumId) -> Result<Vec<ImageId>, CatalogError> {
|
||||
@@ -463,6 +505,28 @@ mod tests {
|
||||
assert_eq!(sources(conn, album).unwrap(), vec![b]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_upload_moves_the_row_of_the_server_album_only() {
|
||||
let cat = catalog();
|
||||
let conn = cat.connection();
|
||||
let web = create(conn, "Web", &Place::Server("Albums/Web".into())).unwrap();
|
||||
let other = create(conn, "Other", &Place::Server("Albums/Other".into())).unwrap();
|
||||
let a = image(conn, "a.cr3");
|
||||
record_exports(conn, web, &[(a, "a.jpg".into())]).unwrap();
|
||||
record_exports(conn, other, &[(a, "a.jpg".into())]).unwrap();
|
||||
|
||||
rename_export(conn, "/Albums/Web", "a.jpg", "a-1.jpg").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
file_names(conn, web).unwrap(),
|
||||
["a-1.jpg".to_string()].into()
|
||||
);
|
||||
assert_eq!(
|
||||
file_names(conn, other).unwrap(),
|
||||
["a.jpg".to_string()].into()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_to_the_server_forgets_the_local_folder() {
|
||||
let cat = catalog();
|
||||
|
||||
@@ -50,6 +50,7 @@ pub mod faces;
|
||||
pub mod jobs;
|
||||
pub mod keywords;
|
||||
pub mod merge;
|
||||
pub mod name_dates;
|
||||
pub mod query;
|
||||
pub mod rating;
|
||||
pub mod recovery;
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
//! TRACES: FR-CAT-5
|
||||
//! A capture time read from the file's name, for an image whose header has
|
||||
//! none.
|
||||
//!
|
||||
//! # Why
|
||||
//!
|
||||
//! A photograph with no EXIF date sorts after everything else, so it is lost
|
||||
//! at the end of the grid and absent from the timeline. The files that end up
|
||||
//! there are rarely without a date — they are without *EXIF*: WhatsApp strips
|
||||
//! every tag and names the file `WhatsApp Image 2023-06-15 at 07.00.42.jpeg`,
|
||||
//! a Windows Phone wrote `WP_20140922_14_16_27_Pro.jpg`, a phone camera
|
||||
//! `IMG_20190812_153012.jpg`, and darktable's import renames to
|
||||
//! `20230629_0001.jpeg`. On the reference library 250 of 274 undated images
|
||||
//! carried their date in the name or in the folder above it.
|
||||
//!
|
||||
//! # What is accepted
|
||||
//!
|
||||
//! A date is `YYYYMMDD` as a whole run of digits, or `YYYY`, `MM` and `DD`
|
||||
//! joined by `-`, `_` or `.`. A time may follow it — `HHMMSS` as one run (or
|
||||
//! nine digits, milliseconds appended), or three two-digit runs joined by
|
||||
//! `-`, `_`, `.` or `:` — after `_`, `-`, `.`, `T`, a space or ` at `.
|
||||
//! Anything else after the date leaves it at midnight: `_0059` in
|
||||
//! `20230628_0059` is a sequence number, not 00:59, and reading it as a time
|
||||
//! would invent one.
|
||||
//!
|
||||
//! The name is tried first and then each folder above it, innermost first —
|
||||
//! `2016/2016-11-11/IMG_7910.jpg` is dated by its folder. A bare year folder
|
||||
//! is not a date: putting a photograph at 1 January is a wrong answer, and an
|
||||
//! undated one at least says it does not know.
|
||||
//!
|
||||
//! The reading is wall-clock time with no zone, stored as EXIF's is
|
||||
//! (`dr_decode::parse_exif_datetime`), and EXIF always wins: this only fills
|
||||
//! rows whose `captured_at` is still empty.
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::CatalogError;
|
||||
|
||||
/// The capture time a path's name states, as wall-clock Unix seconds.
|
||||
pub fn date_from_path(source_ref: &str) -> Option<i64> {
|
||||
let mut parts = source_ref.rsplit(['/', '\\']);
|
||||
let name = parts.next()?;
|
||||
let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem);
|
||||
date_in(stem).or_else(|| parts.find_map(date_in))
|
||||
}
|
||||
|
||||
/// Date every examined, undated image whose name states one.
|
||||
///
|
||||
/// `only` limits the pass to the images just examined — what the sweep hands
|
||||
/// in — and `None` visits every undated image, which is the backfill's case.
|
||||
/// Both read the undated side alone (`images_captured` answers
|
||||
/// `captured_at IS NULL` with a seek), never the library.
|
||||
///
|
||||
/// Returns how many images were dated.
|
||||
pub fn fill(conn: &Connection, only: Option<&[i64]>) -> Result<usize, CatalogError> {
|
||||
let rows: Vec<(i64, String)> = match only {
|
||||
None => {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, source_ref FROM images
|
||||
WHERE captured_at IS NULL AND metadata_state >= 2",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
|
||||
.collect::<Result<_, _>>()?;
|
||||
rows
|
||||
}
|
||||
Some(ids) => {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT source_ref FROM images
|
||||
WHERE id = ?1 AND captured_at IS NULL AND metadata_state >= 2",
|
||||
)?;
|
||||
let mut rows = Vec::new();
|
||||
for &id in ids {
|
||||
let mut q = stmt.query([id])?;
|
||||
if let Some(r) = q.next()? {
|
||||
rows.push((id, r.get(0)?));
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
};
|
||||
|
||||
let dated: Vec<(i64, i64)> = rows
|
||||
.iter()
|
||||
.filter_map(|(id, path)| date_from_path(path).map(|at| (*id, at)))
|
||||
.collect();
|
||||
if dated.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// A savepoint rather than a transaction, so a caller already inside one
|
||||
// can still call this: the backfill's 250 rows are one commit, not 250.
|
||||
conn.execute_batch("SAVEPOINT name_dates")?;
|
||||
let written = (|| {
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"UPDATE images SET captured_at = ?2 WHERE id = ?1 AND captured_at IS NULL",
|
||||
)?;
|
||||
let mut n = 0;
|
||||
for (id, at) in &dated {
|
||||
n += stmt.execute(rusqlite::params![id, at])?;
|
||||
}
|
||||
Ok::<_, CatalogError>(n)
|
||||
})();
|
||||
match written {
|
||||
Ok(n) => {
|
||||
conn.execute_batch("RELEASE name_dates")?;
|
||||
Ok(n)
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = conn.execute_batch("ROLLBACK TO name_dates; RELEASE name_dates");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first date, with its time if one follows, in one name component.
|
||||
fn date_in(s: &str) -> Option<i64> {
|
||||
let b = s.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
// Only at the start of a run of digits: a date inside a longer number
|
||||
// is a coincidence, not a date.
|
||||
if b[i].is_ascii_digit() && (i == 0 || !b[i - 1].is_ascii_digit()) {
|
||||
if let Some(at) = date_at(b, i) {
|
||||
return Some(at);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A date starting at `i`, and the time after it if there is one.
|
||||
fn date_at(b: &[u8], i: usize) -> Option<i64> {
|
||||
let run = digits(b, i);
|
||||
let ((y, mo, d), after) = match run.len() {
|
||||
// YYYYMMDD, or YYYYMMDDHHMMSS written as one number.
|
||||
8 | 14 => ((num(&run[..4]), num(&run[4..6]), num(&run[6..8])), i + 8),
|
||||
4 => {
|
||||
let sep = |at: usize| matches!(b.get(at), Some(b'-' | b'_' | b'.'));
|
||||
let mo_at = i + 4 + 1;
|
||||
let d_at = mo_at + 2 + 1;
|
||||
if !(sep(i + 4) && digits(b, mo_at).len() == 2 && sep(mo_at + 2))
|
||||
|| digits(b, d_at).len() != 2
|
||||
{
|
||||
return None;
|
||||
}
|
||||
(
|
||||
(num(run), num(&b[mo_at..mo_at + 2]), num(&b[d_at..d_at + 2])),
|
||||
d_at + 2,
|
||||
)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let day = civil_days(y, mo, d)?;
|
||||
|
||||
let time = if run.len() == 14 {
|
||||
hms(num(&run[8..10]), num(&run[10..12]), num(&run[12..14]))
|
||||
} else {
|
||||
time_at(b, after)
|
||||
};
|
||||
Some(day * 86_400 + time.unwrap_or(0))
|
||||
}
|
||||
|
||||
/// The time following a date that ends at `i`, as seconds into the day.
|
||||
fn time_at(b: &[u8], i: usize) -> Option<i64> {
|
||||
let rest = &b[i..];
|
||||
let start = if rest.starts_with(b" at ") {
|
||||
i + 4
|
||||
} else if matches!(rest.first(), Some(b'_' | b'-' | b'.' | b'T' | b' ')) {
|
||||
i + 1
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let run = digits(b, start);
|
||||
match run.len() {
|
||||
// HHMMSS, or with milliseconds appended (Pixel's PXL_…_123456789).
|
||||
6 | 9 => hms(num(&run[..2]), num(&run[2..4]), num(&run[4..6])),
|
||||
2 => {
|
||||
let sep = |at: usize| matches!(b.get(at), Some(b'-' | b'_' | b'.' | b':'));
|
||||
let (m_at, s_at) = (start + 3, start + 6);
|
||||
if !(sep(start + 2) && digits(b, m_at).len() == 2 && sep(m_at + 2))
|
||||
|| digits(b, s_at).len() != 2
|
||||
{
|
||||
return None;
|
||||
}
|
||||
hms(num(run), num(&b[m_at..m_at + 2]), num(&b[s_at..s_at + 2]))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The run of ASCII digits starting at `i`.
|
||||
fn digits(b: &[u8], i: usize) -> &[u8] {
|
||||
let rest = b.get(i..).unwrap_or(&[]);
|
||||
let n = rest.iter().take_while(|c| c.is_ascii_digit()).count();
|
||||
&rest[..n]
|
||||
}
|
||||
|
||||
fn num(d: &[u8]) -> i64 {
|
||||
d.iter().fold(0, |n, c| n * 10 + i64::from(c - b'0'))
|
||||
}
|
||||
|
||||
fn hms(h: i64, m: i64, s: i64) -> Option<i64> {
|
||||
((0..24).contains(&h) && (0..60).contains(&m) && (0..61).contains(&s))
|
||||
.then_some(h * 3_600 + m * 60 + s)
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 for a valid civil date, `None` for anything else.
|
||||
///
|
||||
/// The year range is EXIF's (`parse_exif_datetime`): wide enough for scanned
|
||||
/// film, narrow enough that a counter such as `12345678` is not a date.
|
||||
fn civil_days(y: i64, mo: i64, d: i64) -> Option<i64> {
|
||||
let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0);
|
||||
let month_len = match mo {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 if leap => 29,
|
||||
2 => 28,
|
||||
_ => return None,
|
||||
};
|
||||
if !(1900..=2200).contains(&y) || !(1..=month_len).contains(&d) {
|
||||
return None;
|
||||
}
|
||||
let y_adj = if mo <= 2 { y - 1 } else { y };
|
||||
let era = y_adj.div_euclid(400);
|
||||
let yoe = y_adj - era * 400;
|
||||
let mp = (mo + 9) % 12;
|
||||
let doy = (153 * mp + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
Some(era * 146_097 + doe - 719_468)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Wall-clock seconds for a date and time, the expected side of each case.
|
||||
fn at(y: i64, mo: i64, d: i64, h: i64, mi: i64, s: i64) -> Option<i64> {
|
||||
Some(civil_days(y, mo, d).unwrap() * 86_400 + h * 3_600 + mi * 60 + s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_names_in_the_reference_library_are_read() {
|
||||
// Every shape here is a file that sat undated at the end of the grid.
|
||||
for (path, want) in [
|
||||
(
|
||||
"PhotosRaw/alps trip/alps whatsapp/WhatsApp Image 2023-06-15 at 07.00.42.jpeg",
|
||||
at(2023, 6, 15, 7, 0, 42),
|
||||
),
|
||||
(
|
||||
"PhotosRaw/alps trip/alps whatsapp/WhatsApp Image 2023-06-17 at 12.45.52 (1).jpeg",
|
||||
at(2023, 6, 17, 12, 45, 52),
|
||||
),
|
||||
(
|
||||
"PhotosRaw/WP_20140922_14_16_27_Pro.jpg",
|
||||
at(2014, 9, 22, 14, 16, 27),
|
||||
),
|
||||
// A sequence number after the date is not a time.
|
||||
(
|
||||
"PhotosRaw/Darktable/20230629_no_name/20230629_0001.jpeg",
|
||||
at(2023, 6, 29, 0, 0, 0),
|
||||
),
|
||||
("PhotosRaw/20230628_0059.jpg", at(2023, 6, 28, 0, 0, 0)),
|
||||
(
|
||||
"PhotosRaw/backdrops/IMG_20130625_0021.jpg",
|
||||
at(2013, 6, 25, 0, 0, 0),
|
||||
),
|
||||
(
|
||||
"PhotosRaw/alps trip/20230628_0641 - 20230628_0661.jpg",
|
||||
at(2023, 6, 28, 0, 0, 0),
|
||||
),
|
||||
] {
|
||||
assert_eq!(date_from_path(path), want, "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_camera_and_app_names_are_read() {
|
||||
for (path, want) in [
|
||||
("IMG_20190812_153012.jpg", at(2019, 8, 12, 15, 30, 12)),
|
||||
("PXL_20210101_123456789.jpg", at(2021, 1, 1, 12, 34, 56)),
|
||||
(
|
||||
"Screenshot_2021-03-04-12-30-45.png",
|
||||
at(2021, 3, 4, 12, 30, 45),
|
||||
),
|
||||
(
|
||||
"Screenshot from 2021-03-04 12-30-45.png",
|
||||
at(2021, 3, 4, 12, 30, 45),
|
||||
),
|
||||
("IMG-20210304-WA0001.jpg", at(2021, 3, 4, 0, 0, 0)),
|
||||
("20210304143012.jpg", at(2021, 3, 4, 14, 30, 12)),
|
||||
("2019.12.25 party.jpg", at(2019, 12, 25, 0, 0, 0)),
|
||||
("signal-2022-01-02-101112.jpg", at(2022, 1, 2, 10, 11, 12)),
|
||||
("2022-01-02T10:11:12.jpg", at(2022, 1, 2, 10, 11, 12)),
|
||||
] {
|
||||
assert_eq!(date_from_path(path), want, "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_dates_a_name_that_does_not() {
|
||||
assert_eq!(
|
||||
date_from_path("PhotosRaw/2016/2016-11-11/IMG_7910.jpg"),
|
||||
at(2016, 11, 11, 0, 0, 0)
|
||||
);
|
||||
// The innermost folder that states a date wins.
|
||||
assert_eq!(
|
||||
date_from_path("2016-01-01 trip/2016-01-03/_MG_1.jpg"),
|
||||
at(2016, 1, 3, 0, 0, 0)
|
||||
);
|
||||
// The name beats its folder.
|
||||
assert_eq!(
|
||||
date_from_path("2016-11-11/IMG_20161112_080000.jpg"),
|
||||
at(2016, 11, 12, 8, 0, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_that_are_not_dates_are_left_alone() {
|
||||
for path in [
|
||||
"PhotosRaw/_MG_9002.jpg",
|
||||
"PhotosRaw/scanning/fau_2.jpg",
|
||||
// A year folder is not a day.
|
||||
"PhotosRaw/2016/_MG_1.jpg",
|
||||
"IMG_1999.jpg",
|
||||
"DSC_12345678.jpg", // month 56
|
||||
"20230230_0001.jpg", // 30 February
|
||||
"120230615.jpg", // the date is inside a longer number
|
||||
"1612345678901.jpg", // a millisecond epoch, not a civil date
|
||||
"2023-6-15.jpg", // a one-digit month is too loose to trust
|
||||
] {
|
||||
assert_eq!(date_from_path(path), None, "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_time_that_cannot_be_is_dropped_and_the_date_kept() {
|
||||
assert_eq!(
|
||||
date_from_path("20230615_256199.jpg"),
|
||||
at(2023, 6, 15, 0, 0, 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_dates_only_examined_undated_rows_and_never_overrides_exif() {
|
||||
let c = Connection::open_in_memory().unwrap();
|
||||
crate::schema::migrate(&c).unwrap();
|
||||
c.execute(
|
||||
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// (id, name, captured_at, metadata_state)
|
||||
for (id, name, captured, state) in [
|
||||
(1i64, "IMG_20190812_153012.jpg", None, 2i64),
|
||||
// EXIF already answered; the name disagrees and loses.
|
||||
(2, "IMG_20190812_153012b.jpg", Some(42i64), 2),
|
||||
// Not yet examined: EXIF may still come, so the name waits.
|
||||
(3, "IMG_20190813_000000.jpg", None, 1),
|
||||
(4, "_MG_9002.jpg", None, 2),
|
||||
] {
|
||||
c.execute(
|
||||
"INSERT INTO images(id, root_id, source_ref, captured_at, metadata_state, added_at)
|
||||
VALUES (?1, 1, ?2, ?3, ?4, 0)",
|
||||
rusqlite::params![id, name, captured, state],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let captured = |id: i64| -> Option<i64> {
|
||||
c.query_row("SELECT captured_at FROM images WHERE id = ?1", [id], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
assert_eq!(fill(&c, Some(&[2, 3, 4])).unwrap(), 0);
|
||||
assert_eq!(fill(&c, None).unwrap(), 1);
|
||||
assert_eq!(captured(1), at(2019, 8, 12, 15, 30, 12));
|
||||
assert_eq!(captured(2), Some(42));
|
||||
assert_eq!(captured(3), None);
|
||||
assert_eq!(captured(4), None);
|
||||
// Nothing left to do is a no-op, not a rewrite.
|
||||
assert_eq!(fill(&c, None).unwrap(), 0);
|
||||
}
|
||||
}
|
||||
@@ -356,6 +356,15 @@ pub fn backfill(conn: &Connection) -> Result<Vec<(&'static str, usize)>, Catalog
|
||||
out.push(("keyword_terms", n));
|
||||
}
|
||||
|
||||
// TRACES: FR-CAT-5
|
||||
// A date from the file's name for every examined image EXIF left undated.
|
||||
// The sweep does this as it examines each image; this is for the images
|
||||
// examined by a build that did not, and reads the undated side alone.
|
||||
let n = crate::name_dates::fill(conn, None)?;
|
||||
if n > 0 {
|
||||
out.push(("dates_from_names", n));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,10 @@ fn main() {
|
||||
println!("black {:?}", raw.black_level);
|
||||
println!("white {}", raw.white_level);
|
||||
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 {
|
||||
Some(m) => {
|
||||
|
||||
@@ -0,0 +1,806 @@
|
||||
//! TRACES: FR-DEV-3e
|
||||
//! DNG camera profiles: the tables on top of the matrix (D20).
|
||||
//!
|
||||
//! A profile is what [`crate::profile`] already reads — colour and forward
|
||||
//! matrices per calibration illuminant — plus two lookups over HSV: the
|
||||
//! `ProfileHueSatMap`, a calibration, and the `ProfileLookTable`, a rendering
|
||||
//! intent. `docs/dev/camera-profiles.md` is the design; this module finds
|
||||
//! them, in the order its §4 gives:
|
||||
//!
|
||||
//! 1. embedded in the DNG being decoded ([`Dcp::from_ifd`]);
|
||||
//! 2. a `.dcp` file in the profiles directory whose `UniqueCameraModel`
|
||||
//! names this body ([`find`]);
|
||||
//! 3. nowhere, and the matrix renders alone.
|
||||
//!
|
||||
//! A `.dcp` is a TIFF whose magic is `RC` (0x4352) rather than 42, holding one
|
||||
//! IFD of the same tags a DNG carries. rawler's TIFF reader does not check the
|
||||
//! magic, so both sources go through the one parser and [`Dcp::from_ifd`].
|
||||
//!
|
||||
//! Nothing here applies a table. The lookup is the shader's, with its CPU
|
||||
//! reference in `dr-pipeline`; this module resolves *which* tables, and blends
|
||||
//! the HueSatMap for the light the frame was shot under, once per decode.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock, RwLock};
|
||||
|
||||
use dr_types::{HueSatTable, ProfileOrigin, ProfileTables};
|
||||
use rawler::formats::tiff::{
|
||||
DirectoryWriter, GenericTiffReader, SRational, TiffWriter, Value, IFD,
|
||||
};
|
||||
use rawler::imgop::xyz::Illuminant;
|
||||
use rawler::tags::DngTag;
|
||||
|
||||
use crate::profile::{illuminant_temperature, Calibration, CameraProfile};
|
||||
|
||||
/// The magic a `.dcp` carries where a TIFF carries 42.
|
||||
const DCP_MAGIC: u16 = 0x4352;
|
||||
|
||||
/// `ProfileEmbedPolicy` values that permit copying a profile out of the file
|
||||
/// it came in: 0, "allow copying", and 3, "no restrictions". 1 ("embed if
|
||||
/// used") and 2 ("embed never") do not.
|
||||
const COPYABLE_POLICIES: [u32; 2] = [0, 3];
|
||||
|
||||
/// A camera profile as a DNG or a `.dcp` states it.
|
||||
///
|
||||
/// Indexed `[0]`/`[1]` for calibration 1 and 2, positionally, because that is
|
||||
/// how the file pairs a matrix and a table with its illuminant.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Dcp {
|
||||
/// `ProfileName`. Empty where the file names none.
|
||||
pub name: String,
|
||||
/// `UniqueCameraModel`: the body the profile was made for.
|
||||
pub unique_camera_model: Option<String>,
|
||||
pub copyright: Option<String>,
|
||||
pub calibration_signature: Option<String>,
|
||||
/// `ProfileEmbedPolicy`; 0 where absent, as the DNG specification
|
||||
/// defaults it.
|
||||
pub embed_policy: u32,
|
||||
/// `CalibrationIlluminant1/2`, as EXIF light-source codes.
|
||||
pub illuminants: [Option<u16>; 2],
|
||||
/// `ColorMatrix1/2`: XYZ → camera.
|
||||
pub color_matrix: [Option<[[f32; 3]; 3]>; 2],
|
||||
/// `ForwardMatrix1/2`: white-balanced camera → XYZ (D50).
|
||||
pub forward_matrix: [Option<[[f32; 3]; 3]>; 2],
|
||||
/// `ProfileHueSatMapData1/2`, sharing one dimensions tag.
|
||||
pub hue_sat: [Option<HueSatTable>; 2],
|
||||
/// `ProfileLookTableData`.
|
||||
pub look: Option<HueSatTable>,
|
||||
/// `ProfileToneCurve`, as stored: input/output pairs. Carried so a copy
|
||||
/// keeps it, never applied — tone is the view transform's (D19, D20).
|
||||
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 {
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// Read a profile out of an IFD — a DNG's root, or a `.dcp`'s only one.
|
||||
///
|
||||
/// `None` where the IFD carries neither table. A DNG always has matrices
|
||||
/// and the decoder already reads them; what makes a *profile* worth
|
||||
/// carrying separately is a table, so its absence is "no profile" rather
|
||||
/// than a profile that says nothing.
|
||||
pub fn from_ifd(ifd: &IFD) -> Option<Self> {
|
||||
let hue_sat_dims = dims(ifd, DngTag::ProfileHueSatMapDims);
|
||||
let hue_sat_srgb = encoding(ifd, DngTag::ProfileHueSatMapEncoding);
|
||||
let hue_sat = [DngTag::ProfileHueSatMapData1, DngTag::ProfileHueSatMapData2]
|
||||
.map(|tag| hue_sat_dims.and_then(|d| table(ifd, tag, d, hue_sat_srgb)));
|
||||
let look = dims(ifd, DngTag::ProfileLookTableDims).and_then(|d| {
|
||||
table(
|
||||
ifd,
|
||||
DngTag::ProfileLookTableData,
|
||||
d,
|
||||
encoding(ifd, DngTag::ProfileLookTableEncoding),
|
||||
)
|
||||
});
|
||||
if hue_sat[0].is_none() && hue_sat[1].is_none() && look.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
name: string(ifd, DngTag::ProfileName).unwrap_or_default(),
|
||||
unique_camera_model: string(ifd, DngTag::UniqueCameraModel),
|
||||
copyright: string(ifd, DngTag::ProfileCopyright),
|
||||
calibration_signature: string(ifd, DngTag::ProfileCalibrationSignature),
|
||||
embed_policy: ifd
|
||||
.get_entry(DngTag::ProfileEmbedPolicy)
|
||||
.and_then(|e| e.value.get_u32(0).ok().flatten())
|
||||
.unwrap_or(0),
|
||||
illuminants: [
|
||||
DngTag::CalibrationIlluminant1,
|
||||
DngTag::CalibrationIlluminant2,
|
||||
]
|
||||
.map(|tag| {
|
||||
ifd.get_entry(tag)
|
||||
.and_then(|e| e.value.get_u16(0).ok().flatten())
|
||||
}),
|
||||
color_matrix: [DngTag::ColorMatrix1, DngTag::ColorMatrix2].map(|t| matrix(ifd, t)),
|
||||
forward_matrix: [DngTag::ForwardMatrix1, DngTag::ForwardMatrix2]
|
||||
.map(|t| matrix(ifd, t)),
|
||||
hue_sat,
|
||||
look,
|
||||
tone_curve: ifd
|
||||
.get_entry(DngTag::ProfileToneCurve)
|
||||
.and_then(|e| floats(&e.value))
|
||||
.filter(|v| v.len() >= 4 && v.len() % 2 == 0),
|
||||
baseline_exposure_offset: stops(ifd, DngTag::BaselineExposureOffset),
|
||||
})
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// Parse a `.dcp` file's bytes.
|
||||
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
|
||||
if bytes.len() < 8 {
|
||||
return Err("too short to be a camera profile".into());
|
||||
}
|
||||
let magic = match &bytes[..2] {
|
||||
b"II" => u16::from_le_bytes([bytes[2], bytes[3]]),
|
||||
b"MM" => u16::from_be_bytes([bytes[2], bytes[3]]),
|
||||
_ => return Err("not a TIFF-structured file".into()),
|
||||
};
|
||||
if magic != DCP_MAGIC {
|
||||
return Err(format!("magic {magic:#x} is not a camera profile's"));
|
||||
}
|
||||
let reader =
|
||||
GenericTiffReader::new_with_buffer(bytes, 0, 0, Some(0)).map_err(|e| e.to_string())?;
|
||||
use rawler::formats::tiff::reader::TiffReader;
|
||||
let profile = Self::from_ifd(reader.root_ifd())
|
||||
.ok_or_else(|| "a profile with no HueSatMap and no LookTable".to_string())?;
|
||||
if profile.color_matrix[0].is_none() {
|
||||
return Err("a profile with no ColorMatrix1".into());
|
||||
}
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
/// Whether the file this profile came in allows it to be copied out
|
||||
/// (camera-profiles.md §4).
|
||||
pub fn may_copy(&self) -> bool {
|
||||
COPYABLE_POLICIES.contains(&self.embed_policy)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// Whether this profile was made for the body named.
|
||||
///
|
||||
/// `unique` is the file's own `UniqueCameraModel`, where a DNG carries
|
||||
/// one; `make` and `model` are rawler's cleaned names, joined as Adobe
|
||||
/// spells a body ("Canon EOS 6D"). Case and runs of spaces are ignored,
|
||||
/// because the two spellings come from different vendors' tables.
|
||||
pub fn is_for(&self, unique: Option<&str>, make: &str, model: &str) -> bool {
|
||||
let Some(mine) = self.unique_camera_model.as_deref().map(normalise) else {
|
||||
return false;
|
||||
};
|
||||
let joined = if normalise(model).starts_with(&normalise(make)) {
|
||||
normalise(model)
|
||||
} else {
|
||||
normalise(&format!("{make} {model}"))
|
||||
};
|
||||
unique.map(normalise).as_deref() == Some(mine.as_str()) || joined == mine
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The matrices this profile was built against, as the decoder's
|
||||
/// [`CameraProfile`], with the frame's own as-shot neutral.
|
||||
///
|
||||
/// A `.dcp` is a whole profile: its tables were measured relative to its
|
||||
/// forward matrix, so using them over the file's matrices would apply a
|
||||
/// correction for a different starting point. `None` where no calibration
|
||||
/// is usable, and the caller keeps the file's.
|
||||
pub fn camera_profile(&self, neutral: Option<[f32; 3]>) -> Option<CameraProfile> {
|
||||
let calibrations = (0..2)
|
||||
.filter_map(|i| {
|
||||
let xyz_to_cam = self.color_matrix[i]?;
|
||||
let temperature = self.temperature(i)?;
|
||||
Some(Calibration {
|
||||
temperature,
|
||||
xyz_to_cam,
|
||||
forward: self.forward_matrix[i],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
CameraProfile::new(calibrations, neutral)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The tables to render this frame with: the HueSatMap blended for the
|
||||
/// scene's colour temperature, by the same mired weight the matrices use,
|
||||
/// and the LookTable as it is.
|
||||
///
|
||||
/// Tables that change nothing are dropped here, so the shader is never
|
||||
/// asked to look up an identity.
|
||||
pub fn tables(&self, scene_temperature: f32, origin: ProfileOrigin) -> ProfileTables {
|
||||
let hue_sat = match (&self.hue_sat, self.temperature(0), self.temperature(1)) {
|
||||
([Some(a), Some(b)], Some(ta), Some(tb)) => {
|
||||
let t = mired_weight(ta, tb, scene_temperature);
|
||||
a.lerp(b, t).or_else(|| Some(a.clone()))
|
||||
}
|
||||
([Some(a), _], _, _) => Some(a.clone()),
|
||||
([None, Some(b)], _, _) => Some(b.clone()),
|
||||
([None, None], _, _) => None,
|
||||
};
|
||||
ProfileTables {
|
||||
name: self.name.clone(),
|
||||
origin,
|
||||
hue_sat: hue_sat.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),
|
||||
}
|
||||
}
|
||||
|
||||
fn temperature(&self, i: usize) -> Option<f32> {
|
||||
let code = self.illuminants[i]?;
|
||||
let illuminant: Illuminant = code.try_into().ok()?;
|
||||
illuminant_temperature(illuminant)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// This profile as `.dcp` bytes, for [`save`].
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, String> {
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
let writer = TiffWriter::new(&mut cursor).map_err(|e| e.to_string())?;
|
||||
let mut dir = DirectoryWriter::new();
|
||||
if let Some(model) = &self.unique_camera_model {
|
||||
dir.add_tag(DngTag::UniqueCameraModel, model.as_str());
|
||||
}
|
||||
dir.add_tag(DngTag::ProfileName, self.name.as_str());
|
||||
if let Some(c) = &self.copyright {
|
||||
dir.add_tag(DngTag::ProfileCopyright, c.as_str());
|
||||
}
|
||||
if let Some(s) = &self.calibration_signature {
|
||||
dir.add_tag(DngTag::ProfileCalibrationSignature, s.as_str());
|
||||
}
|
||||
dir.add_tag(DngTag::ProfileEmbedPolicy, self.embed_policy);
|
||||
let illuminant_tags = [
|
||||
DngTag::CalibrationIlluminant1,
|
||||
DngTag::CalibrationIlluminant2,
|
||||
];
|
||||
for (tag, code) in illuminant_tags.into_iter().zip(self.illuminants) {
|
||||
if let Some(code) = code {
|
||||
dir.add_tag(tag, code);
|
||||
}
|
||||
}
|
||||
for (tag, m) in [DngTag::ColorMatrix1, DngTag::ColorMatrix2]
|
||||
.into_iter()
|
||||
.zip(self.color_matrix)
|
||||
.chain(
|
||||
[DngTag::ForwardMatrix1, DngTag::ForwardMatrix2]
|
||||
.into_iter()
|
||||
.zip(self.forward_matrix),
|
||||
)
|
||||
{
|
||||
if let Some(m) = m {
|
||||
dir.add_value(tag, srational_matrix(&m));
|
||||
}
|
||||
}
|
||||
if let Some(first) = self.hue_sat.iter().flatten().next() {
|
||||
dir.add_tag(
|
||||
DngTag::ProfileHueSatMapDims,
|
||||
[
|
||||
first.hue_divisions,
|
||||
first.sat_divisions,
|
||||
first.val_divisions,
|
||||
],
|
||||
);
|
||||
dir.add_tag(
|
||||
DngTag::ProfileHueSatMapEncoding,
|
||||
u32::from(first.srgb_encoded),
|
||||
);
|
||||
for (tag, t) in [DngTag::ProfileHueSatMapData1, DngTag::ProfileHueSatMapData2]
|
||||
.into_iter()
|
||||
.zip(&self.hue_sat)
|
||||
{
|
||||
if let Some(t) = t {
|
||||
dir.add_value(
|
||||
tag,
|
||||
Value::Float(t.entries.iter().flatten().copied().collect()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(t) = &self.look {
|
||||
dir.add_tag(
|
||||
DngTag::ProfileLookTableDims,
|
||||
[t.hue_divisions, t.sat_divisions, t.val_divisions],
|
||||
);
|
||||
dir.add_tag(DngTag::ProfileLookTableEncoding, u32::from(t.srgb_encoded));
|
||||
dir.add_value(
|
||||
DngTag::ProfileLookTableData,
|
||||
Value::Float(t.entries.iter().flatten().copied().collect()),
|
||||
);
|
||||
}
|
||||
if let Some(curve) = &self.tone_curve {
|
||||
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())?;
|
||||
let mut bytes = cursor.into_inner();
|
||||
// The writer stamps TIFF's 42 in its own byte order; a profile is the
|
||||
// same structure with its own magic in the same place.
|
||||
bytes[2..4].copy_from_slice(&DCP_MAGIC.to_ne_bytes());
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// The weight toward calibration 2, by reciprocal temperature — the same
|
||||
/// interpolation [`CameraProfile`] gives the matrices, so the tables and the
|
||||
/// matrix agree about how far between the two lights a frame was shot.
|
||||
fn mired_weight(t1: f32, t2: f32, scene: f32) -> f32 {
|
||||
let mired = |k: f32| 1.0e6 / k.max(1.0);
|
||||
let (a, b) = (mired(t1), mired(t2));
|
||||
if (a - b).abs() < 1e-6 {
|
||||
return 0.0;
|
||||
}
|
||||
((mired(scene) - a) / (b - a)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn normalise(s: &str) -> String {
|
||||
s.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
fn string(ifd: &IFD, tag: DngTag) -> Option<String> {
|
||||
ifd.get_entry(tag)
|
||||
.and_then(|e| e.value.as_string().cloned())
|
||||
.map(|s| s.trim_end_matches('\0').trim().to_string())
|
||||
.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>> {
|
||||
(0..value.count())
|
||||
.map(|i| value.get_f32(i).ok().flatten())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn matrix(ifd: &IFD, tag: DngTag) -> Option<[[f32; 3]; 3]> {
|
||||
let v = floats(&ifd.get_entry(tag)?.value)?;
|
||||
if v.len() != 9 || v.iter().any(|x| !x.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some([[v[0], v[1], v[2]], [v[3], v[4], v[5]], [v[6], v[7], v[8]]])
|
||||
}
|
||||
|
||||
fn dims(ifd: &IFD, tag: DngTag) -> Option<[u32; 3]> {
|
||||
let e = ifd.get_entry(tag)?;
|
||||
let at = |i| e.value.get_u32(i).ok().flatten();
|
||||
Some([at(0)?, at(1)?, at(2)?])
|
||||
}
|
||||
|
||||
fn encoding(ifd: &IFD, tag: DngTag) -> bool {
|
||||
ifd.get_entry(tag)
|
||||
.and_then(|e| e.value.get_u32(0).ok().flatten())
|
||||
== Some(1)
|
||||
}
|
||||
|
||||
fn table(ifd: &IFD, tag: DngTag, [h, s, v]: [u32; 3], srgb: bool) -> Option<HueSatTable> {
|
||||
let data = floats(&ifd.get_entry(tag)?.value)?;
|
||||
if data.len() % 3 != 0 {
|
||||
return None;
|
||||
}
|
||||
let entries = data.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect();
|
||||
HueSatTable::new(h, s, v, srgb, entries)
|
||||
}
|
||||
|
||||
fn srational_matrix(m: &[[f32; 3]; 3]) -> Value {
|
||||
const SCALE: i32 = 10_000;
|
||||
Value::SRational(
|
||||
m.iter()
|
||||
.flatten()
|
||||
.map(|v| SRational::new((v * SCALE as f32).round() as i32, SCALE))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- the profiles directory -------------------------------------------------
|
||||
|
||||
/// The `.dcp` files the photographer has installed, loaded once per process.
|
||||
struct Library {
|
||||
dir: PathBuf,
|
||||
/// `(file name, profile)`, sorted by file name so that two profiles for
|
||||
/// one body resolve the same way on every run (camera-profiles.md §4).
|
||||
profiles: Vec<(String, Arc<Dcp>)>,
|
||||
}
|
||||
|
||||
fn library() -> &'static RwLock<Option<Library>> {
|
||||
static LIBRARY: OnceLock<RwLock<Option<Library>>> = OnceLock::new();
|
||||
LIBRARY.get_or_init(|| RwLock::new(None))
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// Name the profiles directory and read every `.dcp` in it.
|
||||
///
|
||||
/// Called once at start-up by the application, with a path under the
|
||||
/// platform data directory. A decode before this, or in a process that never
|
||||
/// calls it (a test, a bench), finds no directory profiles, which is the
|
||||
/// matrix-only render it always had.
|
||||
pub fn set_profiles_directory(dir: PathBuf) {
|
||||
let profiles = load(&dir);
|
||||
if let Ok(mut lib) = library().write() {
|
||||
*lib = Some(Library { dir, profiles });
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory [`set_profiles_directory`] named, if any.
|
||||
pub fn profiles_directory() -> Option<PathBuf> {
|
||||
library().read().ok()?.as_ref().map(|l| l.dir.clone())
|
||||
}
|
||||
|
||||
fn load(dir: &Path) -> Vec<(String, Arc<Dcp>)> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<(String, Arc<Dcp>)> = entries
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.is_some_and(|x| x.eq_ignore_ascii_case("dcp"))
|
||||
})
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
let bytes = std::fs::read(e.path()).ok()?;
|
||||
match Dcp::parse(&bytes) {
|
||||
Ok(p) => Some((name, Arc::new(p))),
|
||||
Err(why) => {
|
||||
log::warn!("camera profile {name} skipped: {why}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
log::info!(
|
||||
"camera profiles: {} loaded from {}",
|
||||
out.len(),
|
||||
dir.display()
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The first installed profile, by file name, made for this body.
|
||||
pub fn find(unique: Option<&str>, make: &str, model: &str) -> Option<(String, Arc<Dcp>)> {
|
||||
let lib = library().read().ok()?;
|
||||
lib.as_ref()?
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|(_, p)| p.is_for(unique, make, model))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// Save a profile copied out of a photograph into the profiles directory, and
|
||||
/// make it available to the next decode.
|
||||
///
|
||||
/// Refuses a profile whose embed policy does not allow copying, and refuses
|
||||
/// when no directory is set. Named after the body and the profile, so a
|
||||
/// second copy of the same profile replaces the first rather than piling up.
|
||||
pub fn save(profile: &Dcp) -> Result<PathBuf, String> {
|
||||
if !profile.may_copy() {
|
||||
return Err("this profile's embed policy does not allow copying it".into());
|
||||
}
|
||||
let model = profile
|
||||
.unique_camera_model
|
||||
.as_deref()
|
||||
.ok_or("the profile names no camera")?;
|
||||
let dir = profiles_directory().ok_or("no profiles directory is set")?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let file_name: String = format!("{model} {}.dcp", profile.name)
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || " -_.".contains(c) {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let path = dir.join(file_name.trim());
|
||||
std::fs::write(&path, profile.to_bytes()?).map_err(|e| e.to_string())?;
|
||||
set_profiles_directory(dir);
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The profile embedded in a file, read on demand — for the panel's offer to
|
||||
/// copy it, which happens long after the decode that rendered it.
|
||||
///
|
||||
/// Reads the header only; no photosite is unpacked.
|
||||
pub fn embedded_in(bytes: &[u8]) -> Option<Dcp> {
|
||||
let source = rawler::rawsource::RawSource::new_from_slice(bytes);
|
||||
let decoder = rawler::get_decoder(&source).ok()?;
|
||||
let root = decoder
|
||||
.ifd(rawler::decoders::WellKnownIFD::Root)
|
||||
.ok()
|
||||
.flatten()?;
|
||||
// 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
|
||||
/// What one decode resolved: the matrices to render through, the tables on
|
||||
/// top of them, and the embedded profile if the file had one — kept whole so
|
||||
/// the panel can offer to copy it.
|
||||
pub struct Resolved {
|
||||
pub profile: Option<CameraProfile>,
|
||||
pub tables: Option<Arc<ProfileTables>>,
|
||||
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
|
||||
/// Apply camera-profiles.md §4's order to one decoded file.
|
||||
///
|
||||
/// `matrices` is the profile the decoder built from the file; `root` the
|
||||
/// file's root IFD, where a DNG keeps its embedded profile.
|
||||
pub fn resolve(
|
||||
matrices: Option<CameraProfile>,
|
||||
root: Option<&IFD>,
|
||||
make: &str,
|
||||
model: &str,
|
||||
) -> Resolved {
|
||||
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 {
|
||||
let tables = matrices
|
||||
.as_ref()
|
||||
.map(|m| dcp.tables(m.scene_temperature(), ProfileOrigin::Embedded))
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(Arc::new);
|
||||
return Resolved {
|
||||
profile: matrices,
|
||||
tables,
|
||||
baseline_exposure: file_baseline + dcp.baseline_exposure_offset,
|
||||
embedded,
|
||||
};
|
||||
}
|
||||
let unique = root.and_then(|r| string(r, DngTag::UniqueCameraModel));
|
||||
if let Some((file, dcp)) = find(unique.as_deref(), make, model) {
|
||||
let neutral = matrices.as_ref().and_then(|m| m.neutral());
|
||||
if let Some(own) = dcp.camera_profile(neutral) {
|
||||
let tables = dcp.tables(own.scene_temperature(), ProfileOrigin::File(file));
|
||||
return Resolved {
|
||||
tables: (!tables.is_empty()).then(|| Arc::new(tables)),
|
||||
profile: Some(own),
|
||||
embedded: None,
|
||||
baseline_exposure: file_baseline + dcp.baseline_exposure_offset,
|
||||
};
|
||||
}
|
||||
}
|
||||
Resolved {
|
||||
profile: matrices,
|
||||
tables: None,
|
||||
embedded: None,
|
||||
baseline_exposure: file_baseline,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn table(h: u32, s: u32, v: u32, fill: [f32; 3]) -> HueSatTable {
|
||||
HueSatTable::new(h, s, v, false, vec![fill; (h * s * v) as usize]).unwrap()
|
||||
}
|
||||
|
||||
fn sample() -> Dcp {
|
||||
Dcp {
|
||||
name: "Test Standard".into(),
|
||||
unique_camera_model: Some("Canon EOS 6D".into()),
|
||||
copyright: Some("nobody".into()),
|
||||
calibration_signature: Some("com.example".into()),
|
||||
embed_policy: 0,
|
||||
illuminants: [Some(17), Some(21)],
|
||||
color_matrix: [
|
||||
Some([
|
||||
[0.7546, -0.1435, -0.0929],
|
||||
[-0.3846, 1.1488, 0.2692],
|
||||
[-0.0332, 0.1209, 0.637],
|
||||
]),
|
||||
Some([
|
||||
[0.7034, -0.0804, -0.1014],
|
||||
[-0.442, 1.2564, 0.2058],
|
||||
[-0.0851, 0.1994, 0.5758],
|
||||
]),
|
||||
],
|
||||
forward_matrix: [
|
||||
Some([
|
||||
[0.7763, 0.0065, 0.1815],
|
||||
[0.2364, 0.8351, -0.0715],
|
||||
[-0.0059, -0.4228, 1.2538],
|
||||
]),
|
||||
Some([
|
||||
[0.7464, 0.1044, 0.1135],
|
||||
[0.2648, 0.9173, -0.182],
|
||||
[0.0113, -0.2154, 1.0292],
|
||||
]),
|
||||
],
|
||||
hue_sat: [
|
||||
Some(table(6, 3, 1, [2.0, 1.1, 1.0])),
|
||||
Some(table(6, 3, 1, [-2.0, 0.9, 1.0])),
|
||||
],
|
||||
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]),
|
||||
baseline_exposure_offset: 0.25,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_survives_being_written_and_read_back() {
|
||||
let original = sample();
|
||||
let bytes = original.to_bytes().unwrap();
|
||||
assert_eq!(&bytes[2..4], &DCP_MAGIC.to_ne_bytes());
|
||||
let back = Dcp::parse(&bytes).unwrap();
|
||||
assert_eq!(
|
||||
back.hue_sat, original.hue_sat,
|
||||
"tables are stored as f32 and come back exact"
|
||||
);
|
||||
assert_eq!(back.look, original.look);
|
||||
assert_eq!(back.name, original.name);
|
||||
assert_eq!(back.unique_camera_model, original.unique_camera_model);
|
||||
assert_eq!(back.illuminants, original.illuminants);
|
||||
assert_eq!(back.tone_curve, original.tone_curve);
|
||||
assert_eq!(back.baseline_exposure_offset, 0.25);
|
||||
assert_eq!(
|
||||
back.forward_matrix, original.forward_matrix,
|
||||
"four decimals, as the file has"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tiff_is_not_a_profile() {
|
||||
let mut bytes = sample().to_bytes().unwrap();
|
||||
bytes[2..4].copy_from_slice(&42u16.to_ne_bytes());
|
||||
assert!(Dcp::parse(&bytes).is_err());
|
||||
assert!(Dcp::parse(b"nonsense").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_matches_by_unique_model_or_by_make_and_model() {
|
||||
let p = sample();
|
||||
assert!(p.is_for(None, "Canon", "EOS 6D"));
|
||||
assert!(p.is_for(None, "canon", "eos 6d"));
|
||||
assert!(p.is_for(Some("Canon EOS 6D"), "", ""));
|
||||
assert!(!p.is_for(None, "Canon", "EOS 6D Mark II"));
|
||||
assert!(!p.is_for(Some("Canon EOS 5D"), "Canon", "EOS 5D"));
|
||||
// A model that already starts with the make is not doubled.
|
||||
assert!(p.is_for(None, "Canon", "Canon EOS 6D"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hue_sat_map_follows_the_light_the_frame_was_shot_under() {
|
||||
let p = sample();
|
||||
let at = |k| {
|
||||
p.tables(k, ProfileOrigin::Embedded)
|
||||
.hue_sat
|
||||
.unwrap()
|
||||
.entries[0]
|
||||
};
|
||||
assert_eq!(at(2856.0), [2.0, 1.1, 1.0], "tungsten is calibration 1");
|
||||
assert_eq!(at(6504.0), [-2.0, 0.9, 1.0], "daylight is calibration 2");
|
||||
let mid = at(4000.0);
|
||||
assert!(mid[0] > -2.0 && mid[0] < 2.0, "{mid:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_that_changes_nothing_is_not_handed_on() {
|
||||
let mut p = sample();
|
||||
p.hue_sat = [Some(table(6, 3, 1, [0.0, 1.0, 1.0])), None];
|
||||
let t = p.tables(5000.0, ProfileOrigin::Embedded);
|
||||
assert!(t.hue_sat.is_none());
|
||||
assert!(t.look.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_copyable_policy_may_be_copied() {
|
||||
let mut p = sample();
|
||||
for (policy, ok) in [(0, true), (1, false), (2, false), (3, true)] {
|
||||
p.embed_policy = policy;
|
||||
assert_eq!(p.may_copy(), ok, "policy {policy}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A Canon 6D DNG from the library, written by Lightroom 6.14 with Adobe
|
||||
/// Standard embedded. Read from `DR_DCP_SAMPLE`, else the library path the
|
||||
/// figures in camera-profiles.md §1 came from; skipped where neither
|
||||
/// exists, because the file is not ours to put in the repository.
|
||||
fn six_d_dng() -> Option<Vec<u8>> {
|
||||
let path = std::env::var_os("DR_DCP_SAMPLE")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::var_os("HOME").map(|h| {
|
||||
PathBuf::from(h).join("Nextcloud/PhotosRaw/2017/2017-08-12/_MG_9080.dng")
|
||||
})
|
||||
})?;
|
||||
let bytes = std::fs::read(&path).ok();
|
||||
if bytes.is_none() {
|
||||
eprintln!("skipped: no sample DNG at {}", path.display());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_libraries_six_d_dngs_carry_adobe_standard() {
|
||||
let Some(bytes) = six_d_dng() else { return };
|
||||
let p = embedded_in(&bytes).expect("an embedded profile");
|
||||
assert_eq!(p.name, "Adobe Standard");
|
||||
assert_eq!(p.unique_camera_model.as_deref(), Some("Canon EOS 6D"));
|
||||
assert_eq!(p.embed_policy, 0);
|
||||
let hs = p.hue_sat[0].as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(hs.hue_divisions, hs.sat_divisions, hs.val_divisions),
|
||||
(90, 30, 1)
|
||||
);
|
||||
assert!(p.hue_sat[1].is_some());
|
||||
let look = p.look.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(look.hue_divisions, look.sat_divisions, look.val_divisions),
|
||||
(36, 8, 16)
|
||||
);
|
||||
assert!(p.tone_curve.is_none());
|
||||
assert!(p.may_copy());
|
||||
|
||||
let back = Dcp::parse(&p.to_bytes().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
back.hue_sat, p.hue_sat,
|
||||
"a copied profile keeps its tables bit for bit"
|
||||
);
|
||||
assert_eq!(back.look, p.look);
|
||||
assert!(
|
||||
back.is_for(None, "Canon", "EOS 6D"),
|
||||
"and so applies to the body's CR2s"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoding_the_six_d_dng_hands_on_its_tables() {
|
||||
let Some(bytes) = six_d_dng() else { return };
|
||||
let raw = crate::decode(&bytes).unwrap();
|
||||
let tables = raw.profile_tables.expect("tables");
|
||||
assert_eq!(tables.origin, ProfileOrigin::Embedded);
|
||||
assert_eq!(tables.name, "Adobe Standard");
|
||||
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]
|
||||
fn a_profile_brings_its_own_matrices() {
|
||||
let p = sample();
|
||||
let cam = p.camera_profile(Some([0.5, 1.0, 0.7])).unwrap();
|
||||
assert_eq!(cam.calibrations().len(), 2);
|
||||
assert!(cam.calibrations().iter().all(|c| c.forward.is_some()));
|
||||
assert!(cam.cam_to_srgb().is_some());
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
//! second decoder can be put behind them without changing any of them
|
||||
//! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out.
|
||||
|
||||
pub mod dcp;
|
||||
mod decoder;
|
||||
mod error;
|
||||
mod locate;
|
||||
@@ -137,8 +138,23 @@ pub struct RawImage {
|
||||
/// carry on: calibrations and the as-shot neutral. `None` for a body the
|
||||
/// decoder has no matrix for.
|
||||
pub profile: Option<profile::CameraProfile>,
|
||||
/// The body, as rawler cleans the names: what `Make`/`Model` say and what
|
||||
/// the base-curve database matches on.
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The camera profile's HueSatMap and LookTable, resolved for this frame
|
||||
/// (D20): embedded in the DNG, or from a matched `.dcp`. `None` renders
|
||||
/// through the matrix alone.
|
||||
///
|
||||
/// Carried with the image, as `color_matrix` is, so that every path that
|
||||
/// renders a decoded file renders it through the same profile without
|
||||
/// having to be told — see camera-profiles.md §3.
|
||||
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
|
||||
/// what a `.dcp`'s `UniqueCameraModel` is matched against.
|
||||
pub make: String,
|
||||
pub model: String,
|
||||
}
|
||||
@@ -534,6 +550,17 @@ pub(crate) fn parse_exif_offset(s: &str) -> Option<i32> {
|
||||
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
|
||||
/// Fully decode sensor data.
|
||||
///
|
||||
@@ -571,6 +598,24 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
||||
// is now structural, because there is only one interpolated matrix and
|
||||
// both callers ask the same object for it.
|
||||
let profile = profile::CameraProfile::extract(&image, &dng);
|
||||
// TRACES: FR-DEV-3e
|
||||
// The tables, and — where a `.dcp` supplies them — the matrices they were
|
||||
// built against, which then stand in for the file's (D20).
|
||||
let root = decoder
|
||||
.ifd(rawler::decoders::WellKnownIFD::Root)
|
||||
.ok()
|
||||
.flatten();
|
||||
let dcp::Resolved {
|
||||
profile,
|
||||
tables: profile_tables,
|
||||
baseline_exposure,
|
||||
..
|
||||
} = dcp::resolve(
|
||||
profile,
|
||||
root.as_deref(),
|
||||
&image.camera.clean_make,
|
||||
&image.camera.clean_model,
|
||||
);
|
||||
let color_matrix = profile.as_ref().and_then(|p| p.cam_to_srgb());
|
||||
let wb_coeffs = sane_wb(
|
||||
image.wb_coeffs,
|
||||
@@ -661,6 +706,8 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
||||
color_matrix,
|
||||
samples_per_pixel,
|
||||
profile,
|
||||
profile_tables,
|
||||
baseline_exposure,
|
||||
make: image.camera.clean_make.clone(),
|
||||
model: image.camera.clean_model.clone(),
|
||||
})
|
||||
|
||||
@@ -521,7 +521,7 @@ fn cct_from_xy(x: f32, y: f32) -> f32 {
|
||||
/// but a profile calibrated under fluorescent light is describing a sensor
|
||||
/// under fluorescent light, and placing it at roughly the right colour is much
|
||||
/// better than discarding it.
|
||||
fn illuminant_temperature(illuminant: Illuminant) -> Option<f32> {
|
||||
pub(crate) fn illuminant_temperature(illuminant: Illuminant) -> Option<f32> {
|
||||
Some(match illuminant {
|
||||
// CIE standard illuminant A: a tungsten filament at 2856 K. The low
|
||||
// end of essentially every dual-illuminant profile ever written.
|
||||
@@ -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)]
|
||||
mod tests {
|
||||
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}
|
||||
@@ -24,7 +24,7 @@ fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let Some(input) = args.next() else {
|
||||
eprintln!("usage: develop <file.cr2> [out.ppm] [preset]");
|
||||
eprintln!(" preset: neutral (default) | punchy | recover");
|
||||
eprintln!(" preset: neutral (default) | matrix | look200 | punchy | recover | …");
|
||||
std::process::exit(2);
|
||||
};
|
||||
let output = args.next().unwrap_or_else(|| "develop.ppm".into());
|
||||
@@ -78,6 +78,24 @@ fn main() {
|
||||
graph.set_param(brilliance::ID, brilliance::BRILLIANCE, 40.0);
|
||||
graph.set_param(white_balance::ID, white_balance::TEMPERATURE, 15.0);
|
||||
}
|
||||
// The camera profile switched off: the matrix alone, as every
|
||||
// photograph rendered before D20. Beside "neutral" on a DNG that
|
||||
// embeds a profile, the difference is the profile's tables.
|
||||
"matrix" => {
|
||||
graph.set_param(
|
||||
dr_pipeline::ops::camera_profile::ID,
|
||||
dr_pipeline::ops::camera_profile::APPLY,
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
// The profile's look table at twice its strength.
|
||||
"look200" => {
|
||||
graph.set_param(
|
||||
dr_pipeline::ops::camera_profile::ID,
|
||||
dr_pipeline::ops::camera_profile::LOOK,
|
||||
200.0,
|
||||
);
|
||||
}
|
||||
// Contrast alone, so its effect can be judged without anything else
|
||||
// moving.
|
||||
"contrast" => {
|
||||
@@ -109,6 +127,14 @@ fn main() {
|
||||
graph.set_param(curve::ID, curve::P0_Y, 0.12);
|
||||
graph.set_param(curve::ID, curve::P1_Y, 0.32);
|
||||
}
|
||||
// A shipped preset by name — `preset:Vivid landscape` — applied as
|
||||
// the presets menu applies it, so a look can be judged on a real file.
|
||||
named if named.starts_with("preset:") => {
|
||||
let name = &named["preset:".len()..];
|
||||
let preset = dr_pipeline::bundled::lookup(&Default::default(), name)
|
||||
.unwrap_or_else(|| panic!("no shipped preset called {name:?}"));
|
||||
let _ = preset.apply(&mut graph, dr_pipeline::Scope::adjustments());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -123,8 +149,15 @@ fn main() {
|
||||
let mut adjust = AdjustPass::new(&ctx);
|
||||
let (w, h) = image.size();
|
||||
|
||||
// Through the detail stage when the edit has one — clarity, sharpening
|
||||
// — which is the path every frontend takes; `render` alone refuses such
|
||||
// a shader.
|
||||
let t2 = std::time::Instant::now();
|
||||
adjust.render(&image, &shader, w, h).expect("adjust");
|
||||
let detail = graph.compose_detail(image.size(), (w, h));
|
||||
let key = graph.invalidation().through(dr_pipeline::Affects::Colour);
|
||||
adjust
|
||||
.render_detailed(&image, &shader, w, h, None, &detail, key)
|
||||
.expect("adjust");
|
||||
ctx.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.expect("poll");
|
||||
@@ -134,8 +167,11 @@ fn main() {
|
||||
// path, and it must not recompile.
|
||||
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.31);
|
||||
let again = graph.compose();
|
||||
let key = graph.invalidation().through(dr_pipeline::Affects::Colour);
|
||||
let t3 = std::time::Instant::now();
|
||||
adjust.render(&image, &again, w, h).expect("adjust");
|
||||
adjust
|
||||
.render_detailed(&image, &again, w, h, None, &detail, key)
|
||||
.expect("adjust");
|
||||
ctx.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.expect("poll");
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -71,6 +71,14 @@ pub struct AdjustPass {
|
||||
empty_film_lut: wgpu::TextureView,
|
||||
/// The loaded stock's tables, once uploaded. See [`Self::set_film`].
|
||||
film: Option<FilmTextures>,
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// Bound at `@binding(8)` for a source with no camera profile tables: the
|
||||
/// two-entry header of zeros that tells the fragment there is nothing to
|
||||
/// apply (D20).
|
||||
empty_profile: wgpu::Buffer,
|
||||
/// The current source's tables, uploaded, keyed by
|
||||
/// [`DemosaicedImage::id`] — one upload per source rather than per frame.
|
||||
profile: Option<(u64, wgpu::Buffer)>,
|
||||
/// TRACES: FR-DEV-3 | FR-DEV-3d
|
||||
/// The neighbourhood stage — sharpening, noise reduction, clarity and the
|
||||
/// rest of FR-DEV-3's detail set, which cannot be fused into the shader
|
||||
@@ -512,6 +520,37 @@ impl AdjustPass {
|
||||
}
|
||||
|
||||
/// The curve texture to bind: the loaded stock's, or the placeholder.
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// A source's camera profile tables as the storage buffer
|
||||
/// `@binding(8)` reads, laid out by `dr_pipeline`'s `profile_buffer`.
|
||||
fn upload_profile(ctx: &GpuContext, tables: Option<&dr_types::ProfileTables>) -> wgpu::Buffer {
|
||||
let data = dr_pipeline::ops::camera_profile::profile_buffer(tables);
|
||||
ctx.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("adjust-profile-tables"),
|
||||
contents: bytemuck::cast_slice(&data),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
})
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The buffer to bind for `source`: its tables, uploaded once per source,
|
||||
/// or the empty header. A cheap handle, cloned out so a caller holding
|
||||
/// other borrows of `self` can bind it.
|
||||
fn profile_buffer(&mut self, source: &DemosaicedImage) -> wgpu::Buffer {
|
||||
let Some(tables) = source.profile_tables() else {
|
||||
return self.empty_profile.clone();
|
||||
};
|
||||
if let Some((id, buffer)) = &self.profile {
|
||||
if *id == source.id() {
|
||||
return buffer.clone();
|
||||
}
|
||||
}
|
||||
let buffer = Self::upload_profile(&self.ctx, Some(tables));
|
||||
self.profile = Some((source.id(), buffer.clone()));
|
||||
buffer
|
||||
}
|
||||
|
||||
fn film_curves_view(&self) -> &wgpu::TextureView {
|
||||
self.film
|
||||
.as_ref()
|
||||
@@ -645,6 +684,8 @@ impl AdjustPass {
|
||||
empty_film_curves,
|
||||
empty_film_lut,
|
||||
film: None,
|
||||
empty_profile: Self::upload_profile(ctx, None),
|
||||
profile: None,
|
||||
detail: DetailRunner::new(ctx),
|
||||
linear_bind_group_layout,
|
||||
linear_pipeline_layout,
|
||||
@@ -775,6 +816,17 @@ impl AdjustPass {
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
// The camera profile's tables (FR-DEV-3e, D20).
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 8,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Storage { read_only: true },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -973,6 +1025,7 @@ impl AdjustPass {
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
|
||||
let profile = self.profile_buffer(source);
|
||||
let pipeline = self
|
||||
.cache
|
||||
.get(&shader.structure_hash)
|
||||
@@ -1020,6 +1073,10 @@ impl AdjustPass {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&sample_out),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: profile.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1139,6 +1196,7 @@ impl AdjustPass {
|
||||
// be read off `self` at the point the bind group is built.
|
||||
let film_curves = self.film_curves_view().clone();
|
||||
let film_lut = self.film_lut_view().clone();
|
||||
let profile = self.profile_buffer(source);
|
||||
|
||||
let mut enc = self
|
||||
.ctx
|
||||
@@ -1202,6 +1260,10 @@ impl AdjustPass {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&sample_out),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: profile.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline = self
|
||||
@@ -1298,6 +1360,10 @@ impl AdjustPass {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&no_sample_out),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: profile.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
{
|
||||
@@ -1609,6 +1675,10 @@ impl AdjustPass {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&self.sample.no_sample_out),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: self.empty_profile.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1818,6 +1888,8 @@ mod tests {
|
||||
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 {
|
||||
@@ -2021,6 +2093,8 @@ mod tests {
|
||||
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 {
|
||||
@@ -2629,6 +2703,8 @@ mod tests {
|
||||
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 {
|
||||
@@ -2732,6 +2808,8 @@ mod tests {
|
||||
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 {
|
||||
@@ -3342,8 +3420,15 @@ mod tests {
|
||||
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 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 delta = (i32::from(from_sensor[0]) - expected).abs();
|
||||
assert!(
|
||||
|
||||
+299
-64
@@ -107,6 +107,11 @@ pub struct DemosaicedImage {
|
||||
height: u32,
|
||||
/// Carried through for the camera→sRGB transform in the adjust pass.
|
||||
color_matrix: [f32; 9],
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The camera profile's tables, carried through with the matrix for the
|
||||
/// adjust pass to upload (D20). `None` for a JPEG and for a raw with no
|
||||
/// profile.
|
||||
profile_tables: Option<std::sync::Arc<dr_types::ProfileTables>>,
|
||||
/// As-shot white balance, the neutral starting point for the WB control.
|
||||
as_shot_wb: [f32; 3],
|
||||
/// Whether the texture holds gamma-encoded rather than linear values.
|
||||
@@ -207,6 +212,12 @@ impl DemosaicedImage {
|
||||
self.color_matrix
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The camera profile's tables this source renders through, if any.
|
||||
pub fn profile_tables(&self) -> Option<&std::sync::Arc<dr_types::ProfileTables>> {
|
||||
self.profile_tables.as_ref()
|
||||
}
|
||||
|
||||
/// As-shot white balance multipliers, green-normalised.
|
||||
///
|
||||
/// The white balance control is expressed *relative* to these, so its
|
||||
@@ -318,6 +329,7 @@ impl DemosaicedImage {
|
||||
width,
|
||||
height,
|
||||
color_matrix: IDENTITY_3X3,
|
||||
profile_tables: None,
|
||||
as_shot_wb: [1.0, 1.0, 1.0],
|
||||
// **The identity, and this is the whole reason the field is here
|
||||
// rather than resolved further down.** A JPEG has already been
|
||||
@@ -334,6 +346,95 @@ 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
|
||||
/// 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
|
||||
@@ -477,7 +578,8 @@ impl DemosaicedImage {
|
||||
view,
|
||||
width,
|
||||
height,
|
||||
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
|
||||
color_matrix: rendering_matrix(raw),
|
||||
profile_tables: raw.profile_tables.clone(),
|
||||
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
|
||||
non_linear: false,
|
||||
id: next_image_id(),
|
||||
@@ -487,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,
|
||||
/// subnormals, round-to-nearest-even, saturation at the largest finite.
|
||||
///
|
||||
@@ -748,62 +867,14 @@ impl Demosaicer {
|
||||
|
||||
// TRACES: FR-RAW-3
|
||||
// The mosaic the demosaic actually reads: the readout with its hot and
|
||||
// dead photosites repaired. 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,
|
||||
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(
|
||||
// dead photosites repaired.
|
||||
let hot = self.hot_pass(
|
||||
raw,
|
||||
(width, height),
|
||||
words,
|
||||
groups_x * HOT_PIXEL_GROUP,
|
||||
&raw_buf,
|
||||
packed.len() as u32,
|
||||
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
|
||||
.ctx
|
||||
.device
|
||||
@@ -842,7 +913,7 @@ impl Demosaicer {
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: repaired.as_entire_binding(),
|
||||
resource: hot.repaired.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
@@ -864,15 +935,7 @@ impl Demosaicer {
|
||||
// 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
|
||||
// sees every repair.
|
||||
{
|
||||
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);
|
||||
}
|
||||
self.record_hot_pass(&mut enc, &hot);
|
||||
{
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("demosaic-pass"),
|
||||
@@ -891,7 +954,8 @@ impl Demosaicer {
|
||||
height,
|
||||
// Identity where the body is uncalibrated: the image renders with
|
||||
// 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(),
|
||||
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),
|
||||
// resolved at decode because that is the only place the make and
|
||||
@@ -907,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];
|
||||
|
||||
/// Pack u16 samples two per u32, little-endian within the word.
|
||||
@@ -1399,6 +1626,8 @@ mod tests {
|
||||
color_matrix: None,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
baseline_exposure: 0.0,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
@@ -1514,6 +1743,8 @@ mod tests {
|
||||
color_matrix: None,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
baseline_exposure: 0.0,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
@@ -1810,6 +2041,8 @@ mod tests {
|
||||
color_matrix: None,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
baseline_exposure: 0.0,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
@@ -1894,6 +2127,8 @@ mod tests {
|
||||
color_matrix: None,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
baseline_exposure: 0.0,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
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 error;
|
||||
mod focus;
|
||||
mod grain;
|
||||
mod histogram;
|
||||
mod mask;
|
||||
mod merge;
|
||||
@@ -41,6 +42,7 @@ pub use demosaic::{DemosaicedImage, Demosaicer};
|
||||
pub use detail::INTERMEDIATE_FORMAT as DETAIL_INTERMEDIATE_FORMAT;
|
||||
pub use error::GpuError;
|
||||
pub use focus::{FocusPeakPass, FocusPeaking, PeakColour, PeakSensitivity};
|
||||
pub use grain::GrainBlend;
|
||||
pub use merge::{Band, MergeFrame, MergeOutput, MergePass};
|
||||
// Renamed on the way out: `BINS` says enough inside `histogram`, and nothing
|
||||
// at all at a crate root shared with demosaic and segmentation.
|
||||
|
||||
+109
-8
@@ -30,18 +30,27 @@
|
||||
//! are the caller's to provide and cache — `source` is asked for frame `k`
|
||||
//! as it is needed, and a caller short of memory may demosaic on demand.
|
||||
//!
|
||||
//! # The blend
|
||||
//!
|
||||
//! With a seam map (`dr_pano::seam`), a frame's weight at a pixel is its
|
||||
//! share of the map about that pixel — whole on its own side of a seam,
|
||||
//! nothing on the other, and a ramp across a window `seam_blend` pixels
|
||||
//! wide that follows the seam. Without one, or where the map has nothing
|
||||
//! to say, the weight is the distance to the frame's edge over `feather`,
|
||||
//! which hides exposure steps and does not hide parallax: the average draws
|
||||
//! anything the frames disagree on twice.
|
||||
//!
|
||||
//! # What is not here yet
|
||||
//!
|
||||
//! A feathered blend, not seams and a Laplacian pyramid: the weight is the
|
||||
//! distance to the frame's edge, which hides exposure steps and small
|
||||
//! misalignments and does not hide parallax. Gain is a scalar per frame
|
||||
//! the caller supplies. Both are panorama.md §10's step 5, after the path
|
||||
//! writes a file end to end.
|
||||
//! A Laplacian pyramid, which would let the seam's blend be narrow for
|
||||
//! detail and wide for exposure at once. Gain is a scalar per frame the
|
||||
//! caller supplies.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use dr_pano::bundle::Cameras;
|
||||
use dr_pano::projection::{Bounds, Projection};
|
||||
use dr_pano::seam::SeamMap;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::readback::await_mapping;
|
||||
@@ -58,7 +67,7 @@ pub struct MergeFrame {
|
||||
}
|
||||
|
||||
/// The output the merge produces.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MergeOutput {
|
||||
pub projection: Projection,
|
||||
/// The projection's scale in output pixels: the cylinder's radius, the
|
||||
@@ -69,6 +78,11 @@ pub struct MergeOutput {
|
||||
pub bounds: Bounds,
|
||||
/// Pixels over which a frame's weight ramps up from its edge.
|
||||
pub feather: f32,
|
||||
/// Which frame each part of the output is taken from, laid out at the
|
||||
/// proxies' scale; `None` for the feathered average everywhere.
|
||||
pub seams: Option<Arc<SeamMap>>,
|
||||
/// The width, in output pixels, of the blend across a seam.
|
||||
pub seam_blend: f32,
|
||||
/// Chunk size: the unit of GPU work and of memory.
|
||||
pub chunk: (u32, u32),
|
||||
/// Multiplies a normalised sample (1.0 = white) to the sensor's scale.
|
||||
@@ -115,6 +129,12 @@ struct WarpParams {
|
||||
feather: f32,
|
||||
clip_onset: f32,
|
||||
balance: [f32; 4],
|
||||
seam_origin: [f32; 2],
|
||||
seam_size: [u32; 2],
|
||||
seam_px: f32,
|
||||
seam_radius: f32,
|
||||
frame_index: u32,
|
||||
seam_on: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
@@ -182,6 +202,16 @@ impl MergePass {
|
||||
count: None,
|
||||
},
|
||||
storage(2, false),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 3,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Uint,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let resolve_layout =
|
||||
@@ -279,6 +309,32 @@ impl MergePass {
|
||||
let mut band_cov = vec![false; (out_w * ch) as usize];
|
||||
let mut chunk_px: Vec<u32> = Vec::new();
|
||||
|
||||
// The seam map, once for the whole output, and where it sits in
|
||||
// this output's coordinates. A one-texel stand-in when there is
|
||||
// none, because the binding is not optional.
|
||||
let (seam_tex, seam_origin, seam_px, seam_radius, seam_size) = match &output.seams {
|
||||
Some(m) => {
|
||||
let ((ou, ov), px) = m.at_scale(output.scale);
|
||||
let radius = m.blend_radius(output.scale, f64::from(output.seam_blend));
|
||||
(
|
||||
self.label_texture(m.width as u32, m.height as u32, &m.labels),
|
||||
[ou as f32, ov as f32],
|
||||
px as f32,
|
||||
radius as f32,
|
||||
[m.width as u32, m.height as u32],
|
||||
)
|
||||
}
|
||||
None => (
|
||||
self.label_texture(1, 1, &[dr_pano::seam::NONE]),
|
||||
[0.0; 2],
|
||||
1.0,
|
||||
1.0,
|
||||
[1, 1],
|
||||
),
|
||||
};
|
||||
let seam_view = seam_tex.create_view(&Default::default());
|
||||
let seam_on = u32::from(output.seams.is_some());
|
||||
|
||||
let mut y = 0u32;
|
||||
while y < out_h {
|
||||
let rows = ch.min(out_h - y);
|
||||
@@ -346,8 +402,14 @@ impl MergePass {
|
||||
output.balance[2].max(1e-3),
|
||||
0.0,
|
||||
],
|
||||
seam_origin,
|
||||
seam_size,
|
||||
seam_px,
|
||||
seam_radius,
|
||||
frame_index: k as u32,
|
||||
seam_on,
|
||||
};
|
||||
self.accumulate(¶ms, tile);
|
||||
self.accumulate(¶ms, tile, &seam_view);
|
||||
}
|
||||
|
||||
self.resolve_chunk((cols, rows), output.sample_scale, &mut chunk_px)?;
|
||||
@@ -385,7 +447,42 @@ impl MergePass {
|
||||
self.ctx.queue.submit(Some(enc.finish()));
|
||||
}
|
||||
|
||||
fn accumulate(&mut self, params: &WarpParams, tile: &wgpu::Texture) {
|
||||
/// The seam map's labels as an `r8uint` texture.
|
||||
fn label_texture(&self, width: u32, height: u32, labels: &[u8]) -> wgpu::Texture {
|
||||
let size = wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let tex = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("merge-seams"),
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::R8Uint,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
self.ctx.queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &tex,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
labels,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(width),
|
||||
rows_per_image: Some(height),
|
||||
},
|
||||
size,
|
||||
);
|
||||
tex
|
||||
}
|
||||
|
||||
fn accumulate(&mut self, params: &WarpParams, tile: &wgpu::Texture, seams: &wgpu::TextureView) {
|
||||
let chunk = (params.chunk_size[0], params.chunk_size[1]);
|
||||
let uniforms = self
|
||||
.ctx
|
||||
@@ -417,6 +514,10 @@ impl MergePass {
|
||||
binding: 2,
|
||||
resource: acc.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(seams),
|
||||
},
|
||||
],
|
||||
});
|
||||
let mut enc = self.ctx.device.create_command_encoder(&Default::default());
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
// pixel it asks which direction that pixel looks along, turns the
|
||||
// direction into the frame's camera, projects it to a source pixel, and
|
||||
// if that pixel is inside the tile that was rendered for this chunk,
|
||||
// samples it and adds it — weighted by its distance from the frame's edge
|
||||
// — into the accumulator. `resolve` runs once per chunk after every frame
|
||||
// samples it and adds it — weighted by the frame's share of the seam map
|
||||
// there, or by its distance from the frame's edge where there is no map —
|
||||
// into the accumulator. `resolve` runs once per chunk after every frame
|
||||
// has been added: divides the sums by the weights and packs the result as
|
||||
// sixteen-bit samples at the sensor's scale (FR-MRG-3).
|
||||
//
|
||||
@@ -50,12 +51,83 @@ struct Params {
|
||||
// white balance the composite will be developed with.
|
||||
clip_onset: f32,
|
||||
balance: vec4<f32>,
|
||||
// The seam map (`dr_pano::seam`): where its texel (0, 0)'s corner sits
|
||||
// in this output's centred coordinates, its size, output pixels per
|
||||
// texel, the blend's radius in texels, which frame this dispatch is,
|
||||
// and whether there is a map at all.
|
||||
seam_origin: vec2<f32>,
|
||||
seam_size: vec2<u32>,
|
||||
seam_px: f32,
|
||||
seam_radius: f32,
|
||||
frame_index: u32,
|
||||
seam_on: u32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> p: Params;
|
||||
@group(0) @binding(1) var tile: texture_2d<f32>;
|
||||
// rgb·w summed, then w: four floats per chunk pixel.
|
||||
@group(0) @binding(2) var<storage, read_write> acc: array<vec4<f32>>;
|
||||
// One frame index per texel, 255 for none.
|
||||
@group(0) @binding(3) var seams: texture_2d<u32>;
|
||||
|
||||
const NO_FRAME: u32 = 255u;
|
||||
|
||||
fn label(i: i32, j: i32) -> u32 {
|
||||
if (i < 0 || j < 0 || i >= i32(p.seam_size.x) || j >= i32(p.seam_size.y)) {
|
||||
return NO_FRAME;
|
||||
}
|
||||
return textureLoad(seams, vec2<i32>(i, j), 0).r;
|
||||
}
|
||||
|
||||
// This frame's share of the seam map about output point (u, v): the
|
||||
// tent-weighted fraction of the texels within the radius that it owns, and
|
||||
// the weight of the texels owned by anyone (zero where the map has nothing
|
||||
// to say). `SeamMap::share` verbatim.
|
||||
fn seam_share(u: f32, v: f32) -> vec2<f32> {
|
||||
let x = (u - p.seam_origin.x) / p.seam_px - 0.5;
|
||||
let y = (v - p.seam_origin.y) / p.seam_px - 0.5;
|
||||
let r = max(p.seam_radius, 1.0);
|
||||
let x0 = i32(ceil(x - r));
|
||||
let x1 = i32(floor(x + r));
|
||||
let y0 = i32(ceil(y - r));
|
||||
let y1 = i32(floor(y + r));
|
||||
// Most pixels are nowhere near a seam: if the window's corners, edge
|
||||
// midpoints and centre agree, so does the window. A seam crossing it
|
||||
// has to cross its border, between two of those.
|
||||
let xm = i32(round(x));
|
||||
let ym = i32(round(y));
|
||||
let c = label(xm, ym);
|
||||
if (label(x0, y0) == c && label(x1, y0) == c && label(x0, y1) == c && label(x1, y1) == c
|
||||
&& label(xm, y0) == c && label(xm, y1) == c && label(x0, ym) == c && label(x1, ym) == c) {
|
||||
if (c == NO_FRAME) {
|
||||
return vec2<f32>(0.0, 0.0);
|
||||
}
|
||||
return vec2<f32>(select(0.0, 1.0, c == p.frame_index), 1.0);
|
||||
}
|
||||
var mine = 0.0;
|
||||
var owned = 0.0;
|
||||
for (var j = y0; j <= y1; j = j + 1) {
|
||||
let wy = 1.0 - abs(y - f32(j)) / r;
|
||||
if (wy <= 0.0) {
|
||||
continue;
|
||||
}
|
||||
for (var i = x0; i <= x1; i = i + 1) {
|
||||
let wx = 1.0 - abs(x - f32(i)) / r;
|
||||
let l = label(i, j);
|
||||
if (wx <= 0.0 || l == NO_FRAME) {
|
||||
continue;
|
||||
}
|
||||
owned = owned + wx * wy;
|
||||
if (l == p.frame_index) {
|
||||
mine = mine + wx * wy;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (owned <= 0.0) {
|
||||
return vec2<f32>(0.0, 0.0);
|
||||
}
|
||||
return vec2<f32>(mine / owned, 1.0);
|
||||
}
|
||||
|
||||
fn to_direction(u: f32, v: f32) -> vec3<f32> {
|
||||
let s = p.proj_scale;
|
||||
@@ -98,7 +170,17 @@ fn warp(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if (edge <= 0.0) {
|
||||
return;
|
||||
}
|
||||
let w = clamp(edge / max(p.feather, 1.0), 0.0, 1.0);
|
||||
var w = clamp(edge / max(p.feather, 1.0), 0.0, 1.0);
|
||||
// With seams, the share of the map scales it. The small floor keeps
|
||||
// the feather underneath as the answer wherever no frame that reaches
|
||||
// this pixel owns it — the map is coarser than the output, so at the
|
||||
// frames' outer edges it can name a frame that falls just short.
|
||||
if (p.seam_on != 0u) {
|
||||
let s = seam_share(u, v);
|
||||
if (s.y > 0.0) {
|
||||
w = w * (s.x + 1e-4);
|
||||
}
|
||||
}
|
||||
// Into the tile.
|
||||
let tx = sx - p.tile_origin.x;
|
||||
let ty = sy - p.tile_origin.y;
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
//! TRACES: FR-DEV-3e
|
||||
//! The camera profile's tables, end to end on a device (D20).
|
||||
//!
|
||||
//! `dr-pipeline` holds the lookup to the DNG SDK's algorithm on the CPU
|
||||
//! (`ops::camera_profile::apply_reference`). Nothing there would notice a
|
||||
//! shader that disagreed with it — a transposed constant matrix, an index
|
||||
//! off by one column, a buffer bound in the wrong order — so this renders a
|
||||
//! frame of 256 different colours through tables that move every one of them
|
||||
//! a long way, and holds each pixel to the reference.
|
||||
//!
|
||||
//! The source is a linear three-sample frame, so the colours arrive exactly
|
||||
//! as written with no demosaic between, and an identity stands in the view
|
||||
//! transform's place so the readback is the scene colour, display-encoded.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||||
use dr_pipeline::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamId};
|
||||
use dr_pipeline::operation::{Operation, Stage, Uniform};
|
||||
use dr_pipeline::ops::camera_profile::{apply_reference, CameraProfile, APPLY, LOOK};
|
||||
use dr_types::{HueSatTable, ProfileOrigin, ProfileTables, Transfer};
|
||||
|
||||
const SIZE: u32 = 16;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
pollster::block_on(GpuContext::new_headless()).ok()
|
||||
}
|
||||
|
||||
/// An identity in the view transform's place.
|
||||
struct IdentityView;
|
||||
|
||||
impl Operation for IdentityView {
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
Arc::new(OpDescriptor {
|
||||
id: OpId("identity_view"),
|
||||
label: LocalizedKey("identity_view"),
|
||||
params: Vec::new(),
|
||||
attributes: vec![Attribute::Tone],
|
||||
})
|
||||
}
|
||||
fn set_param(&mut self, _: ParamId, _: f32) {}
|
||||
fn param(&self, _: ParamId) -> f32 {
|
||||
0.0
|
||||
}
|
||||
fn is_active(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn stage(&self) -> Stage {
|
||||
Stage::View
|
||||
}
|
||||
fn renders(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn wgsl_body(&self) -> String {
|
||||
String::new()
|
||||
}
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 256 colours across hue, saturation and value, kept under the prologue's
|
||||
/// highlight desaturation and above black.
|
||||
fn colours() -> Vec<[f32; 3]> {
|
||||
(0..SIZE * SIZE)
|
||||
.map(|i| {
|
||||
let f = |k: u32| {
|
||||
let x = (i.wrapping_mul(2_654_435_761).rotate_left(k * 7) >> 8) % 1000;
|
||||
0.04 + 0.86 * x as f32 / 1000.0
|
||||
};
|
||||
[f(1), f(2), f(3)]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn frame(tables: Option<ProfileTables>) -> RawImage {
|
||||
let data = colours()
|
||||
.iter()
|
||||
.flat_map(|c| c.map(|v| (v * 65535.0).round() as u16))
|
||||
.collect();
|
||||
RawImage {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
data,
|
||||
cfa_pattern: CfaPattern::Rggb,
|
||||
black_level: [0; 4],
|
||||
white_level: u16::MAX,
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 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: 3,
|
||||
profile: None,
|
||||
profile_tables: tables.map(Arc::new),
|
||||
baseline_exposure: 0.0,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Tables that move every colour by a different amount: hue shifts of tens
|
||||
/// of degrees, saturation scales either side of one, and a 3-D, sRGB-indexed
|
||||
/// look whose value scale varies down the value axis.
|
||||
fn strong_tables() -> ProfileTables {
|
||||
let (hd, sd) = (12u32, 5u32);
|
||||
let hue_sat = (0..hd * sd)
|
||||
.map(|i| {
|
||||
let (h, s) = (i / sd, i % sd);
|
||||
let a = h as f32 / hd as f32 * std::f32::consts::TAU;
|
||||
[25.0 * a.sin(), 1.0 + 0.3 * a.cos() * s as f32 / 4.0, 1.0]
|
||||
})
|
||||
.collect();
|
||||
let (lh, ls, lv) = (8u32, 4u32, 5u32);
|
||||
let look = (0..lh * ls * lv)
|
||||
.map(|i| {
|
||||
let v = i / (lh * ls);
|
||||
let h = (i / ls) % lh;
|
||||
[
|
||||
-15.0 + 4.0 * h as f32,
|
||||
1.25 - 0.05 * v as f32,
|
||||
0.85 + 0.06 * v as f32,
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let mut look = HueSatTable::new(lh, ls, lv, true, look).unwrap();
|
||||
look.srgb_encoded = true;
|
||||
ProfileTables {
|
||||
name: "strong".into(),
|
||||
origin: ProfileOrigin::Embedded,
|
||||
hue_sat: Some(HueSatTable::new(hd, sd, 1, false, hue_sat).unwrap()),
|
||||
look: Some(look),
|
||||
tone_curve: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(ctx: &GpuContext, raw: &RawImage, op: CameraProfile) -> Vec<[u8; 3]> {
|
||||
let source = Demosaicer::new(ctx)
|
||||
.expect("demosaicer")
|
||||
.run(raw)
|
||||
.expect("upload");
|
||||
let ops: Vec<Box<dyn Operation>> = vec![Box::new(op), Box::new(IdentityView)];
|
||||
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");
|
||||
pixels.chunks_exact(4).map(|p| [p[0], p[1], p[2]]).collect()
|
||||
}
|
||||
|
||||
fn encode(c: [f32; 3]) -> [i32; 3] {
|
||||
c.map(|v| (Transfer::Srgb.encode(v.clamp(0.0, 1.0)) * 255.0).round() as i32)
|
||||
}
|
||||
|
||||
fn assert_agrees(got: &[[u8; 3]], expected: impl Fn([f32; 3]) -> [f32; 3], what: &str) {
|
||||
let mut moved = 0;
|
||||
for (i, (c, g)) in colours().into_iter().zip(got).enumerate() {
|
||||
let want = encode(expected(c));
|
||||
let g = g.map(i32::from);
|
||||
// Two 8-bit steps: the half-float source and intermediate, and the
|
||||
// rounding either side of the encode.
|
||||
assert!(
|
||||
want.iter().zip(g).all(|(w, g)| (w - g).abs() <= 2),
|
||||
"{what}: pixel {i} {c:?} rendered {g:?}, the reference says {want:?}"
|
||||
);
|
||||
if want != encode(c) {
|
||||
moved += 1;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
moved > 200,
|
||||
"{what}: only {moved} of 256 colours moved; the test proves little"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shader_agrees_with_the_cpu_reference() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let tables = strong_tables();
|
||||
let got = render(&ctx, &frame(Some(tables.clone())), CameraProfile::new());
|
||||
assert_agrees(&got, |c| apply_reference(&tables, c, 1.0), "at defaults");
|
||||
|
||||
let mut doubled = CameraProfile::new();
|
||||
doubled.set_param(LOOK, 200.0);
|
||||
let got = render(&ctx, &frame(Some(tables.clone())), doubled);
|
||||
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]
|
||||
fn switched_off_or_absent_the_render_is_unchanged() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let bare = render(&ctx, &frame(None), CameraProfile::new());
|
||||
let mut off = CameraProfile::new();
|
||||
off.set_param(APPLY, 0.0);
|
||||
let switched_off = render(&ctx, &frame(Some(strong_tables())), off);
|
||||
assert_eq!(bare, switched_off, "the switch off is the matrix alone");
|
||||
// Against the source colours, one 8-bit step for the half-float texture
|
||||
// the source is uploaded in; the exact comparison is the one above.
|
||||
for (c, g) in colours().into_iter().zip(&bare) {
|
||||
let want = encode(c);
|
||||
assert!(
|
||||
want.iter()
|
||||
.zip(g)
|
||||
.all(|(w, g)| (w - i32::from(*g)).abs() <= 1),
|
||||
"no tables, no change: {c:?} rendered {g:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_libraries_adobe_standard_renders_as_the_reference_does() {
|
||||
// The real tables, when the library's 6D DNG is on this machine: a 90×30
|
||||
// HueSatMap and a 36×8×16 LookTable, at the sizes no synthetic test
|
||||
// reaches.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let path = std::env::var_os("DR_DCP_SAMPLE")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::var_os("HOME").map(|h| {
|
||||
std::path::PathBuf::from(h).join("Nextcloud/PhotosRaw/2017/2017-08-12/_MG_9080.dng")
|
||||
})
|
||||
});
|
||||
let Some(bytes) = path.and_then(|p| std::fs::read(p).ok()) else {
|
||||
eprintln!("skipping: no sample DNG");
|
||||
return;
|
||||
};
|
||||
let tables = dr_decode::dcp::embedded_in(&bytes)
|
||||
.expect("Adobe Standard")
|
||||
.tables(5000.0, ProfileOrigin::Embedded);
|
||||
let got = render(&ctx, &frame(Some(tables.clone())), CameraProfile::new());
|
||||
for (i, (c, g)) in colours().into_iter().zip(&got).enumerate() {
|
||||
let want = encode(apply_reference(&tables, c, 1.0));
|
||||
let g = g.map(i32::from);
|
||||
assert!(
|
||||
want.iter().zip(g).all(|(w, g)| (w - g).abs() <= 2),
|
||||
"pixel {i} {c:?} rendered {g:?}, the reference says {want:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,8 @@ fn flat_raw(level: u16) -> RawImage {
|
||||
// film. `dr-pipeline` asserts the suppression on the generated source.
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
baseline_exposure: 0.0,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ fn frame(pattern: CfaPattern, level: u16, set: &[(u32, u32, u16)]) -> RawImage {
|
||||
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 {
|
||||
@@ -139,3 +141,33 @@ fn a_hot_photosite_on_x_trans_is_invisible() {
|
||||
let diff = worst(&clean, &hot);
|
||||
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]));
|
||||
}
|
||||
|
||||
@@ -94,6 +94,8 @@ fn flat(ctx: &GpuContext, level: f32) -> dr_gpu::DemosaicedImage {
|
||||
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 {
|
||||
|
||||
@@ -58,6 +58,8 @@ fn linear_frame(w: u32, h: u32, noise: bool) -> RawImage {
|
||||
color_matrix: Some([1.6, -0.5, -0.1, -0.2, 1.4, -0.2, 0.0, -0.4, 1.4]),
|
||||
samples_per_pixel: 3,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
baseline_exposure: 0.0,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
|
||||
@@ -33,6 +33,8 @@ fn flat_raw(level: u16) -> RawImage {
|
||||
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 {
|
||||
@@ -44,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.
|
||||
///
|
||||
/// The centre rather than a corner: a demosaic has to invent its edges.
|
||||
@@ -68,7 +117,7 @@ fn the_shader_agrees_with_the_cpu_reference() {
|
||||
return;
|
||||
};
|
||||
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] {
|
||||
let scene = f32::from(level) / f32::from(u16::MAX);
|
||||
let display = curve.channel(scene).min(1.0);
|
||||
@@ -94,7 +143,7 @@ fn highlights_above_one_stay_distinct() {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let mut graph = EditGraph::default_chain();
|
||||
let mut graph = sigmoid_chain();
|
||||
graph.set_param(
|
||||
dr_pipeline::ops::exposure::ID,
|
||||
dr_pipeline::ops::exposure::EXPOSURE,
|
||||
|
||||
@@ -38,6 +38,11 @@ ort = { workspace = true, features = ["cuda", "tensorrt"] }
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
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]
|
||||
# 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.
|
||||
|
||||
@@ -44,6 +44,20 @@ pub fn context_path(cfg: &Config, bytes: &[u8]) -> PathBuf {
|
||||
.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
|
||||
/// take, smallest first, recording each as it lands.
|
||||
pub fn run() {
|
||||
@@ -90,12 +104,27 @@ pub fn run() {
|
||||
Source::Bytes(b) => (b.to_vec(), format!("embedded {role:?}")),
|
||||
};
|
||||
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());
|
||||
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) => {
|
||||
drop(session);
|
||||
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
|
||||
/// the Hexagon are the point of it.
|
||||
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.
|
||||
@@ -78,6 +83,12 @@ pub enum Rung {
|
||||
MiGraphX,
|
||||
/// Qualcomm's Hexagon NPU through QNN, int8 models only. Android only.
|
||||
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 {
|
||||
@@ -88,6 +99,7 @@ impl Rung {
|
||||
Rung::TensorRt => "TensorRT",
|
||||
Rung::MiGraphX => "MIGraphX",
|
||||
Rung::Hexagon => "Hexagon NPU",
|
||||
Rung::CoreMl => "CoreML",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,13 +108,16 @@ impl Rung {
|
||||
fn fallback(self) -> Rung {
|
||||
match self {
|
||||
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.
|
||||
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.
|
||||
@@ -116,9 +131,14 @@ impl Rung {
|
||||
/// 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
|
||||
/// 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 {
|
||||
match self {
|
||||
Rung::Hexagon => role != Role::Embedder,
|
||||
Rung::Hexagon => !matches!(role, Role::Embedder | Role::Denoiser),
|
||||
Rung::CoreMl => role != Role::Embedder,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
@@ -348,6 +368,12 @@ struct Cache {
|
||||
/// the fingerprint changes: a wedged driver must not cost every launch
|
||||
/// thirty seconds.
|
||||
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 {
|
||||
@@ -586,6 +612,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_hexagon_never_takes_the_embedder() {
|
||||
assert!(!Rung::Hexagon.serves(Role::Embedder));
|
||||
assert!(!Rung::Hexagon.serves(Role::Denoiser));
|
||||
assert!(Rung::Hexagon.serves(Role::Detector));
|
||||
assert_eq!(Rung::Hexagon.form(Role::Detector), Form::Int8);
|
||||
// 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]
|
||||
fn the_status_reports_only_the_rungs_above_the_selection() {
|
||||
let _serial = serial();
|
||||
|
||||
@@ -16,10 +16,15 @@ use crate::{api::Runtime, state, Cache, Config, Form, Role, Rung};
|
||||
fn ladder(ceiling: Option<Rung>) -> Vec<Rung> {
|
||||
#[cfg(target_os = "android")]
|
||||
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
|
||||
// "not enabled in this build" or a library that fails to load, and
|
||||
// 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];
|
||||
all.into_iter()
|
||||
.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");
|
||||
|
||||
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 => {
|
||||
cache.rung = Some(rung);
|
||||
cache.reason = format!("{ms:.1} ms against {floor:.1} ms on the CPU");
|
||||
@@ -118,6 +127,52 @@ fn finish(cache: Cache) {
|
||||
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
|
||||
/// 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
|
||||
@@ -168,21 +223,33 @@ fn time_rung(
|
||||
started.elapsed().as_secs_f64()
|
||||
);
|
||||
|
||||
let shape: Vec<usize> = session.inputs()[0]
|
||||
.dtype()
|
||||
.tensor_shape()
|
||||
.ok_or("model input is not a tensor")?
|
||||
// Zeros for every input the model declares, by name — the denoiser
|
||||
// takes two (mosaic and σ), and a probe that fed only the first failed
|
||||
// every rung and left it on the CPU.
|
||||
let feeds: Vec<(String, Vec<usize>)> = session
|
||||
.inputs()
|
||||
.iter()
|
||||
.map(|&d| if d > 0 { d as usize } else { 1 })
|
||||
.collect();
|
||||
let zeros = vec![0f32; shape.iter().product()];
|
||||
.map(|i| {
|
||||
let shape = i
|
||||
.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 input = ort::value::Tensor::from_array((shape.clone(), zeros.clone()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut inputs: Vec<(String, ort::session::SessionInputValue)> = Vec::new();
|
||||
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 out = session
|
||||
.run(ort::inputs![input])
|
||||
.map_err(|e| e.to_string())?;
|
||||
let out = session.run(inputs).map_err(|e| e.to_string())?;
|
||||
let _ = out[0]
|
||||
.try_extract_tensor::<f32>()
|
||||
.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()
|
||||
}
|
||||
|
||||
#[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 {
|
||||
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
|
||||
// app never asked tract for that and does not start now.
|
||||
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
|
||||
// compiles it from the model when there is not; the engine thread is
|
||||
// what makes the second case rare (§6).
|
||||
let context = (rung == Rung::Hexagon).then(|| crate::engines::context_path(cfg, bytes));
|
||||
let ready = context.as_ref().is_some_and(|p| p.is_file());
|
||||
b = providers(
|
||||
b,
|
||||
rung,
|
||||
role,
|
||||
cfg,
|
||||
if ready { None } else { context.as_deref() },
|
||||
)?;
|
||||
// What the rung keeps for this model: the context the Hexagon is to
|
||||
// write, or the directory CoreML compiles into.
|
||||
let per_model = match rung {
|
||||
Rung::CoreMl => Some(crate::engines::coreml_dir(cfg, bytes)),
|
||||
_ if ready => None,
|
||||
_ => context.clone(),
|
||||
};
|
||||
b = providers(b, rung, role, cfg, per_model.as_deref())?;
|
||||
match (ready, context) {
|
||||
(true, Some(path)) => b.commit_from_file(path),
|
||||
_ => 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 compositor and the decoder (§9). tract ignores it.
|
||||
fn threads(cfg: &Config) -> usize {
|
||||
@@ -54,11 +91,12 @@ fn providers(
|
||||
rung: Rung,
|
||||
role: Role,
|
||||
cfg: &Config,
|
||||
_generate_context: Option<&std::path::Path>,
|
||||
per_model: Option<&std::path::Path>,
|
||||
) -> ort::Result<ort::session::builder::SessionBuilder> {
|
||||
use ort::ep;
|
||||
match rung {
|
||||
Rung::Cpu => Ok(b),
|
||||
Rung::CoreMl => coreml(b, per_model),
|
||||
Rung::Cuda => {
|
||||
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.
|
||||
///
|
||||
/// `ort`'s own builder (`ep::MIGraphX`) fills the legacy
|
||||
@@ -175,8 +250,8 @@ fn providers(
|
||||
.build()
|
||||
.error_on_failure()])?)
|
||||
}
|
||||
Rung::Cuda | Rung::TensorRt | Rung::MiGraphX => {
|
||||
unreachable!("no desktop GPU rung on Android")
|
||||
Rung::Cuda | Rung::TensorRt | Rung::MiGraphX | Rung::CoreMl => {
|
||||
unreachable!("no desktop rung on Android")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
//! - [`align`] — the whole thing, from features to cameras, honest about
|
||||
//! what it could not place.
|
||||
//! - [`projection`] — perspective, cylindrical, spherical.
|
||||
//! - [`seam`] — which frame each output pixel is taken from.
|
||||
//! - [`linalg`] — the small dense algebra all of it uses.
|
||||
//!
|
||||
//! # What it depends on
|
||||
@@ -43,6 +44,7 @@ pub mod matching;
|
||||
#[cfg(feature = "xfeat")]
|
||||
pub mod migan;
|
||||
pub mod projection;
|
||||
pub mod seam;
|
||||
#[cfg(feature = "xfeat")]
|
||||
pub mod xfeat;
|
||||
|
||||
@@ -52,6 +54,7 @@ pub use features::{Features, Keypoint};
|
||||
pub use fill::{fill_border, Inpainter, Observer, Params as FillParams};
|
||||
pub use image::Gray;
|
||||
pub use projection::Projection;
|
||||
pub use seam::{SeamMap, SeamOptions};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PanoError {
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
//! TRACES: FR-MRG-10
|
||||
//! Where each frame gives way to the next.
|
||||
//!
|
||||
//! The first merges averaged every overlap: each frame weighted by its
|
||||
//! distance from its own edge, so that across two hundred pixels one frame
|
||||
//! faded into the other. That hides an exposure step and does not hide
|
||||
//! anything that differs between the frames — parallax on a near slope, a
|
||||
//! walker, a branch in the wind — which the average draws twice, half as
|
||||
//! bright, a soft double edge at 1:1.
|
||||
//!
|
||||
//! A seam answers it the way every stitcher does: in an overlap, each output
|
||||
//! pixel is taken from *one* frame, and the line where the choice changes is
|
||||
//! put where the frames agree and the picture is smooth — through sky,
|
||||
//! along a shadow, round the walker rather than through him — and away from
|
||||
//! either frame's edge, where vignetting and the lens correction's fringe
|
||||
//! live. The blend is then narrow and only across that line.
|
||||
//!
|
||||
//! # How
|
||||
//!
|
||||
//! At proxy resolution, on the output surface, which fits (panorama.md §5:
|
||||
//! "it is a mask, not an image"):
|
||||
//!
|
||||
//! 1. Frames are laid down one at a time, each next to one already placed.
|
||||
//! The composite so far is a label per texel and the value its owner saw.
|
||||
//! 2. Where a new frame overlaps the composite, a cost per texel: the
|
||||
//! difference between the two (after the gains), how much detail either
|
||||
//! has there, and how near either frame's edge it is — smoothed over a
|
||||
//! few texels, because "agree" means locally, not at one pixel.
|
||||
//! 3. The cut is a path across the overlap, perpendicular to the line from
|
||||
//! the composite's frames to the new one, found by dynamic programming
|
||||
//! one row at a time: the per-column seam panorama.md §4 chose over a
|
||||
//! graph cut because it is the GPU-friendly shape. Texels on the new
|
||||
//! frame's side of the path become its own.
|
||||
//!
|
||||
//! What the merge reads is [`SeamMap::share`]: the fraction of a small
|
||||
//! window about a point that is labelled with a frame, tent-weighted, which
|
||||
//! is a narrow blend that follows the seam. `merge.wgsl` computes the same
|
||||
//! thing on the GPU from the same labels.
|
||||
|
||||
use crate::bundle::Cameras;
|
||||
use crate::image::Gray;
|
||||
use crate::projection::{self, Projection};
|
||||
|
||||
/// No frame owns this texel.
|
||||
pub const NONE: u8 = 255;
|
||||
|
||||
/// The most frames a map can label: one less than [`NONE`].
|
||||
pub const MAX_FRAMES: usize = NONE as usize;
|
||||
|
||||
/// Which frame each texel of the output takes its pixels from.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SeamMap {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
/// The projection scale the map was laid out at: the proxies' focal
|
||||
/// length. Output coordinates at any other scale are this times the
|
||||
/// ratio of the scales.
|
||||
pub scale: f64,
|
||||
/// Centred output coordinates, at `scale`, of texel (0, 0)'s top-left
|
||||
/// corner.
|
||||
pub origin: (f64, f64),
|
||||
/// Output units per texel, at `scale`.
|
||||
pub px: f64,
|
||||
/// Row-major, one per texel: the frame's index, or [`NONE`].
|
||||
pub labels: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SeamOptions {
|
||||
/// The widest the map is laid out, in texels. Wider than the proxies'
|
||||
/// own resolution buys nothing.
|
||||
pub max_width: usize,
|
||||
/// How much detail costs against disagreement: a seam through texture
|
||||
/// shows even where the frames agree, because the blend across it
|
||||
/// softens it.
|
||||
pub detail: f32,
|
||||
/// How much a frame's edge costs, and how far in from it the cost
|
||||
/// reaches, in proxy pixels. Frame edges are where vignetting is
|
||||
/// darkest and the lens correction ran out of sensor.
|
||||
pub edge: f32,
|
||||
pub edge_margin: f32,
|
||||
/// The radius, in texels, a texel's cost looks about it for the worst
|
||||
/// of its neighbours: at least the radius the merge blends across.
|
||||
pub smoothing: usize,
|
||||
}
|
||||
|
||||
impl Default for SeamOptions {
|
||||
fn default() -> Self {
|
||||
SeamOptions {
|
||||
max_width: 2048,
|
||||
detail: 0.5,
|
||||
edge: 0.5,
|
||||
edge_margin: 24.0,
|
||||
smoothing: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The most texels a blend reaches either side of a seam. The merge's
|
||||
/// shader loads the square of twice this per pixel per frame near a seam.
|
||||
pub const MAX_BLEND_RADIUS: f64 = 4.0;
|
||||
|
||||
/// Cost of a texel outside the overlap: high enough that the path keeps to
|
||||
/// the overlap wherever there is one, finite so that a row with a gap in it
|
||||
/// still has an answer.
|
||||
const OUTSIDE: f32 = 1.0e3;
|
||||
|
||||
impl SeamMap {
|
||||
/// The map's origin and texel size in the coordinates of an output
|
||||
/// laid out at `scale` (the full-resolution focal length, or a fraction
|
||||
/// of it).
|
||||
pub fn at_scale(&self, scale: f64) -> ((f64, f64), f64) {
|
||||
let r = scale / self.scale;
|
||||
((self.origin.0 * r, self.origin.1 * r), self.px * r)
|
||||
}
|
||||
|
||||
/// The radius, in texels, of a blend `blend_px` output pixels wide in an
|
||||
/// output laid out at `scale`: what [`Self::share`] and the shader are
|
||||
/// given, so that the preview and the merge blend alike.
|
||||
pub fn blend_radius(&self, scale: f64, blend_px: f64) -> f64 {
|
||||
let (_, px) = self.at_scale(scale);
|
||||
(blend_px / 2.0 / px).clamp(1.0, MAX_BLEND_RADIUS)
|
||||
}
|
||||
|
||||
/// The share frame `k` has of output point `(u, v)` given at `scale`:
|
||||
/// the tent-weighted fraction of the texels within `radius` (in texels)
|
||||
/// that it owns. `None` where no texel in reach is owned at all — the
|
||||
/// map has nothing to say there, and the caller falls back to its
|
||||
/// feather.
|
||||
///
|
||||
/// This is the function `merge.wgsl`'s `seam_share` repeats; the two
|
||||
/// must agree.
|
||||
pub fn share(&self, k: usize, u: f64, v: f64, scale: f64, radius: f64) -> Option<f32> {
|
||||
let ((ou, ov), px) = self.at_scale(scale);
|
||||
let x = (u - ou) / px - 0.5;
|
||||
let y = (v - ov) / px - 0.5;
|
||||
let r = radius.max(1.0);
|
||||
let (x0, x1) = ((x - r).ceil() as i64, (x + r).floor() as i64);
|
||||
let (y0, y1) = ((y - r).ceil() as i64, (y + r).floor() as i64);
|
||||
let (mut mine, mut all) = (0.0f64, 0.0f64);
|
||||
for j in y0.max(0)..=y1.min(self.height as i64 - 1) {
|
||||
let wy = 1.0 - (y - j as f64).abs() / r;
|
||||
if wy <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
for i in x0.max(0)..=x1.min(self.width as i64 - 1) {
|
||||
let wx = 1.0 - (x - i as f64).abs() / r;
|
||||
if wx <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let l = self.labels[j as usize * self.width + i as usize];
|
||||
if l == NONE {
|
||||
continue;
|
||||
}
|
||||
all += wx * wy;
|
||||
if usize::from(l) == k {
|
||||
mine += wx * wy;
|
||||
}
|
||||
}
|
||||
}
|
||||
(all > 0.0).then(|| (mine / all) as f32)
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame warped onto the map: its gain-corrected value and its distance
|
||||
/// from its own edge (in proxy pixels) per texel, NaN where it does not
|
||||
/// reach.
|
||||
struct Warped {
|
||||
value: Vec<f32>,
|
||||
edge: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Lay seams across the overlaps of `proxies`, aligned by `cameras` (at the
|
||||
/// proxies' scale), with `gains` the linear multipliers the merge will
|
||||
/// apply. `None` if the frames project nowhere or there are more than
|
||||
/// [`MAX_FRAMES`].
|
||||
pub fn find(
|
||||
proxies: &[&Gray],
|
||||
cameras: &Cameras,
|
||||
gains: &[f32],
|
||||
projection: Projection,
|
||||
opts: &SeamOptions,
|
||||
) -> Option<SeamMap> {
|
||||
let n = proxies.len();
|
||||
if n == 0 || n > MAX_FRAMES || cameras.rotations.len() != n || gains.len() != n {
|
||||
return None;
|
||||
}
|
||||
let (fw, fh) = (proxies[0].width as f64, proxies[0].height as f64);
|
||||
let scale = cameras.focal;
|
||||
let bounds = projection::bounds(projection, scale, cameras, (fw, fh))?;
|
||||
let width = opts.max_width.min(bounds.width().ceil() as usize).max(1);
|
||||
let px = bounds.width() / width as f64;
|
||||
let height = ((bounds.height() / px).ceil() as usize).max(1);
|
||||
let mut map = SeamMap {
|
||||
width,
|
||||
height,
|
||||
scale,
|
||||
origin: (bounds.min_u, bounds.min_v),
|
||||
px,
|
||||
labels: vec![NONE; width * height],
|
||||
};
|
||||
|
||||
// Where each frame's centre lands, in texels: what orders the frames
|
||||
// and orients each cut.
|
||||
let centres: Vec<(f64, f64)> = (0..n)
|
||||
.map(|k| {
|
||||
let d = cameras.bearing(k, (0.0, 0.0));
|
||||
projection
|
||||
.from_direction(scale, d)
|
||||
.map(|(u, v)| ((u - bounds.min_u) / px, (v - bounds.min_v) / px))
|
||||
.unwrap_or((width as f64 / 2.0, height as f64 / 2.0))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// The composite so far: what its owner saw, and how far from the
|
||||
// owner's edge.
|
||||
let mut value = vec![f32::NAN; width * height];
|
||||
let mut edge = vec![f32::NAN; width * height];
|
||||
|
||||
for k in order(¢res, (width as f64 / 2.0, height as f64 / 2.0)) {
|
||||
let w = warp(&map, proxies[k], cameras, k, gains[k], projection);
|
||||
let overlap: Vec<usize> = (0..width * height)
|
||||
.filter(|&i| map.labels[i] != NONE && !w.value[i].is_nan())
|
||||
.collect();
|
||||
// Texels nobody owns yet are the new frame's without a cut.
|
||||
let mut take: Vec<bool> = map
|
||||
.labels
|
||||
.iter()
|
||||
.zip(&w.value)
|
||||
.map(|(&l, v)| l == NONE && !v.is_nan())
|
||||
.collect();
|
||||
if !overlap.is_empty() {
|
||||
cut(
|
||||
&map, &value, &edge, &w, &overlap, ¢res, k, opts, &mut take,
|
||||
);
|
||||
}
|
||||
for i in 0..width * height {
|
||||
if take[i] {
|
||||
map.labels[i] = k as u8;
|
||||
value[i] = w.value[i];
|
||||
edge[i] = w.edge[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(map)
|
||||
}
|
||||
|
||||
/// The order frames are laid down in: the one nearest the middle first,
|
||||
/// then always the unplaced frame nearest any placed one, so that each new
|
||||
/// frame meets the composite along an overlap rather than across a gap.
|
||||
fn order(centres: &[(f64, f64)], middle: (f64, f64)) -> Vec<usize> {
|
||||
let d2 = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).powi(2) + (a.1 - b.1).powi(2);
|
||||
let n = centres.len();
|
||||
let mut placed = vec![false; n];
|
||||
let mut out = Vec::with_capacity(n);
|
||||
let first = (0..n)
|
||||
.min_by(|&a, &b| d2(centres[a], middle).total_cmp(&d2(centres[b], middle)))
|
||||
.expect("at least one frame");
|
||||
placed[first] = true;
|
||||
out.push(first);
|
||||
while out.len() < n {
|
||||
let next = (0..n)
|
||||
.filter(|&k| !placed[k])
|
||||
.min_by(|&a, &b| {
|
||||
let near = |k: usize| {
|
||||
out.iter()
|
||||
.map(|&p| d2(centres[k], centres[p]))
|
||||
.fold(f64::MAX, f64::min)
|
||||
};
|
||||
near(a).total_cmp(&near(b))
|
||||
})
|
||||
.expect("an unplaced frame");
|
||||
placed[next] = true;
|
||||
out.push(next);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Frame `k` sampled at every texel's centre, bilinearly. The proxy is
|
||||
/// gamma-encoded grey, so the gain (linear) becomes `gain^(1/2.2)` on it.
|
||||
fn warp(
|
||||
map: &SeamMap,
|
||||
g: &Gray,
|
||||
cameras: &Cameras,
|
||||
k: usize,
|
||||
gain: f32,
|
||||
projection: Projection,
|
||||
) -> Warped {
|
||||
let (fw, fh) = (g.width as f64, g.height as f64);
|
||||
let gain = gain.max(1e-6).powf(1.0 / 2.2);
|
||||
let mut value = vec![f32::NAN; map.width * map.height];
|
||||
let mut edge = vec![f32::NAN; map.width * map.height];
|
||||
for ty in 0..map.height {
|
||||
let v = map.origin.1 + (ty as f64 + 0.5) * map.px;
|
||||
for tx in 0..map.width {
|
||||
let u = map.origin.0 + (tx as f64 + 0.5) * map.px;
|
||||
let d = projection.to_direction(map.scale, u, v);
|
||||
let Some((x, y)) = cameras.project(k, d) else {
|
||||
continue;
|
||||
};
|
||||
let (x, y) = (x + fw / 2.0 - 0.5, y + fh / 2.0 - 0.5);
|
||||
let e = x.min(fw - 1.0 - x).min(y).min(fh - 1.0 - y);
|
||||
if e < 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (x0, y0) = (x.floor() as usize, y.floor() as usize);
|
||||
let (x1, y1) = ((x0 + 1).min(g.width - 1), (y0 + 1).min(g.height - 1));
|
||||
let (ax, ay) = ((x - x0 as f64) as f32, (y - y0 as f64) as f32);
|
||||
let at = |xx: usize, yy: usize| g.data[yy * g.width + xx];
|
||||
let top = at(x0, y0) * (1.0 - ax) + at(x1, y0) * ax;
|
||||
let bot = at(x0, y1) * (1.0 - ax) + at(x1, y1) * ax;
|
||||
let i = ty * map.width + tx;
|
||||
value[i] = (top * (1.0 - ay) + bot * ay) * gain;
|
||||
edge[i] = e as f32;
|
||||
}
|
||||
}
|
||||
Warped { value, edge }
|
||||
}
|
||||
|
||||
/// Central-difference gradient magnitude of `plane` at texel `i`, from the
|
||||
/// neighbours that exist.
|
||||
fn detail(plane: &[f32], width: usize, height: usize, i: usize) -> f32 {
|
||||
let (x, y) = (i % width, i / width);
|
||||
let c = plane[i];
|
||||
let mut g = 0.0f32;
|
||||
let mut diff = |j: usize| {
|
||||
let n = plane[j];
|
||||
if !n.is_nan() {
|
||||
g = g.max((n - c).abs());
|
||||
}
|
||||
};
|
||||
if x > 0 {
|
||||
diff(i - 1);
|
||||
}
|
||||
if x + 1 < width {
|
||||
diff(i + 1);
|
||||
}
|
||||
if y > 0 {
|
||||
diff(i - width);
|
||||
}
|
||||
if y + 1 < height {
|
||||
diff(i + width);
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// Cut the overlap between the composite and frame `k`, marking in `take`
|
||||
/// the overlap texels that go to `k`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn cut(
|
||||
map: &SeamMap,
|
||||
value: &[f32],
|
||||
edge: &[f32],
|
||||
new: &Warped,
|
||||
overlap: &[usize],
|
||||
centres: &[(f64, f64)],
|
||||
k: usize,
|
||||
opts: &SeamOptions,
|
||||
take: &mut [bool],
|
||||
) {
|
||||
let (w, h) = (map.width, map.height);
|
||||
|
||||
// The raw cost per overlap texel.
|
||||
let mut raw = vec![f32::NAN; w * h];
|
||||
let margin = opts.edge_margin.max(1.0);
|
||||
for &i in overlap {
|
||||
let differ = (value[i] - new.value[i]).abs();
|
||||
let detail = detail(value, w, h, i).max(detail(&new.value, w, h, i));
|
||||
let near = (1.0 - edge[i].min(new.edge[i]) / margin).max(0.0);
|
||||
raw[i] = differ + opts.detail * detail + opts.edge * near * near + 1e-3;
|
||||
}
|
||||
// The worst over a small window: a texel is only cheap if its whole
|
||||
// neighbourhood agrees, so the path keeps at least the blend's radius
|
||||
// clear of a difference rather than threading the one lucky texel
|
||||
// beside it — the blend straddles the path by that much and would
|
||||
// otherwise reach the difference anyway.
|
||||
let r = opts.smoothing as isize;
|
||||
let mut cost = vec![OUTSIDE; w * h];
|
||||
for &i in overlap {
|
||||
let (x, y) = ((i % w) as isize, (i / w) as isize);
|
||||
let mut worst = 0.0f32;
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let (xx, yy) = (x + dx, y + dy);
|
||||
if xx < 0 || yy < 0 || xx >= w as isize || yy >= h as isize {
|
||||
continue;
|
||||
}
|
||||
let c = raw[yy as usize * w + xx as usize];
|
||||
if !c.is_nan() {
|
||||
worst = worst.max(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
cost[i] = worst;
|
||||
}
|
||||
|
||||
// The axis the cut crosses: from the composite's frames, weighted by how
|
||||
// much of the overlap each owns, to the new frame.
|
||||
let mut from = (0.0f64, 0.0f64);
|
||||
for &i in overlap {
|
||||
let c = centres[usize::from(map.labels[i])];
|
||||
from = (from.0 + c.0, from.1 + c.1);
|
||||
}
|
||||
let m = overlap.len() as f64;
|
||||
from = (from.0 / m, from.1 / m);
|
||||
let to = centres[k];
|
||||
let (mut ax, mut ay) = (to.0 - from.0, to.1 - from.1);
|
||||
let len = (ax * ax + ay * ay).sqrt();
|
||||
if len < 1e-6 {
|
||||
(ax, ay) = (1.0, 0.0);
|
||||
} else {
|
||||
(ax, ay) = (ax / len, ay / len);
|
||||
}
|
||||
// Along the cut: perpendicular to the axis.
|
||||
let (bx, by) = (-ay, ax);
|
||||
|
||||
// The overlap's extent in (s along the cut, t across it).
|
||||
let st = |i: usize| {
|
||||
let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5);
|
||||
(x * bx + y * by, x * ax + y * ay)
|
||||
};
|
||||
let (mut s0, mut s1, mut t0, mut t1) = (f64::MAX, f64::MIN, f64::MAX, f64::MIN);
|
||||
for &i in overlap {
|
||||
let (s, t) = st(i);
|
||||
s0 = s0.min(s);
|
||||
s1 = s1.max(s);
|
||||
t0 = t0.min(t);
|
||||
t1 = t1.max(t);
|
||||
}
|
||||
let rows = (s1 - s0).round() as usize + 1;
|
||||
let cols = (t1 - t0).round() as usize + 1;
|
||||
|
||||
// The grid in (s, t), each cell sampled from the texel it falls in, so
|
||||
// that a rotated overlap has no holes.
|
||||
let mut grid = vec![OUTSIDE; rows * cols];
|
||||
let mut any = vec![false; rows];
|
||||
for si in 0..rows {
|
||||
for ti in 0..cols {
|
||||
let (s, t) = (s0 + si as f64, t0 + ti as f64);
|
||||
let x = s * bx + t * ax;
|
||||
let y = s * by + t * ay;
|
||||
if x < 0.0 || y < 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (x, y) = (x as usize, y as usize);
|
||||
if x >= w || y >= h {
|
||||
continue;
|
||||
}
|
||||
let c = cost[y * w + x];
|
||||
if c < OUTSIDE {
|
||||
grid[si * cols + ti] = c;
|
||||
any[si] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic programming down the rows: the path moves at most one column
|
||||
// per row, and starts afresh after a row with no overlap in it.
|
||||
let mut acc = grid.clone();
|
||||
let mut from_col = vec![0u32; rows * cols];
|
||||
for si in 1..rows {
|
||||
if !any[si] {
|
||||
continue;
|
||||
}
|
||||
let prev = &acc[(si - 1) * cols..si * cols].to_vec();
|
||||
if !any[si - 1] {
|
||||
continue;
|
||||
}
|
||||
for ti in 0..cols {
|
||||
let mut best = (prev[ti], ti);
|
||||
if ti > 0 && prev[ti - 1] < best.0 {
|
||||
best = (prev[ti - 1], ti - 1);
|
||||
}
|
||||
if ti + 1 < cols && prev[ti + 1] < best.0 {
|
||||
best = (prev[ti + 1], ti + 1);
|
||||
}
|
||||
acc[si * cols + ti] += best.0;
|
||||
from_col[si * cols + ti] = best.1 as u32;
|
||||
}
|
||||
}
|
||||
// Back up from the end of each run of rows with overlap.
|
||||
let mut seam = vec![usize::MAX; rows];
|
||||
let mut si = rows;
|
||||
while si > 0 {
|
||||
si -= 1;
|
||||
if !any[si] {
|
||||
continue;
|
||||
}
|
||||
let row = &acc[si * cols..(si + 1) * cols];
|
||||
let mut t = (0..cols)
|
||||
.min_by(|&a, &b| row[a].total_cmp(&row[b]))
|
||||
.unwrap_or(0);
|
||||
loop {
|
||||
seam[si] = t;
|
||||
if si == 0 || !any[si - 1] {
|
||||
break;
|
||||
}
|
||||
t = from_col[si * cols + t] as usize;
|
||||
si -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
// The new frame takes the side of the path its centre is on.
|
||||
for &i in overlap {
|
||||
let (s, t) = st(i);
|
||||
let si = ((s - s0).round() as usize).min(rows - 1);
|
||||
let ti = (t - t0).round();
|
||||
if seam[si] != usize::MAX && ti >= seam[si] as f64 {
|
||||
take[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::linalg::{Mat3, Vec3};
|
||||
|
||||
/// A scene as a function of direction, and frames of it rendered by the
|
||||
/// same cameras the seam reads.
|
||||
fn render(
|
||||
cameras: &Cameras,
|
||||
k: usize,
|
||||
size: (usize, usize),
|
||||
scene: impl Fn(Vec3) -> f32,
|
||||
) -> Gray {
|
||||
let (w, h) = size;
|
||||
let mut data = vec![0.0; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let p = (
|
||||
x as f64 + 0.5 - w as f64 / 2.0,
|
||||
y as f64 + 0.5 - h as f64 / 2.0,
|
||||
);
|
||||
data[y * w + x] = scene(cameras.bearing(k, p));
|
||||
}
|
||||
}
|
||||
Gray {
|
||||
width: w,
|
||||
height: h,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
fn yaw(a: f64) -> Mat3 {
|
||||
let (s, c) = a.sin_cos();
|
||||
Mat3([[c, 0.0, s], [0.0, 1.0, 0.0], [-s, 0.0, c]])
|
||||
}
|
||||
|
||||
/// Smooth, with a little texture: what a sky over a slope looks like to
|
||||
/// the cost.
|
||||
fn landscape(d: Vec3) -> f32 {
|
||||
let (x, y) = (d.x() / d.z(), d.y() / d.z());
|
||||
let texture = if y > 0.1 { 0.1 * (y * 40.0).sin() } else { 0.0 };
|
||||
(0.5 + 0.2 * (x * 3.0).sin() + texture).clamp(0.0, 1.0) as f32
|
||||
}
|
||||
|
||||
fn pair() -> Cameras {
|
||||
Cameras {
|
||||
rotations: vec![Mat3::IDENTITY, yaw(0.35)],
|
||||
focal: 300.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_frame_owns_everything_it_reaches() {
|
||||
let cameras = Cameras {
|
||||
rotations: vec![Mat3::IDENTITY],
|
||||
focal: 300.0,
|
||||
};
|
||||
let g = render(&cameras, 0, (320, 240), landscape);
|
||||
let map = find(
|
||||
&[&g],
|
||||
&cameras,
|
||||
&[1.0],
|
||||
Projection::Perspective,
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let owned = map.labels.iter().filter(|&&l| l == 0).count();
|
||||
assert!(owned as f64 > 0.95 * (map.width * map.height) as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_frame_keeps_its_own_side() {
|
||||
let cameras = pair();
|
||||
let frames: Vec<Gray> = (0..2)
|
||||
.map(|k| render(&cameras, k, (320, 240), landscape))
|
||||
.collect();
|
||||
let refs: Vec<&Gray> = frames.iter().collect();
|
||||
let map = find(
|
||||
&refs,
|
||||
&cameras,
|
||||
&[1.0, 1.0],
|
||||
Projection::Cylindrical,
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mid = map.height / 2 * map.width;
|
||||
assert_eq!(map.labels[mid + 2], 0, "the left edge is frame 0's alone");
|
||||
assert_eq!(
|
||||
map.labels[mid + map.width - 3],
|
||||
1,
|
||||
"the right edge is frame 1's"
|
||||
);
|
||||
// One change of owner along every row that both frames cross.
|
||||
for y in 0..map.height {
|
||||
let row = &map.labels[y * map.width..(y + 1) * map.width];
|
||||
let owned: Vec<u8> = row.iter().copied().filter(|&l| l != NONE).collect();
|
||||
let changes = owned.windows(2).filter(|p| p[0] != p[1]).count();
|
||||
assert!(changes <= 1, "row {y} changes owner {changes} times");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_seam_goes_round_what_only_one_frame_saw() {
|
||||
// Frame 1 saw something frame 0 did not — a figure that walked into
|
||||
// the overlap — in the middle of where the two meet.
|
||||
let cameras = pair();
|
||||
let figure = Vec3::new(0.175f64.sin(), 0.0, 0.175f64.cos());
|
||||
let walker = |d: Vec3| {
|
||||
let near = (d.x() - figure.x()).abs() < 0.04 && (d.y() - figure.y()).abs() < 0.15;
|
||||
if near {
|
||||
0.95
|
||||
} else {
|
||||
landscape(d)
|
||||
}
|
||||
};
|
||||
let frames = [
|
||||
render(&cameras, 0, (320, 240), landscape),
|
||||
render(&cameras, 1, (320, 240), walker),
|
||||
];
|
||||
let refs: Vec<&Gray> = frames.iter().collect();
|
||||
let map = find(
|
||||
&refs,
|
||||
&cameras,
|
||||
&[1.0, 1.0],
|
||||
Projection::Cylindrical,
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
// Every texel of the figure is taken from the same frame, with a
|
||||
// blend radius of room to spare, so it is either all there or not at
|
||||
// all — never half.
|
||||
let (u, v) = Projection::Cylindrical
|
||||
.from_direction(map.scale, figure)
|
||||
.unwrap();
|
||||
let mut owners = std::collections::HashSet::new();
|
||||
// The figure's extent on the surface, plus the blend's radius.
|
||||
let radius = 3.0;
|
||||
let reach = |half: f64| half * map.scale + radius * map.px;
|
||||
let (ru, rv) = (reach(0.04), reach(0.15));
|
||||
let mut dv = -rv;
|
||||
while dv <= rv {
|
||||
let mut du = -ru;
|
||||
while du <= ru {
|
||||
let s = map.share(1, u + du, v + dv, map.scale, radius);
|
||||
owners.insert((s.unwrap() * 100.0).round() as i32);
|
||||
du += map.px;
|
||||
}
|
||||
dv += map.px;
|
||||
}
|
||||
assert_eq!(owners.len(), 1, "the figure is split: shares {owners:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_is_a_blend_across_the_seam_and_whole_away_from_it() {
|
||||
let map = SeamMap {
|
||||
width: 8,
|
||||
height: 1,
|
||||
scale: 1.0,
|
||||
origin: (0.0, 0.0),
|
||||
px: 1.0,
|
||||
labels: vec![0, 0, 0, 0, 1, 1, 1, 1],
|
||||
};
|
||||
assert_eq!(map.share(0, 1.5, 0.5, 1.0, 2.0), Some(1.0));
|
||||
assert_eq!(map.share(1, 6.5, 0.5, 1.0, 2.0), Some(1.0));
|
||||
let at_seam = map.share(0, 4.0, 0.5, 1.0, 2.0).unwrap();
|
||||
assert!((at_seam - 0.5).abs() < 1e-6, "{at_seam}");
|
||||
// And at twice the scale, the same point is twice as far out.
|
||||
assert_eq!(
|
||||
map.share(0, 8.0, 1.0, 2.0, 2.0),
|
||||
map.share(0, 4.0, 0.5, 1.0, 2.0)
|
||||
);
|
||||
let empty = SeamMap {
|
||||
labels: vec![NONE; 8],
|
||||
..map
|
||||
};
|
||||
assert_eq!(empty.share(0, 4.0, 0.5, 1.0, 2.0), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
id: camera_profile
|
||||
order: 25
|
||||
# A `rust:` node publishes its own descriptor; its attributes are on the type
|
||||
# in `../src/ops/camera_profile.rs`.
|
||||
rust: CameraProfile
|
||||
|
||||
why_rust: |
|
||||
It reads the source's profile tables from a storage buffer no declaration can
|
||||
name, and it is composed at its defaults — a raw whose profile is on is
|
||||
rendered through it without the photographer having touched anything —
|
||||
which a declared node cannot say (D20).
|
||||
|
||||
placement: |
|
||||
After exposure, before contrast (D20, camera-profiles.md §3). Hue and
|
||||
saturation do not change under the uniform gains before it, so a 2.5-D
|
||||
HueSatMap gives the same answer here as straight after the matrix; and the
|
||||
LookTable sees the exposure the photographer chose, as the DNG SDK's does.
|
||||
Contrast, tone and the colour controls then act on the profiled colour, as
|
||||
they do in Camera Raw.
|
||||
@@ -21,24 +21,41 @@ params:
|
||||
kind: amount
|
||||
|
||||
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: |
|
||||
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
|
||||
// that are already saturated. Squaring the falloff keeps the mid-range
|
||||
// responsive while still protecting the extremes.
|
||||
let falloff = (1.0 - sat) * (1.0 - sat);
|
||||
|
||||
// Skin protection. Skin sits in a narrow band of hue where red leads green
|
||||
// leads blue; pushing it is what makes vibrance look wrong on portraits.
|
||||
// Detected by channel ordering rather than a hue angle, which costs a
|
||||
// conversion and buys nothing here.
|
||||
let is_skin = f32(c.r > c.g && c.g > c.b);
|
||||
// Skin protection, for skin: hues between about 10 and 50 degrees (red
|
||||
// leading, green between red and blue) that are not strongly saturated.
|
||||
// Red-over-green-over-blue alone is every warm colour in a photograph —
|
||||
// wood, sand, brick, sunlit grass — and halving all of them is most of why
|
||||
// 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 strength = amount * falloff * skin_guard;
|
||||
@@ -49,9 +66,18 @@ tests:
|
||||
- name: it_starts_neutral
|
||||
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 }
|
||||
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
|
||||
why: |
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
drpl 1
|
||||
|
||||
# Vivid: more colour than the default rendering (camera-profiles.md §9).
|
||||
#
|
||||
# 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
|
||||
# skin back, so a frame gets richer before anything in it looks painted.
|
||||
# Saturation, which moves every colour alike, is used sparingly on top.
|
||||
#
|
||||
# Each changes only what it names (FR-DEV-6), so a corrected exposure or
|
||||
# white balance survives applying one.
|
||||
|
||||
[preset Vivid]
|
||||
contrast.contrast = 10
|
||||
saturation.saturation = 8
|
||||
vibrance.vibrance = 30
|
||||
|
||||
[preset Vivid, strong]
|
||||
blacks_whites.blacks = -10
|
||||
clarity.amount = 8
|
||||
contrast.contrast = 18
|
||||
saturation.saturation = 15
|
||||
vibrance.vibrance = 45
|
||||
|
||||
# Foliage and sky: green and chartreuse for leaves and grass, azure and blue
|
||||
# for sky and water, a little yellow for dry grass and stone. The skin bands
|
||||
# — red and orange — are left where they are, so a figure in a landscape
|
||||
# keeps a human complexion.
|
||||
[preset Vivid landscape]
|
||||
clarity.amount = 10
|
||||
colour_mixer.azure_lum = -10
|
||||
colour_mixer.azure_sat = 20
|
||||
colour_mixer.blue_lum = -10
|
||||
colour_mixer.blue_sat = 15
|
||||
colour_mixer.chartreuse_sat = 15
|
||||
colour_mixer.green_sat = 20
|
||||
colour_mixer.yellow_sat = 10
|
||||
contrast.contrast = 12
|
||||
saturation.saturation = 5
|
||||
vibrance.vibrance = 25
|
||||
|
||||
# Golden hour: oranges and yellows up and a warm cast laid over the
|
||||
# highlights only, so shadows stay clean rather than muddy.
|
||||
[preset Vivid warm]
|
||||
colour_grading.highlight_hue = 45
|
||||
colour_grading.highlight_strength = 12
|
||||
colour_mixer.orange_sat = 15
|
||||
colour_mixer.red_sat = 8
|
||||
colour_mixer.yellow_sat = 15
|
||||
contrast.contrast = 8
|
||||
vibrance.vibrance = 25
|
||||
|
||||
# People: everything around the subject gets richer while skin does not.
|
||||
# Vibrance already protects skin; the orange and red bands are then held a
|
||||
# little below where they started, because a face is the one colour every
|
||||
# viewer knows the right value of.
|
||||
[preset Vivid portrait]
|
||||
colour_mixer.azure_sat = 10
|
||||
colour_mixer.blue_sat = 12
|
||||
colour_mixer.green_sat = 12
|
||||
colour_mixer.orange_sat = -10
|
||||
colour_mixer.red_sat = -5
|
||||
contrast.contrast = 6
|
||||
saturation.saturation = -5
|
||||
vibrance.vibrance = 25
|
||||
@@ -65,6 +65,7 @@ const SECTIONS: &[(&str, &str, &str)] = &[
|
||||
include_str!("../presets/essentials.drpl"),
|
||||
),
|
||||
("skies", "Skies", include_str!("../presets/skies.drpl")),
|
||||
("vivid", "Vivid", include_str!("../presets/vivid.drpl")),
|
||||
(
|
||||
"colour_film",
|
||||
"Film/Colour",
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
/// Its own constructor because the crop rect needs four of them and the
|
||||
|
||||
@@ -260,8 +260,9 @@ impl CropRect {
|
||||
///
|
||||
/// `anchor` is the point of the rect that stays put, in the rect's own
|
||||
/// `0..1` coordinates: `(1.0, 1.0)` while the top-left handle is dragged,
|
||||
/// so the far corner is the one that does not move, and `(0.5, 0.5)` when
|
||||
/// a ratio is chosen and the composition should stay where it is.
|
||||
/// so the far corner is the one that does not move, `(0.0, 0.5)` while
|
||||
/// the right-hand edge is dragged, and `(0.5, 0.5)` when a ratio is
|
||||
/// chosen and the composition should stay where it is.
|
||||
///
|
||||
/// **The rect grows onto the ratio rather than shrinking onto it.** The
|
||||
/// axis that is short is extended; the long one is never trimmed. Fitting
|
||||
@@ -269,6 +270,7 @@ impl CropRect {
|
||||
/// along one axis alone would be immediately clamped back by the other,
|
||||
/// and the handle would simply refuse to move. The result is then scaled
|
||||
/// down, both axes together, only as far as the frame's edge demands.
|
||||
/// The exception is an edge: see the note in the body.
|
||||
pub fn with_aspect(self, frame_w: u32, frame_h: u32, ratio: f32, anchor: (f32, f32)) -> Self {
|
||||
let rect = self.normalised();
|
||||
let ratio = finite(ratio, 0.0);
|
||||
@@ -286,8 +288,19 @@ impl CropRect {
|
||||
let px = rect.x + ax * rect.width;
|
||||
let py = rect.y + ay * rect.height;
|
||||
|
||||
let mut w = rect.width.max(rect.height * r);
|
||||
let mut h = w / r;
|
||||
// An anchor in the middle of one side is an *edge* being dragged, and
|
||||
// then the axis across that edge leads: it is the only one the user
|
||||
// moved. Growing the short axis instead would take the other side
|
||||
// for the leader whenever the edge went inward, and the edge would be
|
||||
// pushed straight back out — a handle that only ever grows the crop.
|
||||
let (mut w, mut h) = if ax == 0.5 && ay != 0.5 {
|
||||
(rect.height * r, rect.height)
|
||||
} else if ay == 0.5 && ax != 0.5 {
|
||||
(rect.width, rect.width / r)
|
||||
} else {
|
||||
let w = rect.width.max(rect.height * r);
|
||||
(w, w / r)
|
||||
};
|
||||
|
||||
// Scaled to fit, never clamped to fit: clamping one axis against the
|
||||
// frame would break the very ratio this exists to hold.
|
||||
@@ -2255,6 +2268,42 @@ mod tests {
|
||||
assert!((c.y - start.y).abs() < 1e-5, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_edge_leads_and_the_far_side_stays_put() {
|
||||
// An edge dragged inward under a lock must narrow the crop. With the
|
||||
// short axis leading, the untouched height would win and push the
|
||||
// edge straight back out.
|
||||
let start = CropRect {
|
||||
x: 0.2,
|
||||
y: 0.2,
|
||||
width: 0.4,
|
||||
height: 0.6,
|
||||
};
|
||||
// Right edge held, dragged in: the left side and the vertical
|
||||
// centre stay, the width is what was asked for.
|
||||
let c = start.with_aspect(4000, 4000, 1.0, (0.0, 0.5));
|
||||
assert!((c.x - start.x).abs() < 1e-5, "{c:?}");
|
||||
assert!((c.width - start.width).abs() < 1e-5, "{c:?}");
|
||||
assert!((c.height - start.width).abs() < 1e-5, "{c:?}");
|
||||
assert!(
|
||||
(c.y + c.height / 2.0 - (start.y + start.height / 2.0)).abs() < 1e-5,
|
||||
"{c:?}"
|
||||
);
|
||||
|
||||
// Top edge held: the bottom and the horizontal centre stay, the
|
||||
// height is what was asked for.
|
||||
let c = start.with_aspect(4000, 4000, 1.0, (0.5, 1.0));
|
||||
assert!(
|
||||
(c.y + c.height - (start.y + start.height)).abs() < 1e-5,
|
||||
"{c:?}"
|
||||
);
|
||||
assert!((c.width - c.height).abs() < 1e-5, "{c:?}");
|
||||
assert!(
|
||||
(c.x + c.width / 2.0 - (start.x + start.width / 2.0)).abs() < 1e-5,
|
||||
"{c:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_rect_grows_onto_the_ratio_rather_than_shrinking_onto_it() {
|
||||
// Shrinking to fit makes a one-axis drag do nothing at all: the other
|
||||
|
||||
@@ -170,6 +170,18 @@ pub struct EditGraph {
|
||||
/// correction the photograph asked for — see
|
||||
/// [`crate::descriptor::ParamDescriptor::switch_on`].
|
||||
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
|
||||
@@ -226,6 +238,9 @@ impl EditGraph {
|
||||
],
|
||||
lens_profile: None,
|
||||
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()
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// Whether the matched profile is being applied.
|
||||
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
|
||||
.into_iter()
|
||||
.chain(warps)
|
||||
.chain(denoise)
|
||||
.chain(ops)
|
||||
.chain(std::iter::once(framing))
|
||||
.collect()
|
||||
@@ -675,6 +738,11 @@ impl EditGraph {
|
||||
// `capabilities`, with the operations and the warps and for the
|
||||
// same reason (FR-DEV-3c).
|
||||
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,
|
||||
film,
|
||||
spots,
|
||||
@@ -746,6 +814,16 @@ impl EditGraph {
|
||||
}
|
||||
|
||||
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
|
||||
if op == crate::learned_denoise::ID {
|
||||
match param {
|
||||
p if p == crate::learned_denoise::APPLY => self.denoise_applied = value != 0.0,
|
||||
p if p == crate::learned_denoise::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 param != crate::lens::profile_switch::APPLY {
|
||||
log::warn!("unknown parameter {param} on {op}; ignoring");
|
||||
@@ -803,6 +881,15 @@ impl EditGraph {
|
||||
|
||||
/// Read a parameter back.
|
||||
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 {
|
||||
return (param == crate::lens::profile_switch::APPLY)
|
||||
.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
|
||||
// to default is the answer to whether to use it, which is on.
|
||||
self.set_lens_profile_applied(true);
|
||||
// The learned denoise returns to off; whether it is available is the
|
||||
// file's and stays.
|
||||
self.denoise_applied = false;
|
||||
self.denoise_grain = 0.0;
|
||||
}
|
||||
|
||||
/// Set the crop rectangle. Clamped to keep it inside the frame.
|
||||
@@ -1170,19 +1261,21 @@ mod tests {
|
||||
// Opening an unedited image must produce the image, not an
|
||||
// interpretation of it.
|
||||
//
|
||||
// One block, and it is the view transform: a view operation is
|
||||
// composed at its defaults, because a photograph with no view
|
||||
// transform is a scan rather than a picture (FR-DEV-3j). It is still
|
||||
// neutral in the sense that matters here — nothing moved, nothing is
|
||||
// written — and every adjustment is absent.
|
||||
// Two blocks, the view transform and the camera profile: both are
|
||||
// composed at their defaults, because a photograph with no view
|
||||
// transform is a scan rather than a picture (FR-DEV-3j) and a raw
|
||||
// with a profile is rendered through it (D20). They are still neutral
|
||||
// in the sense that matters here — nothing moved, nothing is written
|
||||
// — and every adjustment is absent.
|
||||
let g = EditGraph::default_chain();
|
||||
assert!(g.is_neutral());
|
||||
let source = g.compose().source;
|
||||
assert_eq!(
|
||||
source.matches("---- ").count(),
|
||||
1,
|
||||
2,
|
||||
"a neutral graph must generate no adjustment blocks"
|
||||
);
|
||||
assert!(source.contains("---- camera_profile ----"));
|
||||
assert!(source.contains("---- view_transform ----"));
|
||||
}
|
||||
|
||||
@@ -1263,13 +1356,14 @@ mod tests {
|
||||
fn only_active_operations_reach_the_shader() {
|
||||
// The composition property, end to end: two adjustments out of seven
|
||||
// available must generate a shader doing exactly two things — and
|
||||
// the view transform, which every render has (FR-DEV-3j).
|
||||
// the view transform and camera profile, which every render has
|
||||
// (FR-DEV-3j, D20).
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
||||
g.set_param(white_balance::ID, white_balance::TINT, 25.0);
|
||||
|
||||
let shader = g.compose();
|
||||
assert_eq!(shader.source.matches("---- ").count(), 3);
|
||||
assert_eq!(shader.source.matches("---- ").count(), 4);
|
||||
assert!(shader.source.contains("---- view_transform ----"));
|
||||
assert!(shader.source.contains("---- exposure ----"));
|
||||
assert!(shader.source.contains("---- white_balance ----"));
|
||||
@@ -1992,4 +2086,40 @@ mod tests {
|
||||
let after = cropped.render_scale(source, (1500, 1000));
|
||||
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).
|
||||
|
||||
pub mod bundled;
|
||||
pub mod camera_raw;
|
||||
pub mod coverage;
|
||||
pub mod declared;
|
||||
pub mod descriptor;
|
||||
@@ -40,6 +41,7 @@ pub mod detail;
|
||||
pub mod framing;
|
||||
pub mod graph;
|
||||
pub mod history;
|
||||
pub mod learned_denoise;
|
||||
pub mod lens;
|
||||
pub mod mask;
|
||||
pub mod neutral;
|
||||
@@ -115,6 +117,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// Flipping every switch turns the camera profile *off*, which is
|
||||
// active — moved from the default — and composes nothing. Put it
|
||||
// back on; its look strength stays moved, so it is still active and
|
||||
// now doing something, which is what "fully active" means here (D20).
|
||||
g.set_param(
|
||||
crate::ops::camera_profile::ID,
|
||||
crate::ops::camera_profile::APPLY,
|
||||
1.0,
|
||||
);
|
||||
|
||||
// `film_sim` is the one node a moved parameter cannot activate: it
|
||||
// needs a stock's measured tables, which are not parameters and which
|
||||
// no slider produces. So it is loaded explicitly here.
|
||||
|
||||
@@ -270,6 +270,19 @@ pub trait Operation: Send + Sync {
|
||||
/// what is actually used.
|
||||
fn is_active(&self) -> bool;
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// Whether the composer emits this operation's fragment.
|
||||
///
|
||||
/// Default: exactly when it [`Self::is_active`]. The exception is an
|
||||
/// operation that *is* part of the rendering at its defaults — the
|
||||
/// camera profile, which an untouched raw is rendered through (D20) —
|
||||
/// where "moved from the defaults" and "does something" come apart. Such
|
||||
/// an operation keeps `is_active` meaning the former, so a sidecar still
|
||||
/// stores nothing for it, and answers this with the latter.
|
||||
fn composes(&self) -> bool {
|
||||
self.is_active()
|
||||
}
|
||||
|
||||
/// The WGSL body of this operation's transform.
|
||||
///
|
||||
/// Receives `c` (a `vec3<f32>` of linear RGB) and must produce the
|
||||
@@ -843,7 +856,7 @@ fn compose_inner(
|
||||
let active: Vec<&dyn Operation> = ops
|
||||
.iter()
|
||||
.map(|o| o.as_ref())
|
||||
.filter(|o| o.is_active() && o.detail().is_none())
|
||||
.filter(|o| o.composes() && o.detail().is_none())
|
||||
.collect();
|
||||
|
||||
// Whether a detail stage follows. If one does, this pass stops short of
|
||||
@@ -1040,7 +1053,7 @@ fn compose_inner(
|
||||
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect();
|
||||
// The global side of the blend. A view operation always has one; see
|
||||
// `Stage::View`.
|
||||
let global = op.is_active() || op.stage() == Stage::View;
|
||||
let global = op.composes() || op.stage() == Stage::View;
|
||||
if !global && local.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -1333,6 +1346,11 @@ struct Params {{
|
||||
// bound to 1x1 placeholders whenever the flags say not to touch them.
|
||||
@group(0) @binding(6) var sampled: texture_2d<f32>;
|
||||
@group(0) @binding(7) var sample_out: texture_storage_2d<rgba16float, write>;
|
||||
// The source's camera profile tables (FR-DEV-3e, D20): a two-entry header,
|
||||
// then the entries (`ops::camera_profile::profile_buffer`). Declared
|
||||
// unconditionally like the masks, and bound to a header of zeros — no
|
||||
// tables — for every source without a profile.
|
||||
@group(0) @binding(8) var<storage, read> profile_table: array<vec4<f32>>;
|
||||
|
||||
{WINDOW_HELPER}{sampler_helper}{helper_src}{encode_output}
|
||||
// Display-encoded sRGB back to linear, for sources that arrive that way.
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
//! TRACES: FR-DEV-3e
|
||||
//! The camera profile's tables as an operation (D20).
|
||||
//!
|
||||
//! The matrix turns camera RGB into colour; a DNG camera profile adds two
|
||||
//! lookups over hue, saturation and value on top of it — the `HueSatMap`, a
|
||||
//! calibration, and the `LookTable`, a rendering intent. This operation
|
||||
//! applies them. `docs/dev/camera-profiles.md` is the design.
|
||||
//!
|
||||
//! # Where the tables come from
|
||||
//!
|
||||
//! Not from here. They belong to the *source*, like the matrix: `dr-decode`
|
||||
//! resolves them per file and `dr-gpu` uploads them to the storage buffer
|
||||
//! every generated shader declares at `@binding(8)`, laid out by
|
||||
//! [`profile_buffer`]. This operation holds only the photographer's two
|
||||
//! settings — whether to use the profile, and how strongly to apply its look
|
||||
//! — so a render path never has to remember to hand it anything.
|
||||
//!
|
||||
//! # Why it is composed at its defaults
|
||||
//!
|
||||
//! A profile that is on is the rendering, not an edit: an untouched raw
|
||||
//! renders through it and writes no parameters. So [`Operation::composes`]
|
||||
//! answers "is the switch on", not "has anything moved". The fragment then
|
||||
//! branches on the buffer's header, which says whether this source has tables
|
||||
//! at all; a JPEG, or a raw with no profile, reads two zeros and passes
|
||||
//! through.
|
||||
//!
|
||||
//! # The lookup
|
||||
//!
|
||||
//! The DNG SDK's `RefBaselineHueSatMap`, with the two departures §2 of the
|
||||
//! design gives for scene-referred values: value is not clamped on the way
|
||||
//! out, and a colour with a negative ProPhoto component passes through.
|
||||
//! [`apply_reference`] is the same arithmetic on the CPU, and the GPU tests
|
||||
//! hold the shader to it.
|
||||
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use dr_types::{HueSatTable, ProfileTables};
|
||||
|
||||
use crate::descriptor::{
|
||||
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||
};
|
||||
use crate::operation::{Helper, Operation, Uniform};
|
||||
|
||||
pub const ID: OpId = OpId("camera_profile");
|
||||
pub const APPLY: ParamId = ParamId("apply");
|
||||
pub const LOOK: ParamId = ParamId("look");
|
||||
|
||||
/// The look's strength at which the LookTable is applied as the profile
|
||||
/// states it, in percent.
|
||||
pub const DEFAULT_LOOK: f32 = 100.0;
|
||||
/// Twice the profile's look.
|
||||
pub const MAX_LOOK: f32 = 200.0;
|
||||
|
||||
/// Entries of the buffer's header, before the entries themselves: one
|
||||
/// `vec4` describing each table — `(hue divisions, saturation divisions,
|
||||
/// value divisions, sRGB-encoded)`, zero hue divisions meaning absent — and
|
||||
/// 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(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
attributes: vec![Attribute::Colour],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.camera_profile"),
|
||||
params: vec![
|
||||
ParamDescriptor::switch_on("apply", "param.camera_profile.apply"),
|
||||
ParamDescriptor::scalar(
|
||||
"look",
|
||||
"param.camera_profile.look",
|
||||
0.0,
|
||||
MAX_LOOK,
|
||||
DEFAULT_LOOK,
|
||||
Unit::Percent,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
/// 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
|
||||
/// exactly and a neutral reaches the tables with zero saturation.
|
||||
pub(crate) fn working_prophoto() -> &'static ([f32; 9], [f32; 9]) {
|
||||
static M: LazyLock<([f32; 9], [f32; 9])> = LazyLock::new(|| {
|
||||
let to = normalise_rows(dr_types::ColourSpace::ProPhoto.from_linear_srgb());
|
||||
let back = normalise_rows(invert(&to).expect("ProPhoto's matrix is invertible"));
|
||||
(to, back)
|
||||
});
|
||||
&M
|
||||
}
|
||||
|
||||
fn normalise_rows(mut m: [f32; 9]) -> [f32; 9] {
|
||||
for row in m.chunks_exact_mut(3) {
|
||||
let sum: f32 = row.iter().sum();
|
||||
row.iter_mut().for_each(|v| *v /= sum);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
fn invert(m: &[f32; 9]) -> Option<[f32; 9]> {
|
||||
let [a, b, c, d, e, f, g, h, i] = m.map(f64::from);
|
||||
let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
|
||||
if det.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let inv = [
|
||||
(e * i - f * h) / det,
|
||||
(c * h - b * i) / det,
|
||||
(b * f - c * e) / det,
|
||||
(f * g - d * i) / det,
|
||||
(a * i - c * g) / det,
|
||||
(c * d - a * f) / det,
|
||||
(d * h - e * g) / det,
|
||||
(b * g - a * h) / det,
|
||||
(a * e - b * d) / det,
|
||||
];
|
||||
Some(inv.map(|v| v as f32))
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
/// A row-major matrix as a WGSL `mat3x3`, whose constructor takes columns.
|
||||
fn wgsl_mat(m: &[f32; 9]) -> String {
|
||||
let col = |j: usize| format!("vec3<f32>({:e}, {:e}, {:e})", m[j], m[3 + j], m[6 + j]);
|
||||
format!("mat3x3<f32>({}, {}, {})", col(0), col(1), col(2))
|
||||
}
|
||||
|
||||
/// 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 source = format!(
|
||||
"const PROFILE_FROM_WORKING = {};\nconst PROFILE_TO_WORKING = {};\n{SECTIONS_WGSL}",
|
||||
wgsl_mat(to),
|
||||
wgsl_mat(back)
|
||||
);
|
||||
Helper {
|
||||
name: "profile_curve_base",
|
||||
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.
|
||||
const LOOKUP_WGSL: &str = r#"
|
||||
fn profile_srgb_encode(v: f32) -> f32 {
|
||||
if (v <= 0.0031308) { return v * 12.92; }
|
||||
return 1.055 * pow(v, 1.0 / 2.4) - 0.055;
|
||||
}
|
||||
|
||||
fn profile_srgb_decode(v: f32) -> f32 {
|
||||
if (v <= 0.04045) { return v / 12.92; }
|
||||
return pow((v + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
// The DNG SDK's HSV: hue in [0, 6), saturation (max - min) / max, value max.
|
||||
fn profile_rgb_to_hsv(c: vec3<f32>) -> vec3<f32> {
|
||||
let v = max(c.r, max(c.g, c.b));
|
||||
let gap = v - min(c.r, min(c.g, c.b));
|
||||
if (gap <= 0.0) {
|
||||
return vec3<f32>(0.0, 0.0, v);
|
||||
}
|
||||
var h: f32;
|
||||
if (c.r == v) {
|
||||
h = (c.g - c.b) / gap;
|
||||
if (h < 0.0) { h += 6.0; }
|
||||
} else if (c.g == v) {
|
||||
h = 2.0 + (c.b - c.r) / gap;
|
||||
} else {
|
||||
h = 4.0 + (c.r - c.g) / gap;
|
||||
}
|
||||
return vec3<f32>(h, gap / v, v);
|
||||
}
|
||||
|
||||
fn profile_hsv_to_rgb(hsv: vec3<f32>) -> vec3<f32> {
|
||||
let s = hsv.y;
|
||||
let v = hsv.z;
|
||||
if (s <= 0.0) {
|
||||
return vec3<f32>(v);
|
||||
}
|
||||
let h = hsv.x - 6.0 * floor(hsv.x / 6.0);
|
||||
let i = min(floor(h), 5.0);
|
||||
let f = h - i;
|
||||
let p = v * (1.0 - s);
|
||||
let q = v * (1.0 - s * f);
|
||||
let t = v * (1.0 - s * (1.0 - f));
|
||||
switch (i32(i)) {
|
||||
case 0: { return vec3<f32>(v, t, p); }
|
||||
case 1: { return vec3<f32>(q, v, p); }
|
||||
case 2: { return vec3<f32>(p, v, t); }
|
||||
case 3: { return vec3<f32>(p, q, v); }
|
||||
case 4: { return vec3<f32>(t, p, v); }
|
||||
default: { return vec3<f32>(v, p, q); }
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_entry(base: u32, at: u32) -> vec3<f32> {
|
||||
return profile_table[base + at].xyz;
|
||||
}
|
||||
|
||||
// (hue shift in degrees, saturation scale, value scale) at `hsv`: bilinear
|
||||
// over hue and saturation, hue wrapping, and linear over value for a 3-D
|
||||
// table. Indices are the SDK's.
|
||||
fn profile_lookup(dims: vec4<f32>, base: u32, hsv: vec3<f32>) -> vec3<f32> {
|
||||
let hd = u32(dims.x);
|
||||
let sd = u32(dims.y);
|
||||
let vd = u32(dims.z);
|
||||
|
||||
var h0 = 0u;
|
||||
var h1 = 0u;
|
||||
var hf = 0.0;
|
||||
if (hd > 1u) {
|
||||
let hs = hsv.x * f32(hd) / 6.0;
|
||||
h0 = min(u32(hs), hd - 1u);
|
||||
hf = hs - f32(h0);
|
||||
h1 = h0 + 1u;
|
||||
if (h1 >= hd) { h1 = 0u; }
|
||||
}
|
||||
|
||||
let ss = hsv.y * f32(sd - 1u);
|
||||
let s0 = min(u32(ss), sd - 2u);
|
||||
let sf = ss - f32(s0);
|
||||
|
||||
var v0 = 0u;
|
||||
var vf = 0.0;
|
||||
if (vd > 1u) {
|
||||
var ve = clamp(hsv.z, 0.0, 1.0);
|
||||
if (dims.w > 0.5) { ve = profile_srgb_encode(ve); }
|
||||
let vs = ve * f32(vd - 1u);
|
||||
v0 = min(u32(vs), vd - 2u);
|
||||
vf = vs - f32(v0);
|
||||
}
|
||||
|
||||
let val_step = hd * sd;
|
||||
let lo = v0 * val_step;
|
||||
var d = mix(
|
||||
mix(profile_entry(base, lo + h0 * sd + s0), profile_entry(base, lo + h1 * sd + s0), hf),
|
||||
mix(profile_entry(base, lo + h0 * sd + s0 + 1u), profile_entry(base, lo + h1 * sd + s0 + 1u), hf),
|
||||
sf);
|
||||
if (vd > 1u) {
|
||||
let hi = lo + val_step;
|
||||
let e = mix(
|
||||
mix(profile_entry(base, hi + h0 * sd + s0), profile_entry(base, hi + h1 * sd + s0), hf),
|
||||
mix(profile_entry(base, hi + h0 * sd + s0 + 1u), profile_entry(base, hi + h1 * sd + s0 + 1u), hf),
|
||||
sf);
|
||||
d = mix(d, e, vf);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
// One table applied to a ProPhoto colour, its deltas scaled by `amount`.
|
||||
fn profile_apply(dims: vec4<f32>, base: u32, c: vec3<f32>, amount: f32) -> vec3<f32> {
|
||||
let hsv = profile_rgb_to_hsv(c);
|
||||
var d = profile_lookup(dims, base, hsv);
|
||||
d = vec3<f32>(d.x * amount, max(1.0 + (d.y - 1.0) * amount, 0.0), max(1.0 + (d.z - 1.0) * amount, 0.0));
|
||||
let h = hsv.x + d.x * (6.0 / 360.0);
|
||||
let s = min(hsv.y * d.y, 1.0);
|
||||
var v = hsv.z * d.z;
|
||||
if (dims.w > 0.5) {
|
||||
// The scale is defined on the encoded value; applied as the ratio it
|
||||
// makes at min(v, 1), so a value above 1.0 is scaled, not clipped.
|
||||
let vc = min(hsv.z, 1.0);
|
||||
v = hsv.z;
|
||||
if (vc > 0.0) {
|
||||
v = hsv.z * profile_srgb_decode(profile_srgb_encode(vc) * d.z) / vc;
|
||||
}
|
||||
}
|
||||
return profile_hsv_to_rgb(vec3<f32>(h, s, v));
|
||||
}
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CameraProfile {
|
||||
apply: bool,
|
||||
look: f32,
|
||||
}
|
||||
|
||||
impl Default for CameraProfile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
apply: true,
|
||||
look: DEFAULT_LOOK,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CameraProfile {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for CameraProfile {
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
APPLY => self.apply = value != 0.0,
|
||||
LOOK => self.look = value,
|
||||
_ => log::warn!("camera_profile: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
APPLY => f32::from(u8::from(self.apply)),
|
||||
LOOK => self.look,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
!self.apply || self.look != DEFAULT_LOOK
|
||||
}
|
||||
|
||||
fn composes(&self) -> bool {
|
||||
self.apply
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
"\
|
||||
let hue_sat_dims = profile_table[0];
|
||||
let look_dims = profile_table[1];
|
||||
if (hue_sat_dims.x > 0.0 || look_dims.x > 0.0) {
|
||||
var p = PROFILE_FROM_WORKING * c;
|
||||
// A colour outside ProPhoto has no HSV the tables were made for; it
|
||||
// passes through rather than being floored, which would clip it (D19).
|
||||
if (min(p.r, min(p.g, p.b)) >= 0.0) {
|
||||
if (hue_sat_dims.x > 0.0) {
|
||||
p = profile_apply(hue_sat_dims, 3u, p, 1.0);
|
||||
}
|
||||
if (look_dims.x > 0.0 && look > 0.0) {
|
||||
p = profile_apply(look_dims, profile_look_base(), p, look);
|
||||
}
|
||||
c = PROFILE_TO_WORKING * p;
|
||||
}
|
||||
}"
|
||||
.into()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
vec![Uniform {
|
||||
name: "look",
|
||||
value: self.look / 100.0,
|
||||
}]
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &[Helper] {
|
||||
HELPERS.as_slice()
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e | FR-DEV-3j
|
||||
/// The storage buffer a source's profile is uploaded as: the three header
|
||||
/// `vec4`s, the HueSatMap's entries, the LookTable's, each entry
|
||||
/// `(hue shift, saturation scale, value scale, 0)`, then the tone curve's
|
||||
/// samples in `.x`.
|
||||
///
|
||||
/// The curve is always there: the profile's own where it has one, Camera
|
||||
/// 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]> {
|
||||
let header = |t: Option<&HueSatTable>| match t {
|
||||
Some(t) => [
|
||||
t.hue_divisions as f32,
|
||||
t.sat_divisions as f32,
|
||||
t.val_divisions as f32,
|
||||
if t.srgb_encoded { 1.0 } else { 0.0 },
|
||||
],
|
||||
None => [0.0; 4],
|
||||
};
|
||||
let hue_sat = tables.and_then(|t| t.hue_sat.as_ref());
|
||||
let look = tables.and_then(|t| t.look.as_ref());
|
||||
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() {
|
||||
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
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The fragment's arithmetic on the CPU: a working-space colour through the
|
||||
/// source's tables, the look at `look` (1.0 = as the profile states it).
|
||||
///
|
||||
/// The reference the shader is tested against, and the statement of the
|
||||
/// algorithm a reader can step through.
|
||||
pub fn apply_reference(tables: &ProfileTables, c: [f32; 3], look: f32) -> [f32; 3] {
|
||||
let (to, back) = working_prophoto();
|
||||
let mut p = mul(to, c);
|
||||
if p.iter().any(|v| *v < 0.0) {
|
||||
return c;
|
||||
}
|
||||
if let Some(t) = &tables.hue_sat {
|
||||
p = apply_table(t, p, 1.0);
|
||||
}
|
||||
if let Some(t) = tables.look.as_ref().filter(|_| look > 0.0) {
|
||||
p = apply_table(t, p, look);
|
||||
}
|
||||
mul(back, p)
|
||||
}
|
||||
|
||||
fn srgb_encode(v: f32) -> f32 {
|
||||
if v <= 0.003_130_8 {
|
||||
v * 12.92
|
||||
} else {
|
||||
1.055 * v.powf(1.0 / 2.4) - 0.055
|
||||
}
|
||||
}
|
||||
|
||||
fn srgb_decode(v: f32) -> f32 {
|
||||
if v <= 0.040_45 {
|
||||
v / 12.92
|
||||
} else {
|
||||
((v + 0.055) / 1.055).powf(2.4)
|
||||
}
|
||||
}
|
||||
|
||||
/// The SDK's `DNG_RGBtoHSV`: hue in `[0, 6)`.
|
||||
pub fn rgb_to_hsv([r, g, b]: [f32; 3]) -> [f32; 3] {
|
||||
let v = r.max(g).max(b);
|
||||
let gap = v - r.min(g).min(b);
|
||||
if gap <= 0.0 {
|
||||
return [0.0, 0.0, v];
|
||||
}
|
||||
let h = if r == v {
|
||||
let h = (g - b) / gap;
|
||||
if h < 0.0 {
|
||||
h + 6.0
|
||||
} else {
|
||||
h
|
||||
}
|
||||
} else if g == v {
|
||||
2.0 + (b - r) / gap
|
||||
} else {
|
||||
4.0 + (r - g) / gap
|
||||
};
|
||||
[h, gap / v, v]
|
||||
}
|
||||
|
||||
pub fn hsv_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] {
|
||||
if s <= 0.0 {
|
||||
return [v; 3];
|
||||
}
|
||||
let h = h - 6.0 * (h / 6.0).floor();
|
||||
let i = h.floor().min(5.0);
|
||||
let f = h - i;
|
||||
let p = v * (1.0 - s);
|
||||
let q = v * (1.0 - s * f);
|
||||
let t = v * (1.0 - s * (1.0 - f));
|
||||
match i as i32 {
|
||||
0 => [v, t, p],
|
||||
1 => [q, v, p],
|
||||
2 => [p, v, t],
|
||||
3 => [p, q, v],
|
||||
4 => [t, p, v],
|
||||
_ => [v, p, q],
|
||||
}
|
||||
}
|
||||
|
||||
fn lookup(t: &HueSatTable, [h, s, v]: [f32; 3]) -> [f32; 3] {
|
||||
let (hd, sd, vd) = (t.hue_divisions, t.sat_divisions, t.val_divisions);
|
||||
let (mut h0, mut h1, mut hf) = (0u32, 0u32, 0.0f32);
|
||||
if hd > 1 {
|
||||
let hs = h * hd as f32 / 6.0;
|
||||
h0 = (hs as u32).min(hd - 1);
|
||||
hf = hs - h0 as f32;
|
||||
h1 = if h0 + 1 >= hd { 0 } else { h0 + 1 };
|
||||
}
|
||||
let ss = s * (sd - 1) as f32;
|
||||
let s0 = (ss as u32).min(sd - 2);
|
||||
let sf = ss - s0 as f32;
|
||||
let (mut v0, mut vf) = (0u32, 0.0f32);
|
||||
if vd > 1 {
|
||||
let mut ve = v.clamp(0.0, 1.0);
|
||||
if t.srgb_encoded {
|
||||
ve = srgb_encode(ve);
|
||||
}
|
||||
let vs = ve * (vd - 1) as f32;
|
||||
v0 = (vs as u32).min(vd - 2);
|
||||
vf = vs - v0 as f32;
|
||||
}
|
||||
let mix = |a: [f32; 3], b: [f32; 3], w: f32| -> [f32; 3] {
|
||||
std::array::from_fn(|i| a[i] + (b[i] - a[i]) * w)
|
||||
};
|
||||
let at = |v: u32, h: u32, s: u32| t.entries[t.index(h, s, v)];
|
||||
let plane = |v: u32| {
|
||||
mix(
|
||||
mix(at(v, h0, s0), at(v, h1, s0), hf),
|
||||
mix(at(v, h0, s0 + 1), at(v, h1, s0 + 1), hf),
|
||||
sf,
|
||||
)
|
||||
};
|
||||
let d = plane(v0);
|
||||
if vd > 1 {
|
||||
mix(d, plane(v0 + 1), vf)
|
||||
} else {
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_table(t: &HueSatTable, c: [f32; 3], amount: f32) -> [f32; 3] {
|
||||
let hsv = rgb_to_hsv(c);
|
||||
let d = lookup(t, hsv);
|
||||
let d = [
|
||||
d[0] * amount,
|
||||
(1.0 + (d[1] - 1.0) * amount).max(0.0),
|
||||
(1.0 + (d[2] - 1.0) * amount).max(0.0),
|
||||
];
|
||||
let h = hsv[0] + d[0] * (6.0 / 360.0);
|
||||
let s = (hsv[1] * d[1]).min(1.0);
|
||||
let v = if t.srgb_encoded {
|
||||
let vc = hsv[2].min(1.0);
|
||||
if vc > 0.0 {
|
||||
hsv[2] * srgb_decode(srgb_encode(vc) * d[2]) / vc
|
||||
} else {
|
||||
hsv[2]
|
||||
}
|
||||
} else {
|
||||
hsv[2] * d[2]
|
||||
};
|
||||
hsv_to_rgb([h, s, v])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_types::ProfileOrigin;
|
||||
|
||||
fn uniform(h: u32, s: u32, v: u32, e: [f32; 3]) -> HueSatTable {
|
||||
HueSatTable::new(h, s, v, false, vec![e; (h * s * v) as usize]).unwrap()
|
||||
}
|
||||
|
||||
fn tables(hue_sat: Option<HueSatTable>, look: Option<HueSatTable>) -> ProfileTables {
|
||||
ProfileTables {
|
||||
name: "test".into(),
|
||||
origin: ProfileOrigin::Embedded,
|
||||
hue_sat,
|
||||
look,
|
||||
tone_curve: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool {
|
||||
a.iter()
|
||||
.zip(b)
|
||||
.all(|(x, y)| (x - y).abs() <= tol * y.abs().max(1.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_starts_neutral_and_composed() {
|
||||
let op = CameraProfile::new();
|
||||
assert!(!op.is_active(), "an untouched photograph writes nothing");
|
||||
assert!(op.composes(), "and still renders through its profile");
|
||||
let mut off = CameraProfile::new();
|
||||
off.set_param(APPLY, 0.0);
|
||||
assert!(off.is_active() && !off.composes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_working_space_round_trips_through_prophoto() {
|
||||
let (to, back) = working_prophoto();
|
||||
for c in [[1.0, 1.0, 1.0], [0.2, 0.5, 0.1], [4.0, 0.3, 0.02]] {
|
||||
assert!(close(mul(back, mul(to, c)), c, 1e-5), "{c:?}");
|
||||
}
|
||||
let white = mul(to, [1.0; 3]);
|
||||
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-6), "{white:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hsv_round_trips() {
|
||||
for c in [
|
||||
[0.9, 0.2, 0.1],
|
||||
[0.1, 0.7, 0.3],
|
||||
[0.2, 0.3, 0.8],
|
||||
[0.5, 0.5, 0.5],
|
||||
[3.0, 1.0, 2.0],
|
||||
] {
|
||||
assert!(close(hsv_to_rgb(rgb_to_hsv(c)), c, 1e-6), "{c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grey_passes_through() {
|
||||
let t = tables(
|
||||
Some(uniform(6, 3, 1, [30.0, 1.5, 1.0])),
|
||||
Some(uniform(6, 3, 1, [-20.0, 1.3, 1.0])),
|
||||
);
|
||||
for v in [0.0, 0.18, 1.0, 8.0] {
|
||||
let out = apply_reference(&t, [v; 3], 1.0);
|
||||
assert!(close(out, [v; 3], 1e-5), "{v}: {out:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_identity_table_changes_nothing() {
|
||||
let t = tables(
|
||||
Some(uniform(90, 30, 1, [0.0, 1.0, 1.0])),
|
||||
Some(uniform(36, 8, 16, [0.0, 1.0, 1.0])),
|
||||
);
|
||||
for c in [[0.9, 0.2, 0.1], [0.05, 0.4, 0.2], [2.0, 0.5, 0.3]] {
|
||||
assert!(close(apply_reference(&t, c, 1.0), c, 1e-5), "{c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saturation_scale_scales_saturation() {
|
||||
let t = tables(Some(uniform(6, 3, 1, [0.0, 1.2, 1.0])), None);
|
||||
let (to, _) = working_prophoto();
|
||||
let c = [0.6, 0.3, 0.2];
|
||||
let before = rgb_to_hsv(mul(to, c));
|
||||
let after = rgb_to_hsv(mul(to, apply_reference(&t, c, 1.0)));
|
||||
assert!(
|
||||
(after[1] - before[1] * 1.2).abs() < 1e-4,
|
||||
"{before:?} {after:?}"
|
||||
);
|
||||
assert!((after[0] - before[0]).abs() < 1e-4);
|
||||
assert!((after[2] - before[2]).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_interpolation_wraps_from_the_last_column_to_the_first() {
|
||||
// Four hue columns: a shift only in the first. A hue just short of
|
||||
// 6.0 (red, from the magenta side) sits between the last column and
|
||||
// the first, and must take most of the first's shift.
|
||||
let mut e = vec![[0.0, 1.0, 1.0]; 4 * 2];
|
||||
e[0] = [40.0, 1.0, 1.0];
|
||||
e[1] = [40.0, 1.0, 1.0];
|
||||
let t = HueSatTable::new(4, 2, 1, false, e).unwrap();
|
||||
let d = lookup(&t, [5.9, 0.5, 0.5]);
|
||||
assert!(d[0] > 30.0, "{d:?}");
|
||||
// Columns sit at hue 0, 1.5, 3 and 4.5; between the third and the
|
||||
// fourth, neither of which shifts, nothing moves.
|
||||
let d = lookup(&t, [3.7, 0.5, 0.5]);
|
||||
assert!(d[0].abs() < 1e-6, "{d:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_above_one_stays_above_one() {
|
||||
let t = tables(None, Some(uniform(6, 3, 4, [5.0, 1.1, 0.9])));
|
||||
let out = apply_reference(&t, [6.0, 3.0, 2.0], 1.0);
|
||||
assert!(out.iter().any(|v| *v > 1.0), "{out:?}");
|
||||
let mut srgb = uniform(6, 3, 4, [0.0, 1.0, 0.9]);
|
||||
srgb.srgb_encoded = true;
|
||||
let out = apply_reference(&tables(None, Some(srgb)), [6.0, 3.0, 2.0], 1.0);
|
||||
assert!(out.iter().all(|v| v.is_finite()) && out[0] > 1.0, "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_look_strength_scales_the_look_alone() {
|
||||
let hs = uniform(6, 3, 1, [0.0, 1.1, 1.0]);
|
||||
let look = uniform(6, 3, 1, [0.0, 1.2, 1.0]);
|
||||
let t = tables(Some(hs.clone()), Some(look));
|
||||
let c = [0.5, 0.3, 0.2];
|
||||
let none = apply_reference(&t, c, 0.0);
|
||||
assert!(close(
|
||||
none,
|
||||
apply_reference(&tables(Some(hs), None), c, 1.0),
|
||||
1e-6
|
||||
));
|
||||
let (to, _) = working_prophoto();
|
||||
let s = |x| rgb_to_hsv(mul(to, x))[1];
|
||||
assert!(s(apply_reference(&t, c, 2.0)) > s(apply_reference(&t, c, 1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_buffer_puts_the_header_first_and_the_look_after_the_hue_sat_map() {
|
||||
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(
|
||||
Some(uniform(2, 2, 1, [1.0, 2.0, 3.0])),
|
||||
Some(uniform(3, 2, 2, [4.0, 5.0, 6.0])),
|
||||
);
|
||||
let b = profile_buffer(Some(&t));
|
||||
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.len(), HEADER_ENTRIES + 4 + 12 + 1025);
|
||||
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 + 16][0], dr_types::tone::ACR3_DEFAULT[0]);
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@
|
||||
// Hand-written nodes. Each is listed in `ops/` with `rust:`, which is what
|
||||
// places it in the chain; these are the implementations that entry points at.
|
||||
pub mod aberration;
|
||||
pub mod camera_profile;
|
||||
pub mod capture_sharpen;
|
||||
pub mod colour_mixer;
|
||||
pub mod curve;
|
||||
@@ -78,6 +79,7 @@ pub mod view_transform;
|
||||
pub mod vignetting;
|
||||
|
||||
pub use aberration::Aberration;
|
||||
pub use camera_profile::CameraProfile;
|
||||
pub use capture_sharpen::CaptureSharpen;
|
||||
pub use colour_mixer::ColourMixer;
|
||||
pub use curve::ToneCurve;
|
||||
|
||||
@@ -33,11 +33,33 @@ use crate::view::{Sigmoid, CONTRAST_RANGE, DEFAULT_CONTRAST, DEFAULT_WHITE, WHIT
|
||||
pub const ID: OpId = OpId("view_transform");
|
||||
pub const CONTRAST: ParamId = ParamId("contrast");
|
||||
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 {
|
||||
name: "view_sigmoid",
|
||||
source: crate::view::VIEW_SIGMOID_WGSL,
|
||||
}];
|
||||
/// [`CURVE`]'s values, in the order of its variants.
|
||||
pub const SIGMOID: f32 = 0.0;
|
||||
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(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
@@ -67,6 +89,15 @@ static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Scale::Linear,
|
||||
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 {
|
||||
contrast: f32,
|
||||
white: f32,
|
||||
curve: f32,
|
||||
}
|
||||
|
||||
impl Default for ViewTransform {
|
||||
@@ -82,6 +114,7 @@ impl Default for ViewTransform {
|
||||
Self {
|
||||
contrast: DEFAULT_CONTRAST,
|
||||
white: DEFAULT_WHITE,
|
||||
curve: CAMERA_RAW,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +139,7 @@ impl Operation for ViewTransform {
|
||||
match id {
|
||||
CONTRAST => self.contrast = value,
|
||||
WHITE => self.white = value,
|
||||
CURVE => self.curve = value.round(),
|
||||
_ => log::warn!("view_transform: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
@@ -114,12 +148,13 @@ impl Operation for ViewTransform {
|
||||
match id {
|
||||
CONTRAST => self.contrast,
|
||||
WHITE => self.white,
|
||||
CURVE => self.curve,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -130,8 +165,13 @@ impl Operation for ViewTransform {
|
||||
"\
|
||||
// Skipped for an already-rendered source: a JPEG is a display rendering
|
||||
// already, and rendering it again would compress it twice.
|
||||
// D19's sigmoid by default, the DNG reference tone by choice (D21).
|
||||
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()
|
||||
}
|
||||
@@ -151,11 +191,27 @@ if (!non_linear) {
|
||||
name: "peak",
|
||||
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] {
|
||||
&HELPERS
|
||||
HELPERS.as_slice()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +247,7 @@ mod tests {
|
||||
let s = Sigmoid::new(2.0, 6.0);
|
||||
let u = op.uniforms();
|
||||
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]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,8 +51,19 @@ pub const SCENE_GREY: f32 = 0.13;
|
||||
/// Display-linear middle grey — what a camera JPEG shows a grey card as.
|
||||
pub const DISPLAY_GREY: f32 = 0.18;
|
||||
|
||||
/// The default contrast, the sigmoid's log-log slope parameter `n`.
|
||||
pub const DEFAULT_CONTRAST: f32 = 1.4;
|
||||
/// The default contrast: the sigmoid's log-log slope `n`, and for the DNG
|
||||
/// 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`].
|
||||
pub const DEFAULT_WHITE: f32 = 4.0;
|
||||
@@ -223,11 +234,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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
|
||||
// D19's promise to every existing photograph: the midtones do not
|
||||
// move by more than a third of a stop.
|
||||
let s = Sigmoid::default_curve();
|
||||
// D19's promise, now kept by the sigmoid at its reference contrast
|
||||
// rather than by the default (D21 moved the default to the DNG reference
|
||||
// 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;
|
||||
while x <= 1.0 {
|
||||
let ev = (s.channel(x) / retired_default(x)).log2();
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
//!
|
||||
//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`,
|
||||
//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture`, `dehaze`,
|
||||
//! `view_transform` —
|
||||
//! `view_transform`, `camera_profile` —
|
||||
//! names a hand-written type and has no declaration to interpret. It is not skipped
|
||||
//! silently: [`every_declared_node_is_checked`] asserts the two sets partition
|
||||
//! `ops/` between them, so a node that stops being declared cannot quietly
|
||||
@@ -396,6 +396,7 @@ fn every_declared_node_is_checked() {
|
||||
assert_eq!(
|
||||
hand,
|
||||
[
|
||||
"camera_profile",
|
||||
"capture_sharpen",
|
||||
"clarity",
|
||||
"colour_mixer",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
//! wrong on most images and invisibly so, which is worse than an honest gap,
|
||||
//! 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
|
||||
//! 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
|
||||
@@ -137,6 +137,158 @@ const MAPPINGS: &[Mapping] = &[
|
||||
param: "saturation",
|
||||
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
|
||||
// for its maximum gets ours rather than being clamped there silently.
|
||||
Mapping {
|
||||
@@ -192,6 +344,27 @@ pub enum ImportError {
|
||||
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.
|
||||
///
|
||||
/// Tolerant in the same direction the sidecar parser is: a value that will not
|
||||
@@ -375,6 +548,56 @@ mod tests {
|
||||
.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]
|
||||
fn every_mapping_names_a_parameter_this_build_actually_has() {
|
||||
// The test that keeps the table honest. Adobe's half cannot be checked
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! TRACES: FR-DEV-3e
|
||||
//! A camera profile's hue/saturation/value tables, as the renderer receives
|
||||
//! them.
|
||||
//!
|
||||
//! # Why this lives in the types crate
|
||||
//!
|
||||
//! Three crates handle these and none depends on the next: `dr-decode` reads
|
||||
//! them out of a DNG or a `.dcp`, `dr-pipeline` emits the shader that indexes
|
||||
//! them, and `dr-gpu` uploads them in between. The layout — saturation
|
||||
//! fastest, then hue, then value — is the one fact all three must agree on, so
|
||||
//! it is stated once, here, by [`HueSatTable::index`].
|
||||
//!
|
||||
//! See `docs/dev/camera-profiles.md` for the model and D20 for where the
|
||||
//! tables run.
|
||||
|
||||
/// One `ProfileHueSatMap` or `ProfileLookTable`: a grid over HSV whose every
|
||||
/// entry is `(hue shift in degrees, saturation scale, value scale)`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct HueSatTable {
|
||||
pub hue_divisions: u32,
|
||||
pub sat_divisions: u32,
|
||||
/// 1 for a "2.5-D" table, which ignores value.
|
||||
pub val_divisions: u32,
|
||||
/// The value axis is indexed by the sRGB-encoded value rather than the
|
||||
/// linear one (`ProfileHueSatMapEncoding` / `ProfileLookTableEncoding`
|
||||
/// = 1).
|
||||
pub srgb_encoded: bool,
|
||||
/// `hue_divisions × sat_divisions × val_divisions` entries, in
|
||||
/// [`Self::index`] order.
|
||||
pub entries: Vec<[f32; 3]>,
|
||||
}
|
||||
|
||||
impl HueSatTable {
|
||||
/// Build a table, refusing one whose shape cannot be indexed.
|
||||
///
|
||||
/// Saturation needs two samples to interpolate between, and a table with
|
||||
/// a zero dimension or the wrong number of entries is a file that lies
|
||||
/// about itself; either is `None` rather than a lookup that reads past
|
||||
/// its end.
|
||||
pub fn new(
|
||||
hue_divisions: u32,
|
||||
sat_divisions: u32,
|
||||
val_divisions: u32,
|
||||
srgb_encoded: bool,
|
||||
entries: Vec<[f32; 3]>,
|
||||
) -> Option<Self> {
|
||||
let count = (hue_divisions as usize)
|
||||
.checked_mul(sat_divisions as usize)?
|
||||
.checked_mul(val_divisions as usize)?;
|
||||
let sane = hue_divisions >= 1
|
||||
&& sat_divisions >= 2
|
||||
&& val_divisions >= 1
|
||||
&& entries.len() == count
|
||||
// Large enough for any real profile (Adobe's largest are
|
||||
// 90×30×1 and 36×8×16); small enough that a corrupt dimension
|
||||
// cannot ask the GPU for gigabytes.
|
||||
&& count <= 1 << 20
|
||||
&& entries.iter().flatten().all(|v| v.is_finite());
|
||||
sane.then_some(Self {
|
||||
hue_divisions,
|
||||
sat_divisions,
|
||||
val_divisions,
|
||||
srgb_encoded,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
/// Where the entry for `(hue, sat, val)` sits: saturation fastest, then
|
||||
/// hue, then value, as the DNG specification stores it.
|
||||
pub fn index(&self, hue: u32, sat: u32, val: u32) -> usize {
|
||||
((val * self.hue_divisions + hue) * self.sat_divisions + sat) as usize
|
||||
}
|
||||
|
||||
/// Entry-by-entry blend toward `other`, for a two-illuminant HueSatMap.
|
||||
///
|
||||
/// `None` where the two are not the same shape, which a well-formed
|
||||
/// profile never produces — both data tags share one dimensions tag.
|
||||
pub fn lerp(&self, other: &Self, t: f32) -> Option<Self> {
|
||||
if (self.hue_divisions, self.sat_divisions, self.val_divisions)
|
||||
!= (
|
||||
other.hue_divisions,
|
||||
other.sat_divisions,
|
||||
other.val_divisions,
|
||||
)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let entries = self
|
||||
.entries
|
||||
.iter()
|
||||
.zip(&other.entries)
|
||||
.map(|(a, b)| std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t))
|
||||
.collect();
|
||||
Some(Self {
|
||||
entries,
|
||||
..self.clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether every entry is `(0°, 1, 1)`, so the table changes nothing.
|
||||
pub fn is_identity(&self) -> bool {
|
||||
self.entries
|
||||
.iter()
|
||||
.all(|e| e[0] == 0.0 && e[1] == 1.0 && e[2] == 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a source hands the renderer: the tables already resolved for this
|
||||
/// frame, the HueSatMap blended for the light it was shot under.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProfileTables {
|
||||
/// The profile's name, for the panel (`ProfileName`).
|
||||
pub name: String,
|
||||
/// Where it came from, for the panel.
|
||||
pub origin: ProfileOrigin,
|
||||
pub hue_sat: 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).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProfileOrigin {
|
||||
/// Embedded in the DNG being rendered.
|
||||
Embedded,
|
||||
/// A `.dcp` in the profiles directory, by file name.
|
||||
File(String),
|
||||
}
|
||||
|
||||
impl ProfileTables {
|
||||
/// Whether there is anything to apply.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.hue_sat.is_none() && self.look.is_none() && self.tone_curve.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn identity(h: u32, s: u32, v: u32) -> HueSatTable {
|
||||
HueSatTable::new(h, s, v, false, vec![[0.0, 1.0, 1.0]; (h * s * v) as usize]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saturation_varies_fastest_then_hue_then_value() {
|
||||
let t = identity(4, 3, 2);
|
||||
assert_eq!(t.index(0, 1, 0), 1);
|
||||
assert_eq!(t.index(1, 0, 0), 3);
|
||||
assert_eq!(t.index(0, 0, 1), 12);
|
||||
assert_eq!(t.index(3, 2, 1), 23);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_that_lies_about_its_size_is_refused() {
|
||||
assert!(HueSatTable::new(4, 3, 1, false, vec![[0.0, 1.0, 1.0]; 11]).is_none());
|
||||
assert!(HueSatTable::new(4, 1, 1, false, vec![[0.0, 1.0, 1.0]; 4]).is_none());
|
||||
assert!(HueSatTable::new(0, 3, 1, false, vec![]).is_none());
|
||||
let mut bad = vec![[0.0, 1.0, 1.0]; 12];
|
||||
bad[5][1] = f32::NAN;
|
||||
assert!(HueSatTable::new(4, 3, 1, false, bad).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blending_two_illuminants_is_entry_by_entry() {
|
||||
let a = identity(2, 2, 1);
|
||||
let mut b = identity(2, 2, 1);
|
||||
b.entries[3] = [10.0, 2.0, 0.5];
|
||||
let mid = a.lerp(&b, 0.5).unwrap();
|
||||
assert_eq!(mid.entries[3], [5.0, 1.5, 0.75]);
|
||||
assert_eq!(a.lerp(&b, 0.0).unwrap(), a);
|
||||
assert_eq!(a.lerp(&b, 1.0).unwrap(), b);
|
||||
assert!(a.lerp(&identity(3, 2, 1), 0.5).is_none());
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,15 @@ use std::fmt;
|
||||
use std::ops::Range;
|
||||
|
||||
pub mod colour;
|
||||
pub mod hue_sat;
|
||||
pub mod place;
|
||||
pub mod selector;
|
||||
pub mod settings;
|
||||
pub mod time;
|
||||
pub mod tone;
|
||||
|
||||
pub use colour::{Chromaticities, Transfer};
|
||||
pub use hue_sat::{HueSatTable, ProfileOrigin, ProfileTables};
|
||||
pub use place::{Place, PlaceScope, Screen, StoredFilter};
|
||||
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
|
||||
pub use settings::{
|
||||
|
||||
@@ -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"
|
||||
mkdir -p "${OUT}/staging/assets/models"
|
||||
_bundled=""
|
||||
for _dir in face scene inpaint; do
|
||||
for _dir in face scene inpaint denoise; do
|
||||
ASSETS="${REPO}/models/${_dir}"
|
||||
compgen -G "${ASSETS}/*.onnx" >/dev/null || continue
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
case "$(basename "${f}")" in
|
||||
README.md) continue ;;
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
# Camera profiles — DCP tables on top of the matrix
|
||||
|
||||
Design for the deferred half of **FR-DEV-3e** ([requirements.md](requirements.md)): the
|
||||
`HueSatMap` and `LookTable` of a DNG camera profile, read from the DNG that carries one or from a
|
||||
`.dcp` file, and applied after the matrix. Draft of 2026-10-02, recorded as **D20**.
|
||||
|
||||
---
|
||||
|
||||
## 1. What we are matching
|
||||
|
||||
Lightroom renders a raw through a *profile* before any slider moves. A profile is the matrix
|
||||
DarkRoom already applies, plus two lookup tables indexed by hue, saturation and value:
|
||||
|
||||
- **`ProfileHueSatMap`** — a calibration. It corrects what a 3×3 cannot: a sensor whose reds and
|
||||
oranges sit in the wrong place relative to its blues, which no linear map fixes. Two copies, one
|
||||
per calibration illuminant, interpolated like the matrices.
|
||||
- **`ProfileLookTable`** — a rendering intent: hue shifts of up to ±18° by hue, and saturation
|
||||
and value scales that vary with brightness. The difference between Adobe Standard, Adobe Color
|
||||
and Adobe Vivid is largely this table, together with each profile's tone curve.
|
||||
|
||||
Without them a raw renders through the matrix alone, which is accurate on a ColorChecker and is
|
||||
not what Lightroom showed for the same file.
|
||||
|
||||
**What the tables do *not* do is make a photograph more saturated.** Measured after the build
|
||||
(2026-10-02), on four of the library's 6D DNGs rendered at defaults: Adobe Standard's tables
|
||||
*lower* mean saturation by 3–9 %, and the look at 200 % lowers it further. The 6D's look table
|
||||
scales saturation by 0.925 in its darkest value rows and by 1.0 from about a fifth of full scale
|
||||
up, and its HueSatMap adds about 1 %. Adobe Standard was tuned to sit under Camera Raw's default
|
||||
RGB tone curve, which raises saturation in the shadows and midtones, and the look's dark-tone
|
||||
desaturation offsets it. Without that curve (§6) the offset is all that is left. On `_MG_9080`, Lightroom 6's own preview
|
||||
measures 0.49 mean HSV saturation; the matrix alone renders 0.38, Adobe Standard's tables 0.35.
|
||||
That preview also carries whatever was edited in Lightroom, so it is not a clean reference — but
|
||||
the direction is unambiguous: **the gap to Lightroom's colour is mostly tone, not the profile's
|
||||
tables.** The tables still matter for hue: they are what puts each body's reds, skin and foliage
|
||||
where Adobe put them.
|
||||
|
||||
**What the library holds** (catalog of 2026-10-02): 17,286 of its raws are Canon EOS 6D. The
|
||||
9,348 DNGs were written by Lightroom 6.14 and every one sampled embeds *Adobe Standard* with both
|
||||
tables — `HueSatMapDims 90 30 1`, `LookTableDims 36 8 16`, `ProfileEmbedPolicy 0` ("allow
|
||||
copying"), no `ProfileToneCurve`. The 7,938 CR2s from the same body carry no profile. So the
|
||||
tables Lightroom used are already on disk for half the library, and their licence lets them be
|
||||
applied to the other half.
|
||||
|
||||
## 2. The model, as the DNG specification states it
|
||||
|
||||
Each table is a grid of `(hueShift°, satScale, valScale)` triples over HSV, stored with
|
||||
saturation varying fastest, then hue, then value:
|
||||
|
||||
```
|
||||
index = v · (hueDivs · satDivs) + h · satDivs + s
|
||||
```
|
||||
|
||||
Lookup follows the DNG SDK's `RefBaselineHueSatMap`:
|
||||
|
||||
- **HSV** is the SDK's: `v = max(r,g,b)`, `s = (v − min)/v`, `h ∈ [0, 6)` from which channel
|
||||
leads. Grey has `s = 0` and is untouched by construction.
|
||||
- **Hue wraps**: `hueDivs` samples over 360°, the last interpolating to the first.
|
||||
- **Saturation** samples `0..=1` at `satDivs` points; linear between.
|
||||
- **Value** samples `0..=1` at `valDivs` points when `valDivs > 1`; a table with `valDivs = 1` is
|
||||
"2.5-D" and ignores value. `ProfileHueSatMapEncoding` / `ProfileLookTableEncoding` = 1 means the
|
||||
value axis is indexed by the sRGB-encoded value; 0 (the default, and the 6D's) means linear.
|
||||
- **Apply**: `h += hueShift · 6/360`, `s = min(s · satScale, 1)`, `v ·= valScale`; back to RGB.
|
||||
- **Space**: linear ProPhoto (ROMM) primaries, D50 white — the space the forward matrix lands in.
|
||||
- **Two illuminants**: `HueSatMapData1/2` are interpolated entry by entry, with the same mired
|
||||
weight the matrices use. `LookTable` is single.
|
||||
|
||||
Two departures, both forced by D19's unbounded scene-linear values (the SDK runs these on `[0, 1]`):
|
||||
|
||||
1. **Value is not clamped.** The SDK writes `min(v · valScale, 1)`; here `v · valScale`, unbounded.
|
||||
For lookup only, the value axis reads `min(v, 1)`, so a highlight above 1.0 uses the table's
|
||||
brightest row. Where the encoding is sRGB the scale is defined on the encoded value; it is
|
||||
applied as the ratio `decode(enc(v′)·valScale)/v′` at `v′ = min(v, 1)`, so a value above 1.0
|
||||
gets the brightest row's ratio rather than a clip.
|
||||
2. **A colour outside ProPhoto passes through.** A negative component has no SDK HSV. Such a
|
||||
colour is outside every surface colour a camera records under normal light. It is left
|
||||
unmodified rather than floored, because flooring it clips a value D19 says nothing may clip.
|
||||
|
||||
## 3. Where it sits
|
||||
|
||||
```
|
||||
… camera matrix ─► vignetting(5) ─► exposure(20) ─► camera_profile(25) ─► contrast(30) ─► … ─► view transform
|
||||
│
|
||||
working → ProPhoto ─► HueSatMap ─► LookTable ─► ProPhoto → working
|
||||
```
|
||||
|
||||
**A scene operation at order 25, not part of the matrix snippet.** Three reasons:
|
||||
|
||||
- **The matrix stays what it is.** `cam_to_srgb` is unchanged and still runs where D19 put it, and
|
||||
so does every reader of it: the mask pass's copy, the white-balance picker's, the camera-space
|
||||
tap. The tables add a conversion into ProPhoto and back *inside* their own fragment, through two
|
||||
constant matrices (§3.1). A photograph with no profile composes exactly the shader it does today.
|
||||
- **It commutes with what runs before it.** HSV hue and saturation are invariant under a uniform
|
||||
gain, and vignetting and exposure are uniform gains. So a 2.5-D table — every Adobe HueSatMap
|
||||
seen, and the 6D's — gives the same answer before or after them. That lets one position serve
|
||||
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
|
||||
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 the DNG SDK's reference rendering.
|
||||
|
||||
**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
|
||||
`DemosaicedImage` next to `color_matrix`. `dr-gpu` uploads them to a storage buffer at
|
||||
`@binding(8)` and writes their dimensions into the base uniform block. Every render path that
|
||||
reaches `AdjustPass` therefore gets them without being told: develop, export, previews, the tablet.
|
||||
A path that had to call a setter on the graph would be a path that one day forgot to, and an export
|
||||
that differed from the screen would be the result.
|
||||
|
||||
### 3.1 The two constants
|
||||
|
||||
`P⁻¹` is `ColourSpace::ProPhoto.from_linear_srgb()` — the conversion `dr-types` already derives
|
||||
from the two spaces' chromaticities, adapting D65 to D50 by Bradford, which the export path uses to
|
||||
write ProPhoto files — and `P` is its inverse. The fragment uses `P⁻¹` going in and `P` coming
|
||||
out. Each row of both is scaled to sum to one, so working-space white is ProPhoto white exactly and
|
||||
a neutral reaches the tables at `s = 0`. For a profile with forward matrices this recovers the
|
||||
SDK's ProPhoto colour to within the difference between that derivation and `forward_to_srgb`'s
|
||||
published Bradford constants, which is rounding.
|
||||
|
||||
## 4. Where a profile comes from
|
||||
|
||||
In this order, first match wins:
|
||||
|
||||
1. **The profile embedded in the DNG being opened.** It is what the file says, and it was made for
|
||||
the matrices the file carries. Read from the root IFD through rawler's parsed `IFD`, as
|
||||
`read_dng_matrices` already reads the forward matrices — no second TIFF parser.
|
||||
2. **A `.dcp` in the profiles directory** whose `UniqueCameraModel` matches the body — compared
|
||||
case-insensitively against the DNG's `UniqueCameraModel` where there is one, and against
|
||||
`make + " " + model` otherwise ("Canon EOS 6D"). A DCP is a whole profile: its matrices replace
|
||||
the file's, because its tables were built against its forward matrix. The file's as-shot neutral
|
||||
is kept. If several match, the first by file name wins, so the choice is stable.
|
||||
3. **None.** The matrix alone, as today.
|
||||
|
||||
The profiles directory is `profiles/` under the platform data directory (`dr_plat::dirs`), loaded
|
||||
once per process. A DCP is a TIFF with the magic `IIRC` (0x4352) in place of 42; rawler's
|
||||
`GenericTiffReader` already accepts it.
|
||||
|
||||
**Copying an embedded profile out.** A DNG whose profile has `ProfileEmbedPolicy` 0 ("allow
|
||||
copying") or 3 ("no restrictions") can have that profile saved as a `.dcp` into the profiles
|
||||
directory. That is how the 6D's CR2s get Adobe Standard: open a 6D DNG, choose *Use this profile
|
||||
for every Canon EOS 6D*. Policies 1 ("embed if used") and 2 ("embed never") offer no such action.
|
||||
The written file carries the profile's name, copyright and policy unchanged.
|
||||
|
||||
**Nothing is shipped.** Adobe's profiles are Adobe's; the application ships no `.dcp` and copies
|
||||
none on its own. A profile reaches the directory because the photographer put it there or asked
|
||||
for it to be copied from their own file.
|
||||
|
||||
## 5. The control
|
||||
|
||||
A develop operation, `camera_profile`, `[colour]`, order 25, hand-written (`rust:`) because it
|
||||
reads a buffer no declaration can name:
|
||||
|
||||
| Parameter | Kind | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `apply` | Bool | on | Use the profile's tables, or the matrix alone |
|
||||
| `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`,
|
||||
with `a = look/100`, scales floored at 0. At 200 the look is twice as strong, which is the
|
||||
"more vivid than Adobe Standard" this started from. The HueSatMap is a calibration and is not
|
||||
scaled: `apply` is its only switch.
|
||||
|
||||
**Always composed while `apply` is on**, as the view transform is: a profile at its defaults *is*
|
||||
the rendering, not an edit, so an untouched photograph writes no parameters and still renders
|
||||
through its profile. The fragment branches on the uniform that says whether the source has tables,
|
||||
so a JPEG, or a raw with none, pays one uniform read. The branch is uniform across the dispatch.
|
||||
|
||||
**Mask layers.** A layer may offset `look` (blended as a setting, which is linear) but not `apply`;
|
||||
the photograph has one profile.
|
||||
|
||||
**The panel says which profile is in use**, as the lens line does: *Adobe Standard (in the file)*,
|
||||
*Adobe Standard (Canon EOS 6D.dcp)*, or *No profile for this camera — matrix only*. The copy-out
|
||||
action sits on that line.
|
||||
|
||||
## 6. Not done, and why
|
||||
|
||||
- ~~**`ProfileToneCurve` is read and ignored.**~~ *Done after 0.20.0: §12, D21.* It came back as
|
||||
an option of the view transform, as this bullet said it would, and that option is the default
|
||||
for raws.
|
||||
- ~~**`BaselineExposure` is not applied.**~~ *Done after 0.20.0: §11.*
|
||||
- **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,
|
||||
which is its own change.
|
||||
- **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
|
||||
the user sees only approximately anyway.
|
||||
- ~~**The profiles directory does not sync.**~~ *Done after 0.20.0: §13.*
|
||||
- **Rec.2020 working primaries** stay deferred (D19); nothing here depends on them.
|
||||
|
||||
## 7. What it costs
|
||||
|
||||
- **Every DNG with an embedded profile renders differently** — more saturated, which is the point.
|
||||
Previews rendered before the change keep the old look until rendered again, as with D19.
|
||||
- **Tablet and desktop must be released together.** No schema change, and the sidecar gains only
|
||||
ordinary parameters, but two builds render the same DNG differently.
|
||||
- **One storage-buffer binding** in every generated shader's layout (a one-entry placeholder when
|
||||
there are no tables), and two vec4 slots in the base uniform block.
|
||||
- **Per pixel**: two 3×3 multiplies, two HSV round trips, and 4 + 8 buffer reads (bilinear
|
||||
HueSatMap, trilinear LookTable). Small next to the fused pass it joins.
|
||||
|
||||
## 8. Acceptance
|
||||
|
||||
- **Parsing.** The 6D DNG's embedded profile parses to `90×30×1` and `36×8×16`, its policy to 0,
|
||||
its name to "Adobe Standard"; a `.dcp` written from it parses back to the same tables bit for
|
||||
bit.
|
||||
- **The CPU reference matches the SDK's algorithm**: grey passes through; a table of
|
||||
`(0°, 1, 1)` everywhere is the identity to 1e-6; a uniform `satScale` of 1.2 scales HSV
|
||||
saturation by 1.2; hue interpolation wraps between the last and first column.
|
||||
- **The shader agrees with the CPU reference** on a device, within two 8-bit codes of the
|
||||
display-encoded readback (the only readback the adjust pass has), over 256 colours that tables
|
||||
of tens of degrees and ±30 % saturation move, and over the library's real Adobe Standard tables
|
||||
(`dr-gpu/tests/camera_profile.rs`).
|
||||
- **Scene-referred.** A value above 1.0 leaves the stage above 1.0 (`scene_referred_until_the_view`
|
||||
covers the operation).
|
||||
- **Neutral.** `apply` off renders to the bit what a source with no tables renders.
|
||||
- **Two illuminants.** A HueSatMap at blend weight 0 is Data1, at 1 is Data2.
|
||||
- **Matching.** An embedded profile beats a directory one; a DCP for "Canon EOS 6D" matches a CR2
|
||||
whose rawler make/model is "Canon"/"EOS 6D"; no match leaves the matrix.
|
||||
- **Subjective.** A 6D DNG rendered here at defaults is visibly closer to the same file in
|
||||
Lightroom 6 with Adobe Standard than the matrix-only render, side by side.
|
||||
|
||||
## 9. Vivid presets
|
||||
|
||||
Independent of the tables, and — given §1's measurement — the part of this change that actually
|
||||
answers "more colourful". Shipped in the same change: a *Vivid* section of read-only presets
|
||||
(`presets/vivid.drpl`) for the "more colourful than the default" request. They use only operations
|
||||
every photograph has — vibrance, saturation, the colour mixer, colour grading, contrast — so they
|
||||
work on JPEGs and on bodies with no profile, and change only what they name (FR-DEV-6):
|
||||
|
||||
- **Vivid** — vibrance and a little saturation and contrast: the general-purpose one.
|
||||
- **Vivid, strong** — the same, pushed, with deeper blacks.
|
||||
- **Vivid landscape** — greens, blues and azure skies, skin bands left alone.
|
||||
- **Vivid warm** — oranges and yellows up, a warm highlight cast: golden hour.
|
||||
- **Vivid portrait** — vibrance (which protects skin) with the orange and red bands held back.
|
||||
|
||||
Measured on `_MG_9080` (mean HSV saturation; Lightroom's preview 0.49, DarkRoom's default 0.35):
|
||||
Vivid 0.40, Vivid strong 0.44, Vivid landscape 0.46, Vivid warm 0.38, Vivid portrait 0.37 — the
|
||||
last two move particular bands, not the whole frame. Rendered with `cargo run --release -p dr-gpu
|
||||
--example develop -- FILE.dng out.ppm "preset:Vivid"`.
|
||||
|
||||
They are bounded by the existing `bundled.rs` tests: every key names a real parameter, every value
|
||||
is inside its control's range, and every preset changes something.
|
||||
|
||||
## 10. Build order
|
||||
|
||||
1. `dr-decode`: parse the tables (embedded and `.dcp`), the profiles directory, matching, blending,
|
||||
the `.dcp` writer. CPU reference of the lookup. Unit tests against the library's 6D DNG,
|
||||
skipped when it is absent.
|
||||
2. `dr-pipeline`: the `camera_profile` operation, the base-block slots, `@binding(8)`, the WGSL
|
||||
lookup; composition tests.
|
||||
3. `dr-gpu`: carry the tables on `DemosaicedImage`, upload and bind them; the shader-versus-CPU
|
||||
test on a device.
|
||||
4. `dr-ui`: profile line, copy-out action, labels; the profiles directory set at start-up on
|
||||
desktop and Android.
|
||||
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
|
||||
|
||||
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,
|
||||
and every figure marked *estimate* is waiting for the measurement that replaces it.
|
||||
[outstanding.md §3](outstanding.md) says is missing. Drafted 2026-09-27; a first version shipped
|
||||
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.
|
||||
- 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
|
||||
**visible viewport only** (~1 MP, a fraction of a second — *estimate*) so the photographer judges
|
||||
on the real result; releasing it queues the whole frame. There is no per-frame blend between the
|
||||
two paths: blending the classical output back in re-adds the noise the network removed.
|
||||
What shipped is a switch and a **Keep grain** slider, not the Amount described first. The slider
|
||||
blends the two demosaics per pixel — but only the *brightness* of their difference: `out =
|
||||
denoised + grain · ΔY / wb`, with `ΔY` the luminance of `wb · (classical − denoised)`. Taken after
|
||||
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
|
||||
|
||||
Through `dr-inference-engine`, as the other models run ([inference.md](inference.md)): TensorRT or
|
||||
CUDA fp16 on the laptop, MIGraphX on the desktop, ORT CPU everywhere, QNN on the tablet. Work is
|
||||
Through `dr-inference-engine`, as the other models run ([inference.md](inference.md)), as
|
||||
`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).
|
||||
|
||||
## 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.
|
||||
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, AMD GPU with ROCm | MIGraphX, f32 model, fp16 program | 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
|
||||
oversight.
|
||||
⁵ **Unmeasured**, and the one exception to the rule below: nobody here has a Mac. The rung is on
|
||||
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),
|
||||
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
|
||||
same inputs; the rung is tried again when an input changes. Otherwise a wedged driver means a
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
@@ -53,6 +53,13 @@ left outstanding, and its DCP half stays deferred as before. §4's FR-DSP-2 and
|
||||
record the one case that now tiles, a linear DNG larger than one texture, and §11 the merge's frame
|
||||
choice, which changes how FR-MRG-5 is met rather than whether.
|
||||
|
||||
**And for 0.20.0.** FR-DEV-3e's DCP half is built (D20, [camera-profiles.md](camera-profiles.md)):
|
||||
the HueSatMap and LookTable from a DNG's embedded profile or a matched `.dcp`. Two pieces stay
|
||||
open, both named in that design's §6: the profiles directory does not sync, so a CR2 can render
|
||||
with a copied profile on one device and without it on another; and `ProfileToneCurve` and
|
||||
`BaselineExposure` are read and not applied. The measurement in its §1 says the second is where the
|
||||
remaining gap to Lightroom's colour lies.
|
||||
|
||||
---
|
||||
|
||||
## 1. Plugins — post-v1 since 2026-09-19
|
||||
|
||||
+35
-4
@@ -367,7 +367,7 @@ Built, on branch `merge/panorama`, in the order §10 gave:
|
||||
| The camera-space tap | `OutputMode::CameraLinear`, `AdjustPass::render_camera_linear` | Done, `rgba32float`, tiles by view rect |
|
||||
| Linear DNG writer, streamed | `dr_export::write_linear_dng` | Done; rawler reads it back |
|
||||
| A three-sample `RawImage` re-entering the pipeline | `dr-decode`, `DemosaicedImage::from_linear_rgb16` | Done |
|
||||
| Warp, accumulate, resolve, chunk by chunk | `dr_gpu::MergePass`, `merge.wgsl` | Done; feathered blend, scalar gain |
|
||||
| Warp, accumulate, resolve, chunk by chunk | `dr_gpu::MergePass`, `merge.wgsl` | Done; seams (§11.1) over a feather, scalar gain |
|
||||
| The job: load, proxies, align, gains, confirm, merge, provenance | `dr_ui::merge` | Done; `examples/merge.rs` drives it headless |
|
||||
| The page: table, preview, projection, Merge/Stop/Back; the grid's button | `merge.slint`, `merge_ui.rs` | Done; `DARKROOM_START_MERGE=a.CR2,b.CR2` lands on it |
|
||||
| Placement beside the sources through the outbox, rescan | `merge_ui.rs` | Done, untested against a server |
|
||||
@@ -385,9 +385,11 @@ half.
|
||||
the file carries the black border. The largest inscribed rectangle over
|
||||
the coverage, then the DNG's `DefaultCropOrigin`/`DefaultCropSize`, so
|
||||
nothing is thrown away and the develop view opens on the picture.
|
||||
2. **Seams and the pyramid** (§10 step 5). The feather hides exposure and
|
||||
small misalignment; parallax on the near slope will show as a soft
|
||||
double edge at 1:1.
|
||||
2. **The pyramid** (§10 step 5). Seams landed 2026-09-30 (§11.1); the
|
||||
blend across them is one width for every frequency, so an exposure step
|
||||
the gains leave is narrowed to the seam's 64 px rather than hidden over
|
||||
the old 200. A Laplacian pyramid would blend low frequencies wide and
|
||||
detail narrow.
|
||||
3. **Vignetting in the tap.** The lens profile's distortion is applied
|
||||
before the fetch; its vignetting is an operation and is not. Frame edges
|
||||
are darker than their centres by the lens's falloff, and the feather
|
||||
@@ -402,6 +404,35 @@ half.
|
||||
catalog's `content_hash` is null for most images most of the time. The
|
||||
hash can join it when the catalog has one.
|
||||
|
||||
### 11.1 Seams — 2026-09-30
|
||||
|
||||
The feather averaged every overlap over 200 px, so anything the frames
|
||||
disagreed on — parallax on the near slope, a walker, wind in a branch — came
|
||||
out twice at half strength: a soft double edge at 1:1, reported as a glitch.
|
||||
|
||||
`dr_pano::seam` now chooses, per output texel at proxy resolution, which
|
||||
frame it is taken from. Frames are laid down nearest-first; where a new one
|
||||
overlaps the composite, each texel costs the gain-corrected difference
|
||||
between the two, plus the detail either has there, plus nearness to either
|
||||
frame's edge (vignetting, the lens correction's fringe), taken as the
|
||||
**worst** over a 4-texel window so the path stays a blend radius clear of a
|
||||
difference rather than grazing it. The cut is a dynamic-programming path
|
||||
across the overlap, perpendicular to the line from the composite's frames to
|
||||
the new one: §4's per-column seam, not a graph cut. The map is computed per
|
||||
projection, for the page's preview and again for the merge.
|
||||
|
||||
`merge.wgsl` weights a frame by its tent-filtered share of the label map
|
||||
about each pixel (`SeamMap::share`, repeated verbatim), over a window
|
||||
`seam_blend_px` wide (64, capped at 4 texels either side). The edge feather
|
||||
remains underneath as a factor and, with a 1e-4 floor, as the answer where
|
||||
the map names no frame that reaches the pixel. `--feather-only` on
|
||||
`examples/merge.rs` merges the old way, for comparison.
|
||||
|
||||
Known limits: one axis per new frame, so in a multi-row set a frame
|
||||
overlapping its left neighbour and the row above is cut along a compromise
|
||||
direction; the cost reads grey proxies, so a difference in hue alone is
|
||||
invisible to it.
|
||||
|
||||
## 12. Filling the border instead of cropping it — MI-GAN, read and measured 2026-09-19
|
||||
|
||||
Raised after the first merges: the ragged border a cylinder leaves could be
|
||||
|
||||
@@ -433,9 +433,18 @@ camera RGB, where its multipliers are defined, and every other operation receive
|
||||
colour. Before D19 the edits ran in camera RGB and the matrix came after them, so a hue in the
|
||||
colour mixer and the weights in `luminance()` meant something different on every body.
|
||||
|
||||
**Deferred but not foreclosed:** full `.dcp` support with `HueSatDeltas`, `ProfileLookTable`, and
|
||||
~~**Deferred but not foreclosed:** full `.dcp` support with `HueSatDeltas`, `ProfileLookTable`, and
|
||||
dual-illuminant interpolation. The stage shall be structured so these are additions rather than a
|
||||
pipeline reordering.
|
||||
pipeline reordering.~~ *Amended 2026-10-02 (D20):* dual-illuminant interpolation of the matrices
|
||||
was built with item 1. The tables follow, designed in [camera-profiles.md](camera-profiles.md):
|
||||
|
||||
4. **DCP tables.** `ProfileHueSatMap` (both illuminants, blended as the matrices are) and
|
||||
`ProfileLookTable`, read from the profile embedded in a DNG or from a `.dcp` file in the
|
||||
profiles directory matched by `UniqueCameraModel`, the embedded one first. They are applied by a
|
||||
`camera_profile` scene operation after exposure, with a switch and a look strength (0–200 %),
|
||||
on by default where a profile exists. `ProfileToneCurve` is read and not applied: tone is the
|
||||
view transform's (D19). An embedded profile whose `ProfileEmbedPolicy` allows copying can be
|
||||
saved as a `.dcp` for other files from the same body. The application ships no profile.
|
||||
|
||||
Rationale for the reduced scope: a bare 3×3 matrix produces the flat, poor-skin-tone rendering
|
||||
characteristic of dcraw defaults, which is the documented reason people abandon darktable in the
|
||||
@@ -448,6 +457,9 @@ measurements, and their provenance was not known well enough to keep them as def
|
||||
*Acceptance:* the default render is subjectively comparable to the camera's own JPEG — through
|
||||
FR-DEV-3j's default, for every body. ΔE2000 validation against ColorChecker references applies
|
||||
once DCP support lands.
|
||||
For item 4: the lookup follows the DNG SDK's on `[0, 1]` and leaves values above 1.0 above it;
|
||||
grey and an identity table pass through unchanged; the shader agrees with the CPU reference; with
|
||||
the switch off the render is to the bit the one with no profile (camera-profiles.md §8).
|
||||
|
||||
**FR-DEV-3f — Look emulation.** Support HaldCLUT import, which inherits the existing free film
|
||||
simulation ecosystem at near-zero implementation cost, plus reading the in-RAF film simulation tag
|
||||
@@ -2425,6 +2437,8 @@ 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
### D11 — product positioning
|
||||
|
||||
@@ -2730,6 +2744,86 @@ unbounded, but several fragments floor at zero, which clips a colour outside sRG
|
||||
mixer's bands and the colour grading wheel would need their hues re-measured. Gamut compression
|
||||
beyond the output transform's clip goes with it.
|
||||
|
||||
### D20 — DCP camera profiles · **DECIDED 2026-10-02**
|
||||
|
||||
**A camera profile's `HueSatMap` and `LookTable` are applied by a `camera_profile` scene
|
||||
operation at order 25, after exposure, converting into linear ProPhoto and back inside its own
|
||||
fragment.** Design and the full argument: [camera-profiles.md](camera-profiles.md).
|
||||
|
||||
*Why now.* The library's 9,348 Canon 6D DNGs carry Adobe Standard's tables, which Lightroom
|
||||
rendered them through, and DarkRoom ignored them, so every hue on those files sat somewhere other
|
||||
than where Lightroom put it. *Measured after building it:* the tables are not why Lightroom's
|
||||
rendering looks richer — at defaults they lower mean saturation by 3–9 %, because Adobe Standard's
|
||||
look desaturates dark tones to sit under Camera Raw's tone curve, which DarkRoom does not apply.
|
||||
The richer colour is tone, and the Vivid presets (FR-DEV-6) are what answers it today
|
||||
(camera-profiles.md §1).
|
||||
|
||||
*Why there.* The matrix snippet stays what D19 made it, and every copy of it (masks, picker,
|
||||
camera-space tap) stays correct without changing. Hue and saturation are invariant under the
|
||||
uniform gains that precede order 25, so a 2.5-D HueSatMap gives the same answer there as straight
|
||||
after the matrix, and the LookTable sees the photographer's exposure, as it does in the SDK.
|
||||
|
||||
*Rejected.* Extending the matrix snippet: every duplicate of it would have had to follow. Two
|
||||
operations, one per table: the HueSatMap has no control of its own and commutes to the same place.
|
||||
Applying `ProfileToneCurve`: a per-body tone curve is what D19 retired. Shipping Adobe's profiles:
|
||||
they are not ours to ship. Handing the tables to the graph through a setter, as lens profiles are:
|
||||
every render path would have to remember to call it. They travel with the decoded image, as the
|
||||
matrix does.
|
||||
|
||||
*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.
|
||||
|
||||
### 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**
|
||||
|
||||
> Deferred with §3.10 on 2026-09-19. Still to be answered before the format is published as
|
||||
|
||||
+118
-118
File diff suppressed because one or more lines are too long
+24
-24
@@ -39,7 +39,7 @@ The list is longer than it is tall, so a way to walk it that cannot be lost to t
|
||||
|
||||
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. Past 1:1 the pixels are shown as they are, square and unsmoothed; below it, filtered.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:1982`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2029`</sub>
|
||||
|
||||
### Move a magnified photograph about
|
||||
|
||||
@@ -50,7 +50,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
|
||||
|
||||
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2078`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2125`</sub>
|
||||
|
||||
### Paint a mask by hand
|
||||
|
||||
@@ -60,7 +60,7 @@ Only once there is something outside the viewport to reach, which is why the cur
|
||||
|
||||
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2169`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2216`</sub>
|
||||
|
||||
### Open this list
|
||||
|
||||
@@ -70,7 +70,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
|
||||
|
||||
Most of the keys are develop's, and a reference that could only be opened from the grid had to be looked up before opening the photograph they were wanted for.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2395`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2442`</sub>
|
||||
|
||||
### Take back the last change
|
||||
|
||||
@@ -81,7 +81,7 @@ Most of the keys are develop's, and a reference that could only be opened from t
|
||||
|
||||
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2425`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2472`</sub>
|
||||
|
||||
### Do it again after taking it back
|
||||
|
||||
@@ -90,7 +90,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
||||
- **Keyboard** — `Ctrl+Shift+Z`, or `Ctrl+Y`
|
||||
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2439`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2486`</sub>
|
||||
|
||||
### Remove a repair
|
||||
|
||||
@@ -98,7 +98,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
||||
- **Pointer** — Click it, then Delete Repair
|
||||
- **Keyboard** — `Delete` or `Backspace`, while repairing
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2459`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2506`</sub>
|
||||
|
||||
### Copy the settings from this photograph
|
||||
|
||||
@@ -109,7 +109,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
||||
|
||||
The button is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2478`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2525`</sub>
|
||||
|
||||
### Paste the settings onto this photograph
|
||||
|
||||
@@ -120,7 +120,7 @@ The button is the copy that has to work: a tablet has no modifier key to hold an
|
||||
|
||||
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2491`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2538`</sub>
|
||||
|
||||
### Choose which kinds of edit a copy carries
|
||||
|
||||
@@ -131,7 +131,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
|
||||
|
||||
Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving each frame's crop and rotation alone, and that is a choice to make at the moment of copying.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2509`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2556`</sub>
|
||||
|
||||
### Export this photograph as the last one was
|
||||
|
||||
@@ -142,7 +142,7 @@ Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving e
|
||||
|
||||
Every export runs on the defaults in Settings, so "as the last one was" is what the button already does. The chord is Lightroom's and darktable's, kept so hands that learned it there need not learn it again.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2534`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2581`</sub>
|
||||
|
||||
### Choose how to export, then export
|
||||
|
||||
@@ -153,7 +153,7 @@ Every export runs on the defaults in Settings, so "as the last one was" is what
|
||||
|
||||
The export sheet is the export defaults alone with an Export button. What is chosen there is kept, so it is also what the next Ctrl+Shift+E uses.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2547`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2594`</sub>
|
||||
|
||||
### Keep a crop that leaves a mask outside
|
||||
|
||||
@@ -161,7 +161,7 @@ The export sheet is the export defaults alone with an Export button. What is cho
|
||||
- **Pointer** — Press "Keep crop" on the notice, or "Undo crop" to take it back
|
||||
- **Keyboard** — `Enter` keeps it; `Ctrl+Z` takes the crop back, like any other step
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2612`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2659`</sub>
|
||||
|
||||
### Go back to the grid
|
||||
|
||||
@@ -171,7 +171,7 @@ The export sheet is the export defaults alone with an Export button. What is cho
|
||||
|
||||
Lightroom's key for the grid. Escape gets there too, but a step at a time — out of a mode, then out of a zoom — where this goes straight back.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2629`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2676`</sub>
|
||||
|
||||
### Nudge the control last moved
|
||||
|
||||
@@ -181,7 +181,7 @@ Lightroom's key for the grid. Escape gets there too, but a step at a time — ou
|
||||
|
||||
Lightroom's keys for the selected slider. There is no focus ring on a slider here, so "selected" is the last one moved — the same control `R` puts back — which covers the framing sliders, perspective included, as well as the adjustments.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2658`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2705`</sub>
|
||||
|
||||
### Change which group of adjustments is on screen
|
||||
|
||||
@@ -192,7 +192,7 @@ Lightroom's keys for the selected slider. There is no focus ring on a slider her
|
||||
|
||||
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2686`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2733`</sub>
|
||||
|
||||
### Look at the photograph at 1:1
|
||||
|
||||
@@ -203,7 +203,7 @@ The groups are whatever the operation set declares itself to be about, so there
|
||||
|
||||
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. From 1:1 on the photograph is drawn as its own pixels, each a hard-edged square, rather than smoothed into a blur.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2722`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2769`</sub>
|
||||
|
||||
### Rate this photograph
|
||||
|
||||
@@ -211,7 +211,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
|
||||
- **Pointer** — Click a star in the top bar
|
||||
- **Keyboard** — `0`–`5`
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2779`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2826`</sub>
|
||||
|
||||
### Pick or reject this photograph
|
||||
|
||||
@@ -221,7 +221,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
|
||||
|
||||
The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging here does not move on to the next frame: that belongs to culling, and in develop the photograph in front of you is the one being worked on.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2785`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2832`</sub>
|
||||
|
||||
### Give this photograph a colour label
|
||||
|
||||
@@ -232,7 +232,7 @@ The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging h
|
||||
|
||||
The grid's keys, on the photograph that is open, so labelling while stepping through a folder is one hand's work. The bar names the label in words beside its mark.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2815`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2862`</sub>
|
||||
|
||||
### Move to the next or previous photograph
|
||||
|
||||
@@ -243,7 +243,7 @@ The grid's keys, on the photograph that is open, so labelling while stepping thr
|
||||
|
||||
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. A and D as well as the arrows, so the left hand steps along the roll while the right stays on the mouse.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2840`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2887`</sub>
|
||||
|
||||
### See the photograph before you edited it
|
||||
|
||||
@@ -254,7 +254,7 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
|
||||
|
||||
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2970`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:3012`</sub>
|
||||
|
||||
### Put one control back to its default
|
||||
|
||||
@@ -338,7 +338,7 @@ The question a correction raises is whether it did what it was for — whether t
|
||||
|
||||
One key for "up one", innermost first: a question before the sheet under it, a sheet before the view, a view before the library. Nothing is left behind a dialogue that the key walked straight past.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:1024`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:1026`</sub>
|
||||
|
||||
### Do what a sheet offers
|
||||
|
||||
@@ -346,7 +346,7 @@ One key for "up one", innermost first: a question before the sheet under it, a s
|
||||
- **Pointer** — Press its button — Export, or Copy
|
||||
- **Keyboard** — `Enter`, on the export and copy sheets
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:1034`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:1036`</sub>
|
||||
|
||||
### Scroll by the scrollbar
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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>
|
||||
<li><a href="#light">Light</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="#white-balance-from-the-photograph">White balance from the photograph</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
|
||||
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>
|
||||
<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>
|
||||
<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,
|
||||
|
||||
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
|
||||
data-derived restriction (contrast the gaze models §7 of the requirements
|
||||
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
|
||||
# on. Swap `source` for a tagged tarball when there is something to release.
|
||||
pkgname=darkroom
|
||||
pkgver=0.19.1
|
||||
pkgver=0.21.0
|
||||
# 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.
|
||||
pkgrel=1
|
||||
@@ -123,4 +123,13 @@ package() {
|
||||
return 1
|
||||
fi
|
||||
install -Dm644 "${_src}" "${pkgdir}/usr/share/darkroom/models/migan-512.onnx"
|
||||
|
||||
# The learned demosaic and denoise (the project's own weights, GPL —
|
||||
# models/LICENCE.md). Same pointer check, same directory.
|
||||
_src="models/denoise/mosaic-1408.onnx"
|
||||
if [[ "$(stat -c%s "${_src}")" -lt 100000 ]]; then
|
||||
echo "error: the denoise model is an LFS pointer — run: git lfs pull" >&2
|
||||
return 1
|
||||
fi
|
||||
install -Dm644 "${_src}" "${pkgdir}/usr/share/darkroom/models/mosaic-1408.onnx"
|
||||
}
|
||||
|
||||
@@ -98,7 +98,8 @@ pub fn set_state_dir(dir: PathBuf) {
|
||||
|
||||
/// 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
|
||||
/// a cache directory exists to permit, and rather than config because it is
|
||||
/// not something the user edits.
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
//!
|
||||
//! * Linux: `$XDG_STATE_HOME/darkroom/darkroom.log`, else
|
||||
//! `~/.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`,
|
||||
//! which `adb pull` reads from an ordinary release build. See
|
||||
//! [`crate::state`] for why not the internal directory, and
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
//! | Unix default | `~/.config` | `~/.local/share` | `~/.local/state` |
|
||||
//! | Windows | `%APPDATA%` | `%LOCALAPPDATA%` | `%LOCALAPPDATA%`, then `state` |
|
||||
//! | 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,
|
||||
//! 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> {
|
||||
let (var, under_home) = match kind {
|
||||
Base::Config => ("XDG_CONFIG_HOME", ".config"),
|
||||
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)))
|
||||
}
|
||||
@@ -150,7 +162,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
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")]);
|
||||
assert_eq!(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! FR-CAT-10 asks for removable-volume insertion to be detected "where the
|
||||
//! platform permits", which is a careful phrase and this module is why. There
|
||||
//! is no portable answer: Linux has a mount table and a sysfs flag, Android
|
||||
//! has neither and hands out a document tree the user picked (ARCH §6.9).
|
||||
//! has neither and lists its volumes through a Java service (`dr_ui::cards`).
|
||||
//! So this reports what it can and returns an empty list where it cannot,
|
||||
//! and every caller must still offer the user a way to say where the card is.
|
||||
//!
|
||||
@@ -67,22 +67,18 @@ impl Volume {
|
||||
/// where it is mounted. `false` here means the operation cannot be performed
|
||||
/// however hard the user tries, and the interface should not offer it.
|
||||
///
|
||||
/// It is `false` on Android, for two reasons that both have to be fixed before
|
||||
/// it can change:
|
||||
/// It is `true` on Linux and on Android. On Android the card is read by
|
||||
/// path too — `/storage/9C33-6BBD` — once the user has granted "all files
|
||||
/// access", but finding it takes the platform's `StorageManager`, which is
|
||||
/// Java. So [`volumes`] still answers empty there, and `dr_ui::cards` lists
|
||||
/// the volumes and asks for the permission instead.
|
||||
///
|
||||
/// - There is no mount table to read and no path to type. Storage is reached
|
||||
/// through a tree the user granted, and a removable volume appears there or
|
||||
/// not at all (ARCH §6.9).
|
||||
/// - Nothing implements [`WritableStorage`](crate::WritableStorage) except
|
||||
/// [`LocalStorage`](crate::LocalStorage), so there is no destination to write
|
||||
/// into even once a source is named.
|
||||
///
|
||||
/// The engine above this is already portable — it takes storage traits and
|
||||
/// never a path — so what this gates is the *interface*, and it stops being
|
||||
/// `false` when a SAF implementation lands rather than when the importer is
|
||||
/// rewritten.
|
||||
/// The engine above this takes storage traits and never a path, and the
|
||||
/// importer only ever *writes* into its own staging directory, which is a
|
||||
/// plain directory on every platform. So what this gates is whether a card
|
||||
/// can be *read*, nothing more.
|
||||
pub const fn imports_supported() -> bool {
|
||||
cfg!(target_os = "linux")
|
||||
cfg!(any(target_os = "linux", target_os = "android"))
|
||||
}
|
||||
|
||||
/// Every mounted volume that might hold photographs.
|
||||
@@ -130,9 +126,9 @@ fn platform_volumes() -> Vec<Volume> {
|
||||
|
||||
/// Everywhere else: no answer, and saying so is the honest result.
|
||||
///
|
||||
/// On Android the question is not merely unanswerable but wrong — storage is
|
||||
/// reached through a tree the user granted, and a card appears there or not at
|
||||
/// all (ARCH §6.9, FR-PLAT-AND-1).
|
||||
/// On Android there is no readable mount table either; the volumes come from
|
||||
/// `StorageManager` through `dr_ui::cards`, which needs the JNI this crate
|
||||
/// does not have.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn platform_volumes() -> Vec<Volume> {
|
||||
Vec::new()
|
||||
@@ -358,7 +354,10 @@ tmpfs /run/user/1000 tmpfs rw,nosuid 0 0
|
||||
// The two are different claims: an empty list means "plug one in",
|
||||
// `false` here means "this cannot be done here". An interface that
|
||||
// conflated them would offer a page that can never be used.
|
||||
assert_eq!(imports_supported(), cfg!(target_os = "linux"));
|
||||
assert_eq!(
|
||||
imports_supported(),
|
||||
cfg!(any(target_os = "linux", target_os = "android"))
|
||||
);
|
||||
if !imports_supported() {
|
||||
assert!(volumes().is_empty());
|
||||
}
|
||||
|
||||
@@ -20,6 +20,32 @@
|
||||
# directory (docs/inference.md §1.3).
|
||||
set -euo pipefail
|
||||
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)"
|
||||
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
|
||||
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
|
||||
DENOISE = '_MG_8862' # Brooklyn Bridge at ISO 8000: a lit railing, a lamp and a camera against the night
|
||||
|
||||
# --- the registry -----------------------------------------------------------
|
||||
SCENES = {}
|
||||
@@ -841,6 +842,68 @@ def develop_zoom():
|
||||
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'])
|
||||
def develop_wb():
|
||||
at_develop()
|
||||
|
||||
@@ -36,6 +36,8 @@ dr-export.workspace = true
|
||||
# The panorama's geometry and its keypoint detector (§3.11). The detector's
|
||||
# runtime is the same tract the faces and masks already carry.
|
||||
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
|
||||
# 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.
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
//! cargo run -p dr-ui --example merge --release -- --leave-out 3 fixtures/pano/2025-08-05/*.CR2
|
||||
//! ```
|
||||
//!
|
||||
//! `--feather-only` merges without seams — every overlap the feathered
|
||||
//! average — for comparing the two on the same set.
|
||||
//!
|
||||
//! `--leave-out N` unticks frame `N` once the first alignment is in, as the
|
||||
//! page's box would, so the job aligns again without it.
|
||||
//!
|
||||
@@ -25,6 +28,7 @@ fn main() {
|
||||
let mut fill = false;
|
||||
let mut wait_engines = false;
|
||||
let mut leave_out: Option<usize> = None;
|
||||
let mut seams = true;
|
||||
loop {
|
||||
match args.first().map(String::as_str) {
|
||||
Some("--out") => {
|
||||
@@ -39,6 +43,10 @@ fn main() {
|
||||
args.remove(0);
|
||||
leave_out = args.remove(0).parse().ok();
|
||||
}
|
||||
Some("--feather-only") => {
|
||||
args.remove(0);
|
||||
seams = false;
|
||||
}
|
||||
Some("--wait-engines") => {
|
||||
args.remove(0);
|
||||
wait_engines = true;
|
||||
@@ -101,6 +109,7 @@ fn main() {
|
||||
dr_ui::merge::MergeRequest::new(frames, dr_ui::merge::MergeDestination::Local(dir));
|
||||
// The filler from the shared models directory, as the app finds it.
|
||||
request.inpaint_model = dr_ui::merge::inpaint_model_path();
|
||||
request.seams = seams;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (decide, decision) = std::sync::mpsc::channel();
|
||||
let cancel = dr_ui::merge::Cancel::default();
|
||||
|
||||
@@ -83,6 +83,9 @@ pub enum Kind {
|
||||
/// TRACES: FR-EXP-7
|
||||
/// Rendering and writing finished files.
|
||||
Export,
|
||||
/// TRACES: FR-DEV-3g
|
||||
/// The learned demosaic and denoise working through a photograph.
|
||||
Denoise,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
@@ -111,6 +114,8 @@ impl Kind {
|
||||
// background sweep in the same sentence as a download they are
|
||||
// waiting on.
|
||||
Kind::Index => false,
|
||||
// Local work on a file already open.
|
||||
Kind::Denoise => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,20 @@ impl AlbumsController {
|
||||
}
|
||||
}
|
||||
|
||||
/// The file names an album already records, for a batch bound for its
|
||||
/// server folder to name around. Empty where the catalog cannot say.
|
||||
pub fn file_names(&self, album: AlbumId) -> std::collections::HashSet<String> {
|
||||
let catalog = self.library.catalog();
|
||||
let borrow = catalog.borrow();
|
||||
let Some(cat) = borrow.as_ref() else {
|
||||
return Default::default();
|
||||
};
|
||||
albums::file_names(cat.connection(), album).unwrap_or_else(|e| {
|
||||
log::warn!("reading the files of album {}: {e}", album.0);
|
||||
Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Record what a batch wrote into an album, and redraw what counts it.
|
||||
pub fn record(&self, window: &AppWindow, album: AlbumId, files: Vec<(ImageId, String)>) {
|
||||
{
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! TRACES: FR-CAT-10 | NFR-PORT-1
|
||||
//! Finding a camera card on Android, and the permission that makes it readable.
|
||||
//!
|
||||
//! `dr_plat::volumes` reads the mount table, which Android does not let an
|
||||
//! app read; the volumes are listed by `StorageManager`, which is Java. So
|
||||
//! this is the Android half of "where is the card": `Cards.java` does the
|
||||
//! asking and this is the JNI bridge to it, through the helpers `saf` already
|
||||
//! has.
|
||||
//!
|
||||
//! What comes back is an ordinary path — `/storage/9C33-6BBD` — and the rest
|
||||
//! of the import reads it exactly as it reads `/run/media/…` on the desktop.
|
||||
//! That only works once the user has granted "all files access", which is
|
||||
//! [`has_access`] and [`request_access`].
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Whether the app may read a card's files by path.
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn has_access() -> bool {
|
||||
crate::saf::call("checking card access", |env, context| {
|
||||
let cls = crate::saf::class(env, context, jni::jni_str!("paris.tourolle.darkroom.Cards"))?;
|
||||
env.call_static_method(
|
||||
&cls,
|
||||
jni::jni_str!("hasAccess"),
|
||||
jni::jni_sig!("(Landroid/content/Context;)Z"),
|
||||
&[context.into()],
|
||||
)?
|
||||
.z()
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("{e}");
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
/// Open the system page where the user grants it.
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn request_access() {
|
||||
let opened = crate::saf::call("asking for card access", |env, context| {
|
||||
let cls = crate::saf::class(env, context, jni::jni_str!("paris.tourolle.darkroom.Cards"))?;
|
||||
env.call_static_method(
|
||||
&cls,
|
||||
jni::jni_str!("requestAccess"),
|
||||
jni::jni_sig!("(Landroid/content/Context;)V"),
|
||||
&[context.into()],
|
||||
)?;
|
||||
Ok(())
|
||||
});
|
||||
if let Err(e) = opened {
|
||||
log::warn!("{e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every mounted volume that is not the device's own storage.
|
||||
///
|
||||
/// Empty when there is none, and when the platform could not be asked — the
|
||||
/// page says "insert a card" either way, which is the thing to do in both.
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn volumes() -> Vec<dr_plat::Volume> {
|
||||
let lines = crate::saf::call("listing storage volumes", |env, context| {
|
||||
let cls = crate::saf::class(env, context, jni::jni_str!("paris.tourolle.darkroom.Cards"))?;
|
||||
let value = env
|
||||
.call_static_method(
|
||||
&cls,
|
||||
jni::jni_str!("volumes"),
|
||||
jni::jni_sig!("(Landroid/content/Context;)[Ljava/lang/String;"),
|
||||
&[context.into()],
|
||||
)?
|
||||
.l()?;
|
||||
if value.is_null() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let array = env.cast_local::<jni::objects::JObjectArray>(value)?;
|
||||
let count = array.len(env)?;
|
||||
let mut out = Vec::with_capacity(count);
|
||||
for i in 0..count {
|
||||
let element = array.get_element(env, i)?;
|
||||
if let Some(line) = crate::saf::text(env, element)? {
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("{e}");
|
||||
Vec::new()
|
||||
});
|
||||
let mut found: Vec<dr_plat::Volume> = lines
|
||||
.iter()
|
||||
.filter_map(|line| parse(line))
|
||||
.map(|(path, label, removable)| dr_plat::Volume {
|
||||
has_dcim: path.join("DCIM").is_dir(),
|
||||
label,
|
||||
removable,
|
||||
path,
|
||||
})
|
||||
.collect();
|
||||
// The order `dr_plat::volumes` gives: likely cards first, then by name.
|
||||
found.sort_by(|a, b| {
|
||||
b.is_likely_card()
|
||||
.cmp(&a.is_likely_card())
|
||||
.then_with(|| a.label.cmp(&b.label))
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
/// One line from `Cards.volumes`: `path \t description \t removable`.
|
||||
///
|
||||
/// `None` for a line that does not have the three fields, which would be a
|
||||
/// mismatch between the two halves rather than anything a device does.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
fn parse(line: &str) -> Option<(PathBuf, String, bool)> {
|
||||
let mut fields = line.split('\t');
|
||||
let path = fields.next().filter(|p| !p.is_empty())?;
|
||||
let label = fields.next()?;
|
||||
let removable = fields.next()? == "1";
|
||||
if fields.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some((PathBuf::from(path), label.to_string(), removable))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_volume_line_is_read_as_cards_java_writes_it() {
|
||||
// The format is a contract between two files in two languages, and
|
||||
// only a device would otherwise notice it break.
|
||||
let java = include_str!(
|
||||
"../../../apps/darkroom-android/android/java/paris/tourolle/darkroom/Cards.java"
|
||||
);
|
||||
assert!(
|
||||
java.contains(r#"out.add(path + "\t" + description.replace('\t', ' ') + "\t""#),
|
||||
"Cards.volumes no longer writes the line this parses"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse("/storage/9C33-6BBD\tSanDisk SD card\t1"),
|
||||
Some((
|
||||
PathBuf::from("/storage/9C33-6BBD"),
|
||||
"SanDisk SD card".to_string(),
|
||||
true
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
parse("/storage/1234-ABCD\tUSB drive\t0").map(|v| v.2),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_line_is_dropped_rather_than_guessed_at() {
|
||||
assert_eq!(parse(""), None);
|
||||
assert_eq!(parse("/storage/9C33-6BBD"), None);
|
||||
assert_eq!(parse("\tlabel\t1"), None);
|
||||
assert_eq!(parse("/a\tb\t1\textra"), None);
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,8 @@ impl Decoder for Stub {
|
||||
},
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
baseline_exposure: 0.0,
|
||||
make: "Stubco".into(),
|
||||
model: "Stubco One".into(),
|
||||
})
|
||||
@@ -197,7 +199,7 @@ fn the_catalog_scan_reads_headers_through_the_trait() {
|
||||
&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[0].image_id, 7);
|
||||
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.
|
||||
pub place_adopted: 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 {
|
||||
@@ -103,6 +108,7 @@ impl SyncReport {
|
||||
|| self.face_shards_uploaded > 0
|
||||
|| self.face_shards_downloaded > 0
|
||||
|| self.place_adopted
|
||||
|| self.profiles_downloaded > 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +230,16 @@ async fn run(
|
||||
let _ = tx.send(SyncMessage::Status("checking thumbnails…".into()));
|
||||
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
|
||||
// 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
|
||||
@@ -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
|
||||
/// Fetch just the place, for the handover at launch.
|
||||
///
|
||||
@@ -1734,6 +1861,79 @@ mod derived_guard_tests {
|
||||
(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]
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user