Compare commits
100
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 | ||
|
|
5ae742816d | ||
|
|
8d66fa9be5 | ||
|
|
34630ff752 | ||
|
|
3b7d7129ff | ||
|
|
6050a8e703 | ||
|
|
ae4e1a0f07 | ||
|
|
ce5b7d72e3 | ||
|
|
98a67393d9 | ||
|
|
37136f7377 | ||
|
|
e2e2181469 | ||
|
|
ad27369cdc | ||
|
|
883b4aca10 | ||
|
|
8102234e68 | ||
|
|
757133d2a8 | ||
|
|
5e863fa718 | ||
|
|
e600dae3df | ||
|
|
330ece0abe | ||
|
|
4bd8d86c00 | ||
|
|
6c749b469d | ||
|
|
affdaecaee | ||
|
|
31bcc3a462 | ||
|
|
89b464db0c | ||
|
|
87b2599740 | ||
|
|
885864b6a0 | ||
|
|
0007fa459f | ||
|
|
1eab723c90 | ||
|
|
e601bd806c | ||
|
|
cfc1fea25a | ||
|
|
72aa7e98bf | ||
|
|
77a1925bac | ||
|
|
0d9feb0556 | ||
|
|
0682d05f95 | ||
|
|
657f8f19ff | ||
|
|
c07f81edcb | ||
|
|
92afaebd34 | ||
|
|
37a6d99dc4 | ||
|
|
db7b84795c | ||
|
|
e8898a5c38 | ||
|
|
621a6b8313 | ||
|
|
e98b1def98 | ||
|
|
f52c4cb8b6 |
Generated
+43
-27
@@ -1265,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-android"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"dr-plat",
|
||||
@@ -1278,7 +1278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-desktop"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-plat",
|
||||
@@ -1454,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dr-bench"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-catalog",
|
||||
@@ -1471,7 +1471,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-catalog"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-face",
|
||||
"dr-plat",
|
||||
@@ -1486,7 +1486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-decode"
|
||||
version = "0.18.2"
|
||||
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.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-gpu",
|
||||
@@ -1519,7 +1536,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-face"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
@@ -1532,7 +1549,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-film"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1541,7 +1558,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-gpu"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dr-decode",
|
||||
@@ -1559,7 +1576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-inference-engine"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"libloading",
|
||||
@@ -1574,7 +1591,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ingest"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-plat",
|
||||
"dr-types",
|
||||
@@ -1586,7 +1603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-lens"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"lensfun",
|
||||
"log",
|
||||
@@ -1594,7 +1611,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pano"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-inference-engine",
|
||||
@@ -1608,7 +1625,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pipeline"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -1617,7 +1634,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-plat"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"android-native-keyring-store",
|
||||
"dr-types",
|
||||
@@ -1633,7 +1650,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-preset-xmp"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-pipeline",
|
||||
"log",
|
||||
@@ -1643,7 +1660,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-segment"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
@@ -1656,7 +1673,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-plat",
|
||||
@@ -1670,7 +1687,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-folder"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-sync",
|
||||
@@ -1682,7 +1699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-nextcloud"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-decode",
|
||||
@@ -1704,7 +1721,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-thumbs"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"jpeg-encoder",
|
||||
@@ -1716,7 +1733,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-types"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1725,12 +1742,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ui"
|
||||
version = "0.18.2"
|
||||
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.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -5513,8 +5531,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "rawler"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04f4cc35c23969a4a834e0b117c7da41ace812eb9053b5effc3fc5c77d114677"
|
||||
dependencies = [
|
||||
"backtrace",
|
||||
"bitstream-io",
|
||||
@@ -7109,7 +7125,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "traceability"
|
||||
version = "0.18.2"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"proc-macro2",
|
||||
|
||||
+22
-6
@@ -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.18.2"
|
||||
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,11 +278,25 @@ opt-level = 0
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
|
||||
# Two upstream crates carry a local patch so that the Android build can draw
|
||||
# with wgpu on a rotated display (technical-debt.md TD-1). Both are exact
|
||||
# copies of the version the lockfile already resolves, plus that patch;
|
||||
# third_party/README.md says what was changed and how to carry it forward
|
||||
# when Slint or wgpu moves.
|
||||
# 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
|
||||
# pixels decodes. Each is an exact copy of the version the lockfile already
|
||||
# resolves, plus its patch; third_party/README.md says what was changed and
|
||||
# how to carry it forward when Slint, wgpu or rawler moves.
|
||||
[patch.crates-io]
|
||||
wgpu-hal = { path = "third_party/wgpu-hal-29.0.4" }
|
||||
i-slint-renderer-skia = { path = "third_party/i-slint-renderer-skia-1.17.1" }
|
||||
rawler = { path = "third_party/rawler-0.7.2" }
|
||||
|
||||
@@ -25,26 +25,34 @@ dated folder and a backup beside it — is found, proved the same, and folded
|
||||
onto one copy with the spares in the trash. Face detection and identity,
|
||||
with the index syncing between devices.
|
||||
|
||||
**Developing.** Eighteen declared operations fused into one compute
|
||||
dispatch, plus the neighbourhood work that cannot be: clarity, texture,
|
||||
capture sharpening, noise reduction, lens correction, spectral film
|
||||
simulation. Crop, straighten and correct converging verticals, spot repair,
|
||||
and local adjustments over masks the model draws — click a subject or a
|
||||
category, then paint, subtract a gradient or keep only where two selections
|
||||
agree, grow or shrink the edge. A mask's sliders add to the photograph's, the
|
||||
film's among them, so a sky can be burned in on the print as a darkroom
|
||||
printer would. Hot and dead photosites are mended before the demosaic, with
|
||||
nothing to set. Focus peaking and a raw histogram for judging
|
||||
what is recoverable. Presets, with a collection shipped in the application —
|
||||
**Developing.** Nineteen declared operations, those that read one pixel
|
||||
fused into a generated shader rather than run a pass each, plus the
|
||||
neighbourhood work that cannot be: clarity, texture, dehaze, capture
|
||||
sharpening, noise reduction, lens correction. Every edit works on the scene
|
||||
as the camera recorded it — linear, highlights beyond white included — and
|
||||
one `Tone Mapping` step, last, after sharpening and noise reduction, fits it
|
||||
to the screen, with a contrast and a white point of its own; a spectral film
|
||||
stock takes its place when one is chosen. Crop, straighten and correct
|
||||
converging verticals, spot repair, and local adjustments over masks the
|
||||
model draws — click a subject or a category, then paint, subtract a gradient
|
||||
or keep only where two selections agree, grow or shrink the edge. A mask's
|
||||
sliders add to the photograph's, the film's among them, so a sky can be
|
||||
burned in on the print as a darkroom printer would. Hot and dead photosites
|
||||
are mended before the demosaic, with nothing to set. Focus peaking and a raw
|
||||
histogram for judging what is recoverable. Presets, a click away in a menu
|
||||
at the foot of the tool rail, with a collection shipped in the application —
|
||||
everyday corrections, and a look for each measured colour, cinema and
|
||||
black-and-white stock — and Lightroom presets imported as looks that leave a
|
||||
photograph's own corrections alone. XMP sidecars other editors read.
|
||||
photograph's own corrections alone. XMP sidecars other editors read. A
|
||||
linear DNG larger than one GPU texture — a stitched panorama twenty thousand
|
||||
pixels wide — opens, develops and exports at full size.
|
||||
|
||||
[](docs/manual/README.md#local-adjustments)
|
||||
|
||||
**Panoramas.** Select the frames, align, choose a projection, fill the
|
||||
ragged border rather than crop it, and the composite lands beside its
|
||||
sources as a DNG, with a sidecar recording what it was merged from.
|
||||
**Panoramas.** Select the frames, align, untick any frame to leave it out
|
||||
and the rest re-align at once, choose a projection, fill the ragged border
|
||||
rather than crop it, and the composite lands beside its sources as a DNG,
|
||||
with a sidecar recording what it was merged from.
|
||||
|
||||
[](docs/manual/README.md#merging-a-panorama)
|
||||
|
||||
@@ -75,40 +83,137 @@ 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.18.2**, twenty-eight tagged releases in. 192 numbered requirements in
|
||||
scope, 84% of them claimed by code and [traced to it](docs/dev/traceability.md);
|
||||
**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.
|
||||
|
||||
**Not built:** plugins (post-v1, [D12](docs/dev/requirements.md)), compare and
|
||||
survey culling, AI denoise, tiled rendering, HDR merge and
|
||||
focus stacking, importing a Lightroom or darktable catalog, translations
|
||||
beyond the launch screen, most of the Android platform integration beyond
|
||||
running, and a Flatpak actually built and run in its sandbox. The
|
||||
performance targets are half verified: the per-commit benchmark suite §8
|
||||
requires exists for everything that does not need a frame — the catalog,
|
||||
the scan, the thumbnails — and not yet for the render path, so a regression
|
||||
there fails nothing.
|
||||
survey culling, AI denoise, tiled rendering beyond the export of an oversized
|
||||
DNG, HDR merge and focus stacking, importing a Lightroom or darktable catalog,
|
||||
translations beyond the launch screen, most of the Android platform
|
||||
integration beyond running, and a Flatpak actually built and run in its
|
||||
sandbox. The performance targets are half verified: the per-commit benchmark
|
||||
suite §8 requires exists for everything that does not need a frame — the
|
||||
catalog, the scan, the thumbnails — and not yet for the render path, so a
|
||||
regression there fails nothing.
|
||||
[outstanding.md](docs/dev/outstanding.md) is the list, with the reasoning for
|
||||
each.
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
# DarkRoom camera base curves (FR-DEV-3e).
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adding a body is editing this file. It is not a code change.
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The copy you are reading is compiled into the binary as a floor. At startup
|
||||
# `dr_decode::base_curve::load` also looks for `base_curves.yaml` in:
|
||||
#
|
||||
# 1. $DARKROOM_PROFILES/ (set it while you are tuning)
|
||||
# 2. $XDG_DATA_HOME/darkroom/profiles/
|
||||
# or $HOME/.local/share/darkroom/profiles/
|
||||
#
|
||||
# and uses the first one it finds *whose `version:` is higher than this one's*.
|
||||
# So: bump `version`, drop the file in that directory, restart. A body added
|
||||
# this afternoon renders correctly this afternoon, with no release and no
|
||||
# rebuild — which is what the requirement asks for, and what makes these
|
||||
# contributable under the GPL.
|
||||
#
|
||||
# The version check runs both ways on purpose. A file older than the built-in
|
||||
# copy is ignored with a log line, so upgrading DarkRoom cannot silently lose
|
||||
# curves to a pack somebody downloaded a year ago.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# What the numbers mean
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Five `[x, y]` control points on a monotone spline (Fritsch-Carlson, the same
|
||||
# one the tone curve widget draws). Both axes are **linear**:
|
||||
#
|
||||
# x scene-referred camera RGB after white balance, 1.0 = sensor saturation
|
||||
# y display-referred linear; the sRGB transfer function is applied later,
|
||||
# at the end of the shader, so do not pre-apply a gamma here
|
||||
#
|
||||
# The identity is y = x, and it is what an unrecognised body gets if `default:`
|
||||
# is removed. It is also the wrong answer for almost every photograph: linear
|
||||
# scene data has middle grey at about 13% and a camera JPEG puts it near 18%,
|
||||
# so an uncurved render is roughly half a stop dark through the midtones and
|
||||
# has no highlight rolloff at all.
|
||||
#
|
||||
# A curve that works has three parts, and it is worth naming them because they
|
||||
# are what you are actually tuning:
|
||||
#
|
||||
# the toe the first span, slope near or below 1. Deep shadows stay
|
||||
# deep. Lift it and blacks go milky; crush it and shadow
|
||||
# detail the sensor recorded disappears.
|
||||
# the midtones the middle spans, slope well above 1. This is the contrast
|
||||
# and the brightness people read as "the camera's look".
|
||||
# the shoulder the last span, slope well below 1. Highlights compress
|
||||
# toward white instead of arriving there and clipping. It is
|
||||
# the difference between a rolled-off sky and a white hole.
|
||||
#
|
||||
# Two invariants are enforced in code and tested, so a mistake here fails the
|
||||
# build rather than the photograph: x must strictly increase, y must not
|
||||
# decrease, and everything must lie inside the unit square.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Honesty about these values
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# These are hand-tuned shapes, not measurements. They encode what every camera
|
||||
# JPEG rendering has in common — the toe/midtone/shoulder structure above —
|
||||
# plus each maker's well-known house differences: Canon's gentler shoulder and
|
||||
# warmer-reading midtones, Nikon's slightly higher midtone contrast, Sony's
|
||||
# flatter and more conservative default, Fujifilm's markedly contrastier
|
||||
# Provia-derived rendering.
|
||||
#
|
||||
# FR-DEV-3e's acceptance criterion is subjective comparison against each body's
|
||||
# own JPEG, and meeting it properly needs a frame from that body in front of
|
||||
# you. Where that has not been done, the entry is still much closer to right
|
||||
# than the identity — which is the bar these have to clear, and do.
|
||||
|
||||
version: 1
|
||||
|
||||
# The rendering for a body with no entry of its own.
|
||||
#
|
||||
# **Deliberately not the identity.** The failure this requirement exists to fix
|
||||
# is the flat render, and a conservative curve is far closer to right for every
|
||||
# body than no curve is for any of them. It is gentler than the per-body
|
||||
# entries below — a shallower midtone and an earlier, softer shoulder — because
|
||||
# it has to be safe on a sensor nobody has looked at, and the cost of being too
|
||||
# tame is a photograph that wants a little contrast rather than one that has
|
||||
# lost its highlights.
|
||||
default:
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.04, 0.043]
|
||||
- [0.13, 0.175]
|
||||
- [0.45, 0.690]
|
||||
- [1.00, 1.000]
|
||||
|
||||
bodies:
|
||||
# Canon. A soft toe and a long, gradual shoulder — the reason Canon files
|
||||
# are described as forgiving in highlights and a little low in contrast
|
||||
# straight out of camera.
|
||||
- make: Canon
|
||||
model: EOS 6D
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.04, 0.045]
|
||||
- [0.13, 0.190]
|
||||
- [0.45, 0.720]
|
||||
- [1.00, 1.000]
|
||||
|
||||
- make: Canon
|
||||
model: EOS R6
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.04, 0.044]
|
||||
- [0.13, 0.195]
|
||||
- [0.45, 0.730]
|
||||
- [1.00, 1.000]
|
||||
|
||||
# Nikon. A slightly deeper toe and more midtone slope than Canon, which is
|
||||
# the "punchier out of camera" difference people describe between the two.
|
||||
- make: Nikon
|
||||
model: Z 6
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.04, 0.038]
|
||||
- [0.13, 0.200]
|
||||
- [0.46, 0.750]
|
||||
- [1.00, 1.000]
|
||||
|
||||
- make: Nikon
|
||||
model: D750
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.04, 0.039]
|
||||
- [0.13, 0.198]
|
||||
- [0.46, 0.745]
|
||||
- [1.00, 1.000]
|
||||
|
||||
# Sony. The flattest default of the four, and intentionally so — Sony's own
|
||||
# rendering leaves more headroom than it uses, which is why Sony files are
|
||||
# the ones people describe as needing the most work.
|
||||
- make: Sony
|
||||
model: ILCE-7M3
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.04, 0.048]
|
||||
- [0.13, 0.185]
|
||||
- [0.44, 0.700]
|
||||
- [1.00, 1.000]
|
||||
|
||||
# Fujifilm. Provia, the default film simulation: a firm toe, the steepest
|
||||
# midtones here, and a hard shoulder. It is the most distinctive rendering of
|
||||
# the four and the one where a flat render looks most obviously wrong.
|
||||
#
|
||||
# This entry does *not* read the in-RAF film simulation tag — that is
|
||||
# FR-DEV-3f, and until it lands every Fujifilm file gets the Provia shape
|
||||
# whatever the camera was set to.
|
||||
- make: Fujifilm
|
||||
model: X-T3
|
||||
points:
|
||||
- [0.00, 0.000]
|
||||
- [0.045, 0.040]
|
||||
- [0.14, 0.215]
|
||||
- [0.47, 0.775]
|
||||
- [1.00, 1.000]
|
||||
@@ -1,752 +0,0 @@
|
||||
//! TRACES: FR-DEV-3e
|
||||
//! Base curves — the per-body rendering that turns a correct exposure into a
|
||||
//! photograph.
|
||||
//!
|
||||
//! # What this is for
|
||||
//!
|
||||
//! A camera matrix gets the *colours* right and leaves the picture flat. Sensor
|
||||
//! data is scene-referred and very nearly linear; a print, a screen and a
|
||||
//! camera's own JPEG are none of those things. Rendering linear data straight
|
||||
//! out is the dcraw default, and FR-DEV-3e names it precisely: "the flat,
|
||||
//! poor-skin-tone rendering characteristic of dcraw defaults, which is the
|
||||
//! documented reason people abandon darktable in the first hour."
|
||||
//!
|
||||
//! The fix is a tone curve applied as part of *reading* the file rather than as
|
||||
//! an edit — a toe, a steep midtone, and a shoulder that rolls highlights off
|
||||
//! instead of clipping them. Every raw converter has one. Adobe calls it the
|
||||
//! camera profile's tone curve, darktable calls it the base curve, and the name
|
||||
//! here follows darktable's because the placement does too: it runs in camera
|
||||
//! RGB, after white balance and the user's adjustments, immediately before the
|
||||
//! conversion out to a working space.
|
||||
//!
|
||||
//! # Why it is not an edit
|
||||
//!
|
||||
//! It never reaches the sidecar and there is no slider for it, for the same
|
||||
//! reason the EXIF orientation is not an edit (FR-DEV-3h): it is a property of
|
||||
//! the body that took the frame, not of what anyone decided about the frame.
|
||||
//! Sidecars are shared between devices and bodies (FR-NC-9), and one camera's
|
||||
//! rendering must not follow an edit onto another camera's file.
|
||||
//!
|
||||
//! # Why it is data
|
||||
//!
|
||||
//! FR-DEV-3e requires the profile database to be "versioned independently of
|
||||
//! the app binary so bodies and curves can be added without a release — and,
|
||||
//! under D8's GPLv3, contributed by users". So the curves live in
|
||||
//! `profiles/base_curves.yaml`, a file that is compiled in as a floor and
|
||||
//! *overridden* by a copy on disk carrying a higher `version:`. Adding a body
|
||||
//! is adding ten numbers to a YAML file; shipping that body to users is
|
||||
//! publishing the file. Neither is a code change and neither needs a release.
|
||||
//!
|
||||
//! See [`load`] for the search path and [`Curves::body`] for the matching.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// How many control points a base curve has.
|
||||
///
|
||||
/// Five, which is not a coincidence: it is what the tone curve widget uses
|
||||
/// (`dr_pipeline::ops::curve::POINTS`), so the shader evaluates a profile's
|
||||
/// curve and a photographer's curve through exactly the same spline. A profile
|
||||
/// author and a photographer dragging a point mean the same thing by it, and
|
||||
/// the generated shader carries one implementation rather than two that could
|
||||
/// disagree.
|
||||
pub const POINTS: usize = 5;
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// A base curve: five points on a monotone spline through the unit square.
|
||||
///
|
||||
/// `xs` is scene-linear camera RGB, normalised so that 1.0 is the sensor's
|
||||
/// saturation point. `ys` is display-referred linear — *not* gamma-encoded,
|
||||
/// because the sRGB transfer function is applied at the very end of the
|
||||
/// generated shader and applying it twice would wash the image out.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct BaseCurve {
|
||||
pub xs: [f32; POINTS],
|
||||
pub ys: [f32; POINTS],
|
||||
}
|
||||
|
||||
impl BaseCurve {
|
||||
/// The curve that does nothing — the identity diagonal.
|
||||
///
|
||||
/// What an unrecognised body gets if the database carries no default, and
|
||||
/// what a JPEG gets always: an already-rendered image must not be rendered
|
||||
/// a second time.
|
||||
pub const IDENTITY: Self = Self {
|
||||
xs: [0.0, 0.25, 0.5, 0.75, 1.0],
|
||||
ys: [0.0, 0.25, 0.5, 0.75, 1.0],
|
||||
};
|
||||
|
||||
/// Whether this curve would leave the image alone.
|
||||
///
|
||||
/// The shader is told to skip the stage entirely when it would, so an
|
||||
/// unprofiled body costs a branch that is uniform across the dispatch
|
||||
/// rather than a spline evaluation per channel per pixel.
|
||||
pub fn is_identity(&self) -> bool {
|
||||
self.xs
|
||||
.iter()
|
||||
.zip(self.ys.iter())
|
||||
.all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
}
|
||||
|
||||
/// Build from raw pairs, rejecting anything that is not a curve.
|
||||
///
|
||||
/// A profile file is data a user may have edited, so this is the boundary
|
||||
/// where "ten numbers" becomes "a curve": the x coordinates must increase,
|
||||
/// the y coordinates must not decrease, and both must lie in the unit
|
||||
/// square. A non-monotone x sends the spline's span search backwards and
|
||||
/// divides by a negative width; a decreasing y inverts tones locally,
|
||||
/// which reads as a dark halo through smooth gradients rather than as a
|
||||
/// bad profile.
|
||||
///
|
||||
/// Endpoints are not forced to (0,0) and (1,1). A curve that lifts black
|
||||
/// slightly, or that places the shoulder below white, is a legitimate
|
||||
/// rendering choice and several bodies make it.
|
||||
pub fn from_points(points: &[[f32; 2]]) -> Option<Self> {
|
||||
if points.len() != POINTS {
|
||||
return None;
|
||||
}
|
||||
let mut xs = [0.0f32; POINTS];
|
||||
let mut ys = [0.0f32; POINTS];
|
||||
for (i, p) in points.iter().enumerate() {
|
||||
if !p[0].is_finite() || !p[1].is_finite() {
|
||||
return None;
|
||||
}
|
||||
if !(0.0..=1.0).contains(&p[0]) || !(0.0..=1.0).contains(&p[1]) {
|
||||
return None;
|
||||
}
|
||||
xs[i] = p[0];
|
||||
ys[i] = p[1];
|
||||
}
|
||||
for i in 1..POINTS {
|
||||
// Strictly increasing in x — the spline divides by the span width.
|
||||
if xs[i] <= xs[i - 1] {
|
||||
return None;
|
||||
}
|
||||
// Non-decreasing in y. Flat is allowed: a curve that holds a
|
||||
// highlight range at white is clipping deliberately.
|
||||
if ys[i] < ys[i - 1] {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Self { xs, ys })
|
||||
}
|
||||
}
|
||||
|
||||
/// One body's entry in the database.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct BodyCurve {
|
||||
/// The manufacturer, as the file writes it — "Canon", "NIKON CORPORATION".
|
||||
pub make: String,
|
||||
/// The model, as the file writes it — "EOS 6D", "ILCE-7M3".
|
||||
pub model: String,
|
||||
pub curve: BaseCurve,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The base curve database.
|
||||
///
|
||||
/// Versioned as a whole rather than per body, because that is the unit a user
|
||||
/// downloads and the unit that has to beat the built-in copy. See [`load`].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Curves {
|
||||
version: u32,
|
||||
default: Option<BaseCurve>,
|
||||
bodies: Vec<BodyCurve>,
|
||||
}
|
||||
|
||||
impl Curves {
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The curve to render a frame from this body with.
|
||||
///
|
||||
/// Falls back, in order, to the database's `default:` and then to the
|
||||
/// identity. **The default is deliberately not the identity**: an
|
||||
/// unrecognised body rendered flat is the failure this requirement exists
|
||||
/// to prevent, and a gentle, conservative curve is much closer to right for
|
||||
/// every body than no curve is for any of them. A body with its own entry
|
||||
/// gets that instead.
|
||||
///
|
||||
/// # What "this body" has to survive
|
||||
///
|
||||
/// The same camera names itself three ways depending on which program last
|
||||
/// touched the file. A native NEF says make "NIKON CORPORATION", model
|
||||
/// "NIKON Z 6"; rawler's own database cleans that to "Nikon" and "Z 6"; an
|
||||
/// Adobe-converted DNG keeps the uncleaned pair. A database that had to
|
||||
/// spell every variant would go stale the first time a maker changed its
|
||||
/// mind about its own name, so the matching does the folding instead:
|
||||
///
|
||||
/// - Case, punctuation and runs of whitespace are flattened, so
|
||||
/// "ILCE-7M3", "ILCE 7M3" and "ilce-7m3" are one body.
|
||||
/// - The make is compared on its **first word only**. Every maker's
|
||||
/// trailing corporate boilerplate — "CORPORATION", "IMAGING CORP" — is
|
||||
/// noise, and no two camera manufacturers share a first word.
|
||||
/// - The model is tried both as written and with a leading copy of the
|
||||
/// make removed, which is what lets one "Canon"/"EOS 6D" entry cover
|
||||
/// "Canon EOS 6D" as well.
|
||||
pub fn body(&self, make: &str, model: &str) -> BaseCurve {
|
||||
let (make, model) = (make_key(make), normalise(model));
|
||||
// The model with a leading copy of the maker's name removed.
|
||||
let bare = model.strip_prefix(&format!("{make} ")).unwrap_or(&model);
|
||||
|
||||
self.bodies
|
||||
.iter()
|
||||
.find(|b| {
|
||||
let entry_model = normalise(&b.model);
|
||||
make_key(&b.make) == make && (entry_model == model || entry_model == bare)
|
||||
})
|
||||
.map(|b| b.curve)
|
||||
.or(self.default)
|
||||
.unwrap_or(BaseCurve::IDENTITY)
|
||||
}
|
||||
|
||||
/// The database version. Higher wins; see [`load`].
|
||||
pub fn version(&self) -> u32 {
|
||||
self.version
|
||||
}
|
||||
|
||||
/// How many bodies have their own curve, excluding the default.
|
||||
pub fn len(&self) -> usize {
|
||||
self.bodies.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.bodies.is_empty()
|
||||
}
|
||||
|
||||
/// Parse a database from YAML.
|
||||
///
|
||||
/// Entries that are not curves are dropped with a warning rather than
|
||||
/// failing the parse. A user-contributed file with one bad body should
|
||||
/// cost that body's rendering, not every body's — and the alternative is an
|
||||
/// application that will not open a photograph because somebody typed a
|
||||
/// comma.
|
||||
pub fn parse(yaml: &str) -> Result<Self, String> {
|
||||
let file: File = serde_norway::from_str(yaml).map_err(|e| e.to_string())?;
|
||||
|
||||
let default = file.default.and_then(|d| {
|
||||
BaseCurve::from_points(&d.points).or_else(|| {
|
||||
log::warn!("base curves: the default entry is not a monotone curve; ignoring it");
|
||||
None
|
||||
})
|
||||
});
|
||||
|
||||
let bodies = file
|
||||
.bodies
|
||||
.into_iter()
|
||||
.filter_map(|b| match BaseCurve::from_points(&b.points) {
|
||||
Some(curve) => Some(BodyCurve {
|
||||
make: b.make,
|
||||
model: b.model,
|
||||
curve,
|
||||
}),
|
||||
None => {
|
||||
log::warn!(
|
||||
"base curves: {} {} is not a monotone curve; ignoring it",
|
||||
b.make,
|
||||
b.model
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Self {
|
||||
version: file.version,
|
||||
default,
|
||||
bodies,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The copy that ships inside the binary.
|
||||
///
|
||||
/// A floor, not the answer: [`load`] prefers a newer file on disk. Compiled in
|
||||
/// so that a fresh install with no profile directory — and every Android build,
|
||||
/// where there is no such directory to speak of — still renders properly.
|
||||
const BUILT_IN: &str = include_str!("../profiles/base_curves.yaml");
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The base curve database, loaded once.
|
||||
///
|
||||
/// # The search path, and why it is a version comparison
|
||||
///
|
||||
/// 1. `$DARKROOM_PROFILES`, a directory, when set. The escape hatch: a profile
|
||||
/// author iterating on a curve points this at their working copy and does
|
||||
/// not have to install anything.
|
||||
/// 2. `$XDG_DATA_HOME/darkroom/profiles/`, else `$HOME/.local/share/darkroom/profiles/`.
|
||||
/// The same base directory the catalog uses, chosen there for the same
|
||||
/// reason — it is data, not cache, and must survive a storage sweep.
|
||||
/// 3. The copy compiled into the binary.
|
||||
///
|
||||
/// The first file that parses *and carries a higher `version:` than the
|
||||
/// built-in copy* wins. The version check is the whole mechanism the
|
||||
/// requirement asks for, and it runs in both directions:
|
||||
///
|
||||
/// - A downloaded pack at version 7 supersedes a binary shipping version 3, so
|
||||
/// a body added after the release renders correctly with no release.
|
||||
/// - A stale pack at version 2 does **not** supersede a binary shipping version
|
||||
/// 3, so upgrading the application cannot silently lose curves to a file
|
||||
/// somebody downloaded a year ago and forgot.
|
||||
///
|
||||
/// Failures are warnings, never errors. A malformed profile file must cost the
|
||||
/// user their curves, not their photographs.
|
||||
pub fn load() -> &'static Curves {
|
||||
static LOADED: OnceLock<Curves> = OnceLock::new();
|
||||
LOADED.get_or_init(|| {
|
||||
let built_in = Curves::parse(BUILT_IN).unwrap_or_else(|e| {
|
||||
// Unreachable in a build that ran its tests — `the_shipped_database_parses`
|
||||
// asserts exactly this — but a panic here would mean an
|
||||
// application that cannot open a photograph because of a typo in a
|
||||
// data file, which is never the right trade.
|
||||
log::error!("base curves: the built-in database does not parse: {e}");
|
||||
Curves {
|
||||
version: 0,
|
||||
default: None,
|
||||
bodies: Vec::new(),
|
||||
}
|
||||
});
|
||||
|
||||
choose(built_in, &search_path())
|
||||
})
|
||||
}
|
||||
|
||||
/// The version comparison, separated from where the directories come from.
|
||||
///
|
||||
/// Split out so it can be tested against real files in a real directory
|
||||
/// without the process-wide `OnceLock` and the environment `load` reads. The
|
||||
/// rule this implements is the whole of what FR-DEV-3e asks for, so it is
|
||||
/// worth being able to state it as a test rather than as a comment.
|
||||
fn choose(built_in: Curves, dirs: &[PathBuf]) -> Curves {
|
||||
for dir in dirs {
|
||||
let path = dir.join("base_curves.yaml");
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
match Curves::parse(&text) {
|
||||
Ok(external) if external.version > built_in.version => {
|
||||
log::info!(
|
||||
"base curves: using {} (version {}, {} bodies) over the built-in version {}",
|
||||
path.display(),
|
||||
external.version,
|
||||
external.len(),
|
||||
built_in.version
|
||||
);
|
||||
return external;
|
||||
}
|
||||
Ok(external) => log::info!(
|
||||
"base curves: ignoring {} at version {}; the built-in database is version {}",
|
||||
path.display(),
|
||||
external.version,
|
||||
built_in.version
|
||||
),
|
||||
Err(e) => log::warn!("base curves: {} does not parse: {e}", path.display()),
|
||||
}
|
||||
}
|
||||
built_in
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The curve for a body, from the loaded database.
|
||||
///
|
||||
/// The one call site the decoder needs; everything above is reachable for
|
||||
/// tests and for a future profile editor.
|
||||
pub fn for_body(make: &str, model: &str) -> BaseCurve {
|
||||
load().body(make, model)
|
||||
}
|
||||
|
||||
/// Directories that may hold a `base_curves.yaml`, most specific first.
|
||||
fn search_path() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(explicit) = std::env::var_os("DARKROOM_PROFILES") {
|
||||
dirs.push(PathBuf::from(explicit));
|
||||
}
|
||||
// The same resolution `dr_ui::library::catalog_path` uses, and for the
|
||||
// same reason: this is data a user may have installed, not a cache. It is
|
||||
// duplicated rather than shared because `dr-decode` sits far below the UI
|
||||
// and must not acquire a dependency on it to find a directory.
|
||||
let base = std::env::var_os("XDG_DATA_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| Path::new(&h).join(".local/share")));
|
||||
if let Some(base) = base {
|
||||
dirs.push(base.join("darkroom").join("profiles"));
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// A manufacturer's first word, folded.
|
||||
///
|
||||
/// "NIKON CORPORATION", "Nikon" and "nikon" all become `NIKON`. The corporate
|
||||
/// suffixes are not information — they appear or not depending on whether the
|
||||
/// file went through a DNG converter — and no two camera manufacturers share a
|
||||
/// first word, so nothing is lost by dropping them.
|
||||
fn make_key(s: &str) -> String {
|
||||
normalise(s)
|
||||
.split(' ')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Fold a make or model into something two files can agree on.
|
||||
///
|
||||
/// Upper-cased, with every run of non-alphanumeric characters collapsed to one
|
||||
/// space and the ends trimmed, so that "ILCE-7M3", "ILCE 7M3" and "ilce-7m3"
|
||||
/// become one.
|
||||
fn normalise(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut pending_space = false;
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
if pending_space && !out.is_empty() {
|
||||
out.push(' ');
|
||||
}
|
||||
pending_space = false;
|
||||
out.push(c.to_ascii_uppercase());
|
||||
} else {
|
||||
pending_space = true;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---- The on-disk shape, kept apart from the in-memory one ----------------
|
||||
//
|
||||
// Deliberately separate types. The file is data a user edits and is allowed to
|
||||
// be wrong; `Curves` is a parsed database whose every entry is known to be a
|
||||
// monotone curve. Deriving `Deserialize` on `BaseCurve` directly would delete
|
||||
// that boundary and let an unchecked five-point array reach the shader.
|
||||
//
|
||||
// Unknown fields are **accepted**, which is not laziness. The database is
|
||||
// versioned independently of the binary and moves in both directions: a pack
|
||||
// published after this release may carry keys this build has never heard of —
|
||||
// a hue twist, a look table (FR-DEV-3f) — and it must still deliver its curves
|
||||
// to an older DarkRoom rather than failing to parse and leaving every body
|
||||
// flat. `deny_unknown_fields` would trade that for a diagnostic nobody needs.
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct File {
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
default: Option<Entry>,
|
||||
#[serde(default)]
|
||||
bodies: Vec<BodyEntry>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Entry {
|
||||
points: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct BodyEntry {
|
||||
make: String,
|
||||
model: String,
|
||||
points: Vec<[f32; 2]>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_shipped_database_parses_and_carries_a_default() {
|
||||
// The one test that must never be allowed to fail quietly: `load`
|
||||
// degrades to an empty database rather than panicking, so without this
|
||||
// a typo in the YAML would ship as "every photograph renders flat"
|
||||
// rather than as a build failure.
|
||||
let curves = Curves::parse(BUILT_IN).expect("the shipped database parses");
|
||||
assert!(curves.version() >= 1);
|
||||
assert!(!curves.is_empty(), "the database ships bodies");
|
||||
assert!(
|
||||
!curves.body("Nobody", "Nothing").is_identity(),
|
||||
"an unknown body must still get the default rendering"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_shipped_curve_lifts_the_midtones_and_rolls_the_highlights() {
|
||||
// What makes a base curve a base curve rather than a decoration. If a
|
||||
// shipped curve failed either half it would be a worse rendering than
|
||||
// the flat one it replaced, which is the one outcome forbidden.
|
||||
let curves = Curves::parse(BUILT_IN).expect("parses");
|
||||
let all = curves
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| (format!("{} {}", b.make, b.model), b.curve))
|
||||
.chain(curves.default.map(|c| ("default".to_string(), c)));
|
||||
|
||||
for (name, curve) in all {
|
||||
// The midtone point sits above the diagonal: a linear midtone is
|
||||
// roughly a stop and a half darker than any camera renders it.
|
||||
let mid = 2;
|
||||
assert!(
|
||||
curve.ys[mid] > curve.xs[mid],
|
||||
"{name} does not lift its midtones ({} -> {})",
|
||||
curve.xs[mid],
|
||||
curve.ys[mid]
|
||||
);
|
||||
// And the last span is shallower than the one before it, which is
|
||||
// what a shoulder *is*. Without one the curve clips highlights
|
||||
// harder than the linear rendering did.
|
||||
let slope =
|
||||
|i: usize| (curve.ys[i + 1] - curve.ys[i]) / (curve.xs[i + 1] - curve.xs[i]);
|
||||
assert!(
|
||||
slope(POINTS - 2) < slope(POINTS - 3),
|
||||
"{name} has no highlight shoulder"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_that_is_not_monotone_is_refused() {
|
||||
// The profile file is user-editable, so this is a real boundary and
|
||||
// not a formality. A decreasing y inverts tones locally and shows up
|
||||
// as a dark halo in a gradient, which reads as a rendering fault
|
||||
// rather than as a bad profile.
|
||||
assert_eq!(
|
||||
BaseCurve::from_points(&[[0.0, 0.0], [0.25, 0.4], [0.5, 0.3], [0.75, 0.8], [1.0, 1.0]]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_whose_x_does_not_advance_is_refused() {
|
||||
// The spline divides by the span width; a repeated x is a division by
|
||||
// zero in the shader, which is a NaN pixel rather than an error.
|
||||
assert_eq!(
|
||||
BaseCurve::from_points(&[
|
||||
[0.0, 0.0],
|
||||
[0.25, 0.3],
|
||||
[0.25, 0.5],
|
||||
[0.75, 0.8],
|
||||
[1.0, 1.0]
|
||||
]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_of_the_wrong_length_is_refused() {
|
||||
assert_eq!(BaseCurve::from_points(&[[0.0, 0.0], [1.0, 1.0]]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_outside_the_unit_square_are_refused() {
|
||||
// The shader clamps its output at the very end anyway, but a control
|
||||
// point above 1.0 would put the shoulder outside the range the curve
|
||||
// is defined over and silently flatten everything below it.
|
||||
assert_eq!(
|
||||
BaseCurve::from_points(&[[0.0, 0.0], [0.25, 0.3], [0.5, 1.4], [0.75, 1.5], [1.0, 1.6]]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_with_its_own_entry_beats_the_default() {
|
||||
let curves = Curves::parse(
|
||||
"version: 2
|
||||
default:
|
||||
points: [[0.0, 0.0], [0.25, 0.3], [0.5, 0.6], [0.75, 0.85], [1.0, 1.0]]
|
||||
bodies:
|
||||
- make: Canon
|
||||
model: EOS 6D
|
||||
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
",
|
||||
)
|
||||
.expect("parses");
|
||||
|
||||
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
|
||||
assert_eq!(curves.body("Canon", "EOS 5D").ys[1], 0.30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_make_may_be_repeated_in_the_model() {
|
||||
// Canon writes "Canon" as the make and "Canon EOS 6D" as the model;
|
||||
// rawler's cleaned strings drop the repetition and both reach here.
|
||||
// One entry has to cover both or half the files on a card miss.
|
||||
let curves = Curves::parse(
|
||||
"version: 1
|
||||
bodies:
|
||||
- make: Canon
|
||||
model: EOS 6D
|
||||
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
",
|
||||
)
|
||||
.expect("parses");
|
||||
|
||||
assert_eq!(curves.body("Canon", "Canon EOS 6D").ys[1], 0.35);
|
||||
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
|
||||
assert_eq!(curves.body("CANON", "eos 6d").ys[1], 0.35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corporate_suffix_does_not_hide_a_body() {
|
||||
// The same Z 6 arrives as "Nikon"/"Z 6" from rawler's camera database
|
||||
// and as "NIKON CORPORATION"/"NIKON Z 6" from a DNG converted out of
|
||||
// the same file. Both must find the entry, or converting a file to
|
||||
// DNG would silently change how it renders.
|
||||
let curves = Curves::parse(
|
||||
"version: 1
|
||||
bodies:
|
||||
- make: Nikon
|
||||
model: Z 6
|
||||
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
",
|
||||
)
|
||||
.expect("parses");
|
||||
|
||||
assert_eq!(curves.body("Nikon", "Z 6").ys[1], 0.35);
|
||||
assert_eq!(curves.body("NIKON CORPORATION", "NIKON Z 6").ys[1], 0.35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn punctuation_and_spacing_do_not_decide_whether_a_body_is_known() {
|
||||
let curves = Curves::parse(
|
||||
"version: 1
|
||||
bodies:
|
||||
- make: Sony
|
||||
model: ILCE-7M3
|
||||
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
",
|
||||
)
|
||||
.expect("parses");
|
||||
|
||||
assert_eq!(curves.body("SONY", "ILCE 7M3").ys[1], 0.35);
|
||||
assert_eq!(curves.body("sony", "ilce-7m3").ys[1], 0.35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_bad_entry_does_not_cost_the_rest() {
|
||||
// A user-contributed file with one typo should cost that body's
|
||||
// rendering, not every body's.
|
||||
let curves = Curves::parse(
|
||||
"version: 1
|
||||
bodies:
|
||||
- make: Broken
|
||||
model: Body
|
||||
points: [[0.0, 0.0], [0.25, 0.9], [0.5, 0.1], [0.75, 0.9], [1.0, 1.0]]
|
||||
- make: Canon
|
||||
model: EOS 6D
|
||||
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
",
|
||||
)
|
||||
.expect("parses");
|
||||
|
||||
assert_eq!(curves.len(), 1);
|
||||
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
|
||||
assert!(curves.body("Broken", "Body").is_identity());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_from_the_future_still_delivers_its_curves() {
|
||||
// The database is versioned independently of the binary, so a pack
|
||||
// published after this build may carry keys this build has never heard
|
||||
// of. It must still hand over the curves it does understand — failing
|
||||
// the parse would leave every body flat, which is the exact failure
|
||||
// FR-DEV-3e exists to prevent, delivered by the mechanism meant to
|
||||
// prevent it.
|
||||
let curves = Curves::parse(
|
||||
"version: 9
|
||||
look_table: ambitious
|
||||
bodies:
|
||||
- make: Canon
|
||||
model: EOS 6D
|
||||
hue_twist: [1, 2, 3]
|
||||
points: [[0.0, 0.0], [0.25, 0.35], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
",
|
||||
)
|
||||
.expect("an unfamiliar key must not fail the parse");
|
||||
|
||||
assert_eq!(curves.version(), 9);
|
||||
assert_eq!(curves.body("Canon", "EOS 6D").ys[1], 0.35);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_body_with_no_default_gets_the_identity() {
|
||||
// Graceful fallback, stated as a property: never worse than a flat
|
||||
// render, and never a curve tuned for somebody else's sensor when the
|
||||
// database declines to offer one.
|
||||
let curves = Curves::parse("version: 1\nbodies: []\n").expect("parses");
|
||||
assert!(curves.body("Nobody", "Nothing").is_identity());
|
||||
}
|
||||
|
||||
/// A directory holding one `base_curves.yaml`, unique to the caller.
|
||||
fn a_pack_dir(name: &str, yaml: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("darkroom-base-curves-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("a writable temp directory");
|
||||
std::fs::write(dir.join("base_curves.yaml"), yaml).expect("write");
|
||||
dir
|
||||
}
|
||||
|
||||
const A_CANON_ENTRY: &str = "bodies:
|
||||
- make: Canon
|
||||
model: EOS 6D
|
||||
points: [[0.0, 0.0], [0.25, 0.42], [0.5, 0.7], [0.75, 0.9], [1.0, 1.0]]
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn a_newer_pack_on_disk_supersedes_the_built_in_database() {
|
||||
// **This is the requirement.** FR-DEV-3e asks for a profile database
|
||||
// versioned independently of the app binary "so bodies and curves can
|
||||
// be added without a release". A file with a higher version, dropped
|
||||
// in the profile directory, is what that means in practice.
|
||||
let built_in = Curves::parse(BUILT_IN).expect("parses");
|
||||
let newer = format!("version: {}\n{A_CANON_ENTRY}", built_in.version() + 1);
|
||||
let dir = a_pack_dir("newer", &newer);
|
||||
|
||||
let chosen = choose(built_in.clone(), &[dir]);
|
||||
assert_eq!(chosen.version(), built_in.version() + 1);
|
||||
assert_eq!(chosen.body("Canon", "EOS 6D").ys[1], 0.42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_pack_does_not_survive_an_upgrade() {
|
||||
// The other direction, and the one that protects the user. Somebody
|
||||
// downloads a pack, a release later ships better curves for the same
|
||||
// bodies, and the forgotten file must not quietly hold the application
|
||||
// back at last year's rendering.
|
||||
let built_in = Curves::parse(BUILT_IN).expect("parses");
|
||||
let stale = format!("version: {}\n{A_CANON_ENTRY}", built_in.version());
|
||||
let dir = a_pack_dir("stale", &stale);
|
||||
|
||||
let chosen = choose(built_in.clone(), &[dir]);
|
||||
assert_eq!(chosen.version(), built_in.version());
|
||||
assert_ne!(
|
||||
chosen.body("Canon", "EOS 6D").ys[1],
|
||||
0.42,
|
||||
"an equal version must not displace the built-in database"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_pack_costs_the_curves_and_not_the_photographs() {
|
||||
// A malformed profile file must degrade to the built-in database, not
|
||||
// to an error. The user came here to look at a photograph.
|
||||
let built_in = Curves::parse(BUILT_IN).expect("parses");
|
||||
let dir = a_pack_dir("broken", "version: [this is not a number\n");
|
||||
|
||||
let chosen = choose(built_in.clone(), &[dir]);
|
||||
assert_eq!(chosen.version(), built_in.version());
|
||||
assert_eq!(chosen.len(), built_in.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_directory_with_no_pack_in_it_is_simply_skipped() {
|
||||
// The ordinary case on every machine: the search path exists, the file
|
||||
// does not. It must not be a warning, an error, or a slow path.
|
||||
let built_in = Curves::parse(BUILT_IN).expect("parses");
|
||||
let missing = std::env::temp_dir().join("darkroom-base-curves-nothing-here");
|
||||
let _ = std::fs::remove_dir_all(&missing);
|
||||
|
||||
assert_eq!(choose(built_in.clone(), &[missing]), built_in);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_identity_is_recognised_as_doing_nothing() {
|
||||
assert!(BaseCurve::IDENTITY.is_identity());
|
||||
assert!(!Curves::parse(BUILT_IN)
|
||||
.expect("parses")
|
||||
.body("Canon", "EOS 6D")
|
||||
.is_identity());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+49
-27
@@ -16,14 +16,13 @@
|
||||
//! 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 base_curve;
|
||||
pub mod dcp;
|
||||
mod decoder;
|
||||
mod error;
|
||||
mod locate;
|
||||
mod preview;
|
||||
pub mod profile;
|
||||
|
||||
pub use base_curve::BaseCurve;
|
||||
pub use decoder::{default, Decoder, Rawler};
|
||||
pub use error::DecodeError;
|
||||
pub use locate::{
|
||||
@@ -125,19 +124,6 @@ pub struct RawImage {
|
||||
/// for the light the frame was shot under; see [`profile::CameraProfile`].
|
||||
pub color_matrix: Option<[f32; 9]>,
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The per-body rendering curve, the other half of the camera profile.
|
||||
///
|
||||
/// The matrix above decides what the colours *are*; this decides what the
|
||||
/// picture looks like. Carried on the decoded image rather than looked up
|
||||
/// downstream because this is the only point in the system that knows
|
||||
/// which body took the frame, and because it is not an edit: it belongs to
|
||||
/// the file in the same way the masked-photosite crop does, and must never
|
||||
/// reach a sidecar (FR-NC-9).
|
||||
///
|
||||
/// [`BaseCurve::IDENTITY`] for an unknown body with no default in the
|
||||
/// database, which renders exactly as this decoder did before profiles
|
||||
/// existed.
|
||||
pub base_curve: BaseCurve,
|
||||
/// The usable region of `data`, excluding masked and border photosites.
|
||||
pub crop: CropRect,
|
||||
/// TRACES: FR-MRG-3
|
||||
@@ -152,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,
|
||||
}
|
||||
@@ -549,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.
|
||||
///
|
||||
@@ -586,21 +598,30 @@ 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,
|
||||
profile.as_ref().map(|p| p.xyz_to_cam()).as_ref(),
|
||||
);
|
||||
|
||||
// The rendering half of the profile (FR-DEV-3e). rawler's cleaned strings
|
||||
// are preferred where it has them — they are what the shipped database is
|
||||
// written against — and the matching folds the variants either way, so a
|
||||
// DNG naming the same body differently still finds its curve.
|
||||
let base_curve = base_curve::for_body(
|
||||
image.camera.clean_make.as_str(),
|
||||
image.camera.clean_model.as_str(),
|
||||
);
|
||||
|
||||
// TRACES: FR-MRG-3
|
||||
// A linear DNG — three samples per pixel, no colour filter array — is a
|
||||
// composite this application wrote (or any other demosaiced DNG). It
|
||||
@@ -683,9 +704,10 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
|
||||
.unwrap_or(u16::MAX),
|
||||
wb_coeffs,
|
||||
color_matrix,
|
||||
base_curve,
|
||||
samples_per_pixel,
|
||||
profile,
|
||||
profile_tables,
|
||||
baseline_exposure,
|
||||
make: image.camera.clean_make.clone(),
|
||||
model: image.camera.clean_model.clone(),
|
||||
})
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
//! colour needs two things the file cannot supply on its own: a **matrix**
|
||||
//! saying how this sensor's three responses relate to the CIE observer, and a
|
||||
//! **rendering** saying what to do with the resulting scene-referred values so
|
||||
//! that a photograph looks like a photograph. This module supplies the first
|
||||
//! and looks up the second ([`crate::base_curve`]).
|
||||
//! that a photograph looks like a photograph. This module supplies the first.
|
||||
//! The second is not the body's: since D19 it is the pipeline's view
|
||||
//! transform (FR-DEV-3j), one for every camera, and the per-body base curves
|
||||
//! that used to be looked up here are retired.
|
||||
//!
|
||||
//! # What is extracted, and from where
|
||||
//!
|
||||
@@ -65,8 +67,8 @@
|
||||
//! FR-DEV-3e defers full `.dcp` support — `HueSatDeltas` and
|
||||
//! `ProfileLookTable` — and requires that they arrive as *additions* rather
|
||||
//! than as a pipeline reordering. They would: both are lookups applied to a
|
||||
//! colour after this matrix and before, or alongside, the base curve, so they
|
||||
//! extend [`CameraProfile`] with more calibration data and extend the shader's
|
||||
//! colour at this matrix, before any edit reaches it, so they extend
|
||||
//! [`CameraProfile`] with more calibration data and extend the shader's
|
||||
//! camera-profile stage with more work. Nothing above would move.
|
||||
|
||||
use crate::{cam_to_srgb_from, invert3};
|
||||
@@ -519,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.
|
||||
@@ -738,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}
|
||||
@@ -1,9 +1,8 @@
|
||||
# Film stocks
|
||||
|
||||
One file per stock in [`profiles/`](profiles/). Adding a stock is adding a
|
||||
file — no code change, no shader, no new operation — for the same reason
|
||||
`dr-decode`'s base curves work that way: under the GPLv3 a stock should be
|
||||
contributable without a release.
|
||||
file — no code change, no shader, no new operation — because under the GPLv3
|
||||
a stock should be contributable without a release.
|
||||
|
||||
## What a profile is
|
||||
|
||||
@@ -51,6 +50,13 @@ matters — see [`src/bake.rs`](src/bake.rs) for the argument:
|
||||
log exposure through the negative, the enlarger's exposure is added there,
|
||||
and the paper's own curve row and cube take it to linear sRGB.
|
||||
|
||||
The stock is the last thing that happens to the picture. It runs in the view
|
||||
transform's place (D19): handed linear sRGB, scene-referred, after every other
|
||||
adjustment and after sharpening and noise reduction, and handing back the
|
||||
rendering the output transform encodes. So every other slider decides the
|
||||
exposure the negative receives, and the default tone mapping is not applied
|
||||
on top.
|
||||
|
||||
Per pixel that is a matrix multiply, a handful of curve taps and one texture
|
||||
fetch — two for a print. Splitting 2 from 3, rather than baking one LUT over exposure, is measured
|
||||
rather than assumed: the curve carries all the sharp shape and the dye mixing
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//!
|
||||
//! # Why it is data
|
||||
//!
|
||||
//! The same argument `dr_decode::base_curve` makes for camera bodies, and for
|
||||
//! the same requirement: under the GPLv3 a stock should be contributable
|
||||
//! without a release. A profile is three tables and a handful of facts, all of
|
||||
//! Under the GPLv3 a stock should be contributable without a release (the
|
||||
//! argument the retired per-body base curves made for camera bodies, before
|
||||
//! D19). A profile is three tables and a handful of facts, all of
|
||||
//! them published in the manufacturer's datasheet, so adding a stock is adding
|
||||
//! a file — not a code change, not a shader, and not a new operation.
|
||||
//!
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -182,8 +182,7 @@ fn render_to(
|
||||
// and the example never has to know which it was handed.
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale(image.size(), (w, h));
|
||||
let detail =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
adjust
|
||||
.render_detailed(image, &shader, w, h, None, &detail, key)
|
||||
|
||||
+260
-76
@@ -34,16 +34,6 @@ use crate::{DemosaicedImage, GpuContext, GpuError};
|
||||
/// reads them.
|
||||
const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS;
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The two crates must agree on how many points a base curve has.
|
||||
///
|
||||
/// `dr-decode` reads them from the profile database and `dr-pipeline` declares
|
||||
/// the uniform slots; this file is the only place the two meet, and it packs
|
||||
/// them by index. A disagreement would not fail to compile — it would upload a
|
||||
/// curve with a point missing or a stale float in it, which renders as a
|
||||
/// plausible-looking wrong tone response. Cheaper to catch here, at build time.
|
||||
const _: () = assert!(dr_decode::base_curve::POINTS == dr_pipeline::BASE_CURVE_POINTS);
|
||||
|
||||
/// Runs composed operation chains against demosaiced images.
|
||||
pub struct AdjustPass {
|
||||
ctx: GpuContext,
|
||||
@@ -81,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
|
||||
@@ -132,6 +130,8 @@ pub struct AdjustPass {
|
||||
colour_dispatches: usize,
|
||||
/// Detail dispatches encoded.
|
||||
detail_dispatches: usize,
|
||||
/// View passes encoded — one per render with a detail stage (D19).
|
||||
view_dispatches: usize,
|
||||
}
|
||||
|
||||
struct Target {
|
||||
@@ -520,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()
|
||||
@@ -653,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,
|
||||
@@ -663,6 +696,7 @@ impl AdjustPass {
|
||||
sample: SampleCache::new(ctx),
|
||||
colour_dispatches: 0,
|
||||
detail_dispatches: 0,
|
||||
view_dispatches: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,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,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -980,6 +1025,7 @@ impl AdjustPass {
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
|
||||
let profile = self.profile_buffer(source);
|
||||
let pipeline = self
|
||||
.cache
|
||||
.get(&shader.structure_hash)
|
||||
@@ -1027,6 +1073,10 @@ impl AdjustPass {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&sample_out),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: profile.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1058,13 +1108,14 @@ impl AdjustPass {
|
||||
/// Render one frame with a neighbourhood stage.
|
||||
///
|
||||
/// `shader` and `detail` must be the two halves of **one** composition —
|
||||
/// `EditGraph::compose_for` and `EditGraph::compose_detail_for` on the same
|
||||
/// graph, at the same output space. The fused pass stops at linear working
|
||||
/// values when a detail stage exists and the last detail pass performs the
|
||||
/// output transform, so a mismatched pair either encodes twice or not at
|
||||
/// all.
|
||||
/// `EditGraph::compose_for` and `EditGraph::compose_detail` on the same
|
||||
/// graph. The fused pass stops at linear working values when a detail
|
||||
/// stage exists, and its view pass ([`ComposedShader::view`]) performs the
|
||||
/// view transform and the output transform after the last detail pass
|
||||
/// (D19), so a mismatched pair either encodes twice or not at all.
|
||||
///
|
||||
/// An empty `detail` falls through to [`Self::render_masked`], which is
|
||||
/// An encoded shader with an empty `detail` falls through to
|
||||
/// [`Self::render_masked`], which is
|
||||
/// the honest thing to do rather than an optimisation: an edit with no
|
||||
/// active sharpening *is* an ordinary edit, and it should cost exactly
|
||||
/// what one costs.
|
||||
@@ -1104,17 +1155,25 @@ impl AdjustPass {
|
||||
detail: &ComposedDetail,
|
||||
colour_key: u64,
|
||||
) -> Result<&wgpu::Texture, GpuError> {
|
||||
if detail.is_empty() {
|
||||
// An edit with no detail stage encodes in the fused pass, and is an
|
||||
// ordinary render. Decided from the shader rather than from the chain:
|
||||
// an active kernel too fine for this render emits no pass, and its
|
||||
// fused pass has still stopped at linear values for the view pass.
|
||||
if shader.output_mode == OutputMode::Encoded && detail.is_empty() {
|
||||
return self.render_masked(source, shader, width, height, masks);
|
||||
}
|
||||
if shader.output_mode != OutputMode::LinearWorking {
|
||||
return Err(GpuError::ShaderCompilation(
|
||||
"this detail chain expects a fused pass composed to hand on \
|
||||
linear working values, but the shader given encodes its own \
|
||||
output; compose both halves from the same graph"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
let view = match (shader.output_mode, shader.view.as_deref()) {
|
||||
(OutputMode::LinearWorking, Some(view)) => view,
|
||||
_ => {
|
||||
return Err(GpuError::ShaderCompilation(
|
||||
"this detail chain expects a fused pass composed to hand on \
|
||||
linear working values, with its view pass, but the shader \
|
||||
given encodes its own output; compose both halves from the \
|
||||
same graph"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let (width, height) = (width.max(1), height.max(1));
|
||||
self.ensure_target(width, height);
|
||||
@@ -1127,6 +1186,7 @@ impl AdjustPass {
|
||||
// both want `&mut self`, and the second holds its borrow across the
|
||||
// encode below.
|
||||
self.pipeline(shader)?;
|
||||
self.pipeline(view)?;
|
||||
let colour_view = self
|
||||
.detail
|
||||
.colour_target(detail.len(), width, height)
|
||||
@@ -1136,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
|
||||
@@ -1199,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
|
||||
@@ -1217,20 +1282,12 @@ impl AdjustPass {
|
||||
self.colour_dispatches += 1;
|
||||
}
|
||||
|
||||
// One encoder for the colour pass and every detail pass, submitted
|
||||
// once — the shape `MaskPass::render` established. Submission order is
|
||||
// the whole of the synchronisation: each pass reads what the previous
|
||||
// one wrote, through the same queue.
|
||||
let target_view = self.targets[self.current]
|
||||
.as_ref()
|
||||
.expect("ensured above")
|
||||
.view
|
||||
.clone();
|
||||
let ran = match self
|
||||
.detail
|
||||
.encode(&mut enc, detail, &target_view, width, height)
|
||||
{
|
||||
Ok(ran) => ran,
|
||||
// One encoder for the colour pass, every detail pass and the view pass,
|
||||
// submitted once — the shape `MaskPass::render` established.
|
||||
// Submission order is the whole of the synchronisation: each pass
|
||||
// reads what the previous one wrote, through the same queue.
|
||||
let (ran, result) = match self.detail.encode(&mut enc, detail, width, height) {
|
||||
Ok(done) => done,
|
||||
Err(e) => {
|
||||
// Nothing is submitted, so a cache this frame was to write
|
||||
// holds nothing, and must not be read as though it did.
|
||||
@@ -1240,6 +1297,90 @@ impl AdjustPass {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// TRACES: FR-DEV-3j
|
||||
// The view pass: the view transform and the output transform, after
|
||||
// every kernel (D19). Its own uniform block, filled from the source
|
||||
// like the fused pass's — it reads the non-linear flag and the film
|
||||
// settings there — with the sample cache off, because the colour it
|
||||
// reads is the detail stage's result, bound where the cache would be.
|
||||
let view_uniforms = Self::fused_uniforms(source, view);
|
||||
let view_params = self
|
||||
.ctx
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("adjust-view-params"),
|
||||
contents: bytemuck::cast_slice(&view_uniforms),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let (_, no_sample_out) = self.sample.views(SampleUse::Direct);
|
||||
let target_view = self.targets[self.current]
|
||||
.as_ref()
|
||||
.expect("ensured above")
|
||||
.view
|
||||
.clone();
|
||||
let view_bind_group = self
|
||||
.ctx
|
||||
.device
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("adjust-view-bg"),
|
||||
layout: &self.bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(source.view()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: view_params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::TextureView(&target_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(
|
||||
masks.map_or(&self.empty_masks, |m| m.view()),
|
||||
),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::TextureView(&film_curves),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: wgpu::BindingResource::TextureView(&film_lut),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(&result),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&no_sample_out),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 8,
|
||||
resource: profile.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
{
|
||||
let pipeline = self
|
||||
.cache
|
||||
.get(&view.structure_hash)
|
||||
.expect("compiled above");
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("adjust-view-pass"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, &view_bind_group, &[]);
|
||||
pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
|
||||
}
|
||||
self.view_dispatches += 1;
|
||||
|
||||
self.ctx.queue.submit(Some(enc.finish()));
|
||||
self.detail_dispatches += ran;
|
||||
self.colour_key = Some((key, width, height));
|
||||
@@ -1275,22 +1416,13 @@ impl AdjustPass {
|
||||
// runs. See `DemosaicedImage::is_non_linear`.
|
||||
let non_linear = if source.is_non_linear() { 1.0 } else { 0.0 };
|
||||
uniforms[12..16].copy_from_slice(&[wb[0], wb[1], wb[2], non_linear]);
|
||||
// TRACES: FR-DEV-3e
|
||||
// The camera profile's base curve, packed the way the generated block
|
||||
// declares it: four x, four y, then the fifth point and the flag. The
|
||||
// flag is what lets one compiled shader serve a profiled body and an
|
||||
// unprofiled one, so the pipeline cache is not split in two by which
|
||||
// camera took the frame.
|
||||
//
|
||||
// Written here rather than at the call site so that *both* callers —
|
||||
// the plain render and the masked one — carry the profile. Filling it
|
||||
// at one of them was how the two halves of this merge each had it.
|
||||
let curve = source.base_curve();
|
||||
let on = if curve.is_identity() { 0.0 } else { 1.0 };
|
||||
let b = dr_pipeline::BASE_CURVE_UNIFORM_OFFSET;
|
||||
uniforms[b..b + 4].copy_from_slice(&curve.xs[0..4]);
|
||||
uniforms[b + 4..b + 8].copy_from_slice(&curve.ys[0..4]);
|
||||
uniforms[b + 8..b + 12].copy_from_slice(&[curve.xs[4], curve.ys[4], on, 0.0]);
|
||||
// TRACES: FR-DSP-2 | NFR-RES-2
|
||||
// Which part of the photograph the texture holds. The whole of it for
|
||||
// every source that fits in one texture, which writes back exactly
|
||||
// what the composer put there.
|
||||
let w = dr_pipeline::SOURCE_WINDOW_UNIFORM_OFFSET;
|
||||
uniforms[w..w + dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS]
|
||||
.copy_from_slice(&source.window_uniforms());
|
||||
uniforms
|
||||
}
|
||||
|
||||
@@ -1404,6 +1536,13 @@ impl AdjustPass {
|
||||
self.detail_dispatches
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3j
|
||||
/// View passes encoded since this pass was created: one for every render
|
||||
/// that had a detail stage, since the view transform follows it.
|
||||
pub fn view_dispatches(&self) -> usize {
|
||||
self.view_dispatches
|
||||
}
|
||||
|
||||
/// How many linear intermediates have been allocated. For tests: see
|
||||
/// [`crate::MaskPass::allocations`] for the regression this catches.
|
||||
pub fn detail_allocations(&self) -> usize {
|
||||
@@ -1447,9 +1586,9 @@ impl AdjustPass {
|
||||
/// one: the storage format is in the layout. The profile uniforms are
|
||||
/// filled neutral here rather than from the source, which is the whole
|
||||
/// point of the mode (`OutputMode::CameraLinear`): unit white balance,
|
||||
/// identity matrix, base curve off. The non-linear flag is kept, so a
|
||||
/// JPEG source is still linearised — camera space for a JPEG is the
|
||||
/// decoded values made linear, which is the best that exists.
|
||||
/// identity matrix, and no view transform composed. The non-linear flag
|
||||
/// is kept, so a JPEG source is still linearised — camera space for a
|
||||
/// JPEG is the decoded values made linear, which is the best that exists.
|
||||
///
|
||||
/// The texture stays on the device for a merge's warp to sample; see
|
||||
/// [`Self::camera_texture`] and [`Self::read_camera_linear`].
|
||||
@@ -1478,8 +1617,6 @@ impl AdjustPass {
|
||||
uniforms[4..8].copy_from_slice(&[0.0, 1.0, 0.0, 0.0]);
|
||||
uniforms[8..12].copy_from_slice(&[0.0, 0.0, 1.0, 0.0]);
|
||||
uniforms[12..16].copy_from_slice(&[1.0, 1.0, 1.0, non_linear]);
|
||||
let b = dr_pipeline::BASE_CURVE_UNIFORM_OFFSET;
|
||||
uniforms[b + 10] = 0.0;
|
||||
|
||||
let params_buf = self
|
||||
.ctx
|
||||
@@ -1538,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(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1707,7 +1848,7 @@ pub(crate) fn numbered(src: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
|
||||
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||
use dr_pipeline::ops::{colour_mixer, exposure, saturation};
|
||||
use dr_pipeline::EditGraph;
|
||||
// For `Operation::detail`, which is how `the_whole_chain_at_once_compiles`
|
||||
@@ -1745,9 +1886,10 @@ mod tests {
|
||||
// Identity, so the test reasons about the operations alone
|
||||
// rather than about a camera's colour response.
|
||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
baseline_exposure: 0.0,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
@@ -1949,9 +2091,10 @@ mod tests {
|
||||
white_level: 16383,
|
||||
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]),
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
baseline_exposure: 0.0,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
@@ -2381,7 +2524,7 @@ mod tests {
|
||||
// find those operations in neither stage and fail for a reason that is
|
||||
// not a defect. Shadows the smaller size deliberately.
|
||||
let (w, h) = g.output_size(512, 512);
|
||||
let detail = g.compose_detail_for((512, 512), (w, h), dr_types::ColourSpace::Srgb);
|
||||
let detail = g.compose_detail((512, 512), (w, h));
|
||||
assert!(
|
||||
!detail.is_empty(),
|
||||
"the detail half composed nothing, so nothing of it was compiled"
|
||||
@@ -2401,7 +2544,20 @@ mod tests {
|
||||
let mut fused_blocks = 0;
|
||||
for desc in g.descriptors() {
|
||||
let id = desc.id.0;
|
||||
let point = shader.source.contains(&format!("---- {id} ----"));
|
||||
// A stock is loaded here, and a stock is a rendering: the view
|
||||
// transform it replaces is correctly in neither half (FR-DEV-3j).
|
||||
if id == dr_pipeline::ops::view_transform::ID.0 {
|
||||
assert!(!shader.source.contains("---- view_transform ----"));
|
||||
continue;
|
||||
}
|
||||
// A view operation is in the view pass when a detail stage
|
||||
// follows, which it does here (D19).
|
||||
let block = format!("---- {id} ----");
|
||||
let point = shader.source.contains(&block)
|
||||
|| shader
|
||||
.view
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.source.contains(&block));
|
||||
let neighbourhood = detail
|
||||
.passes
|
||||
.iter()
|
||||
@@ -2435,8 +2591,15 @@ mod tests {
|
||||
// because the two catch different faults: the XOR catches an operation
|
||||
// in the wrong stage, this catches a block in the shader that nothing
|
||||
// in the chain asked for.
|
||||
//
|
||||
// The view pass repeats the prologue — framing and the warps — for
|
||||
// the positions it publishes, so only its operation blocks count.
|
||||
let view_blocks = shader
|
||||
.view
|
||||
.as_ref()
|
||||
.map_or(0, |v| v.source.matches("---- ").count() - (warp_blocks + 1));
|
||||
assert_eq!(
|
||||
shader.source.matches("---- ").count(),
|
||||
shader.source.matches("---- ").count() + view_blocks,
|
||||
fused_blocks + warp_blocks + 1,
|
||||
"the fused shader carries a block nothing in the chain asked for"
|
||||
);
|
||||
@@ -2538,9 +2701,10 @@ mod tests {
|
||||
white_level: 16383,
|
||||
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]),
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
baseline_exposure: 0.0,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
@@ -2642,9 +2806,10 @@ mod tests {
|
||||
white_level: 16383,
|
||||
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]),
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
baseline_exposure: 0.0,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
@@ -3227,11 +3392,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jpeg_and_sensor_data_agree_on_the_same_scene_value() {
|
||||
// The two producers must be interchangeable. A mid-grey that is
|
||||
// linearly 0.216 (sRGB 128) arriving as sensor data and as a JPEG
|
||||
// must render the same, or an edit would mean different things
|
||||
// depending on which decoder opened the file.
|
||||
fn a_jpeg_and_sensor_data_differ_by_exactly_the_view_transform() {
|
||||
// TRACES: FR-DEV-3j
|
||||
// The two producers must be interchangeable up to the rendering. A
|
||||
// mid-grey that is linearly 0.216 (sRGB 128) arriving as sensor data
|
||||
// is scene-referred and goes through the view transform; arriving as
|
||||
// a JPEG it is already a rendering and must come out as it went in.
|
||||
// Before D19 the fixture's identity base curve made both unrendered
|
||||
// and this asserted they matched; what it guards is unchanged — the
|
||||
// linearisation of each agrees — but the rendering between them is
|
||||
// now always there for sensor data.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let shader = EditGraph::default_chain().compose();
|
||||
@@ -3250,11 +3420,25 @@ mod tests {
|
||||
read_centre(&ctx, t)
|
||||
};
|
||||
|
||||
let delta = (i32::from(from_sensor[0]) - i32::from(from_jpeg[0])).abs();
|
||||
// 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::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!(
|
||||
delta <= 3,
|
||||
"the same scene value rendered {from_sensor:?} from sensor data \
|
||||
and {from_jpeg:?} from a JPEG"
|
||||
"sensor data rendered {from_sensor:?}, expected about {expected}"
|
||||
);
|
||||
let delta = (i32::from(from_jpeg[0]) - 128).abs();
|
||||
assert!(
|
||||
delta <= 3,
|
||||
"a JPEG was rendered again: {from_jpeg:?} from sRGB 128"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+448
-104
@@ -10,7 +10,7 @@
|
||||
//! pass over this texture; it does not re-demosaic, which is what keeps the
|
||||
//! interaction budget (NFR-P9) reachable on a 24 MP file.
|
||||
|
||||
use dr_decode::{BaseCurve, CfaPattern, RawImage};
|
||||
use dr_decode::{CfaPattern, RawImage};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::{GpuContext, GpuError};
|
||||
@@ -107,25 +107,32 @@ 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],
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The camera profile's rendering curve, carried through for the adjust
|
||||
/// pass exactly as `color_matrix` is.
|
||||
///
|
||||
/// It rides on the image rather than on the edit graph because it is not
|
||||
/// an edit: it belongs to the body that took the frame, the way the
|
||||
/// masked-photosite crop and the EXIF orientation do, and a sidecar shared
|
||||
/// between two bodies must never carry one body's rendering onto the
|
||||
/// other's file (FR-NC-9).
|
||||
base_curve: BaseCurve,
|
||||
/// Whether the texture holds gamma-encoded rather than linear values.
|
||||
non_linear: bool,
|
||||
/// Which upload this is, unique for the life of the process. See
|
||||
/// [`Self::id`].
|
||||
id: u64,
|
||||
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||||
/// The whole frame's size in pixels — what [`Self::size`] reports.
|
||||
/// The texture's own size when it holds the whole frame at full
|
||||
/// resolution, which is every photograph that fits in one.
|
||||
frame: (u32, u32),
|
||||
/// Which part of the frame the texture holds, as origin and extent in
|
||||
/// normalised frame coordinates. `[0, 0, 1, 1]` for the whole frame,
|
||||
/// reduced or not. See [`Self::window_uniforms`].
|
||||
window: [f32; 4],
|
||||
}
|
||||
|
||||
/// The window of a texture that holds the whole frame.
|
||||
const WHOLE_FRAME: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
|
||||
|
||||
/// The next [`DemosaicedImage::id`].
|
||||
fn next_image_id() -> u64 {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -143,10 +150,50 @@ impl DemosaicedImage {
|
||||
&self.view
|
||||
}
|
||||
|
||||
/// The size of the photograph this stands for, in its own pixels.
|
||||
///
|
||||
/// **Not necessarily the texture's.** For a photograph larger than one
|
||||
/// texture this is a reduced copy of it or a window cut from it, and
|
||||
/// everything that sizes a render, a crop or a kernel has to go on
|
||||
/// measuring the photograph. What indexes the texture's texels asks
|
||||
/// [`Self::texture_size`] instead.
|
||||
pub fn size(&self) -> (u32, u32) {
|
||||
self.frame
|
||||
}
|
||||
|
||||
/// The texture's own size in texels.
|
||||
pub fn texture_size(&self) -> (u32, u32) {
|
||||
(self.width, self.height)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||||
/// The source window uniforms the fused shader reads, in the order
|
||||
/// `dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS` declares them.
|
||||
///
|
||||
/// The second `vec4` is zero for a texture that holds the whole frame at
|
||||
/// full resolution, so the shader measures the texture itself exactly as
|
||||
/// it did before windows existed.
|
||||
pub fn window_uniforms(&self) -> [f32; dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS] {
|
||||
let [x, y, w, h] = self.window;
|
||||
let (fw, fh) = if self.is_whole() {
|
||||
(0.0, 0.0)
|
||||
} else {
|
||||
(self.frame.0 as f32, self.frame.1 as f32)
|
||||
};
|
||||
[x, y, w, h, fw, fh, 0.0, 0.0]
|
||||
}
|
||||
|
||||
/// Whether the texture is the whole frame at full resolution.
|
||||
pub fn is_whole(&self) -> bool {
|
||||
self.window == WHOLE_FRAME && self.frame == (self.width, self.height)
|
||||
}
|
||||
|
||||
/// The window this texture holds, as origin and extent in normalised
|
||||
/// frame coordinates.
|
||||
pub fn window(&self) -> [f32; 4] {
|
||||
self.window
|
||||
}
|
||||
|
||||
/// Which texture this is, as a number that is never reused.
|
||||
///
|
||||
/// For a cache that has to know it is still looking at the same pixels
|
||||
@@ -166,12 +213,9 @@ impl DemosaicedImage {
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The camera profile's base curve, as five `(x, y)` points.
|
||||
///
|
||||
/// [`BaseCurve::IDENTITY`] where the body is unprofiled or the source was
|
||||
/// never raw, in which case the adjust pass skips the stage entirely.
|
||||
pub fn base_curve(&self) -> BaseCurve {
|
||||
self.base_curve
|
||||
/// 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.
|
||||
@@ -285,30 +329,157 @@ 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 had its
|
||||
// camera's base curve baked in by the camera; applying one again
|
||||
// would render the rendering, crushing the shadows and flattening
|
||||
// the highlights of an image that was already finished.
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
// rather than resolved further down.** A JPEG has already been
|
||||
// rendered by the camera; the view transform skips a source
|
||||
// flagged non-linear, since rendering the rendering would crush
|
||||
// the shadows and flatten the highlights of an image that was
|
||||
// already finished.
|
||||
non_linear: true,
|
||||
id: next_image_id(),
|
||||
frame: (width, height),
|
||||
window: WHOLE_FRAME,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
/// file's black and white levels exactly as the demosaic kernel would
|
||||
/// normalise a photosite, and everything else — the matrix, the
|
||||
/// balance, the body's base curve — is carried through as for a CFA
|
||||
/// balance, the view transform — is carried through as for a CFA
|
||||
/// file, because the composite is developed as one photograph from the
|
||||
/// body that took its sources.
|
||||
pub fn from_linear_rgb16(ctx: &GpuContext, raw: &RawImage) -> Result<Self, GpuError> {
|
||||
let (width, height) = (raw.crop.width.max(1), raw.crop.height.max(1));
|
||||
Self::linear_rgb16_window(ctx, raw, [0, 0, width, height], 1)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||||
/// Part of a linear DNG, or a reduced copy of it, for a photograph too
|
||||
/// large to hold in one texture.
|
||||
///
|
||||
/// `region` is `[x, y, width, height]` in pixels of the frame (the
|
||||
/// file's crop), clamped to it. `reduce` averages `reduce × reduce`
|
||||
/// blocks into one texel — a box filter, which is what a reduced copy
|
||||
/// that is only ever displayed smaller than itself needs, and which keeps
|
||||
/// the samples in scene-linear light where an average means something.
|
||||
///
|
||||
/// The texture then knows where it sits ([`Self::window`]) and how large
|
||||
/// the photograph is ([`Self::size`]), and the fused shader maps each
|
||||
/// output pixel's position in the *photograph* into it. So a crop, a
|
||||
/// rotation or a mask drawn on the reduced copy lands on the same pixels
|
||||
/// of a full-resolution window, and an export in tiles is the same
|
||||
/// picture as one that fitted.
|
||||
///
|
||||
/// Refused only if the result itself does not fit the device.
|
||||
pub fn linear_rgb16_window(
|
||||
ctx: &GpuContext,
|
||||
raw: &RawImage,
|
||||
region: [u32; 4],
|
||||
reduce: u32,
|
||||
) -> Result<Self, GpuError> {
|
||||
let frame = (raw.crop.width.max(1), raw.crop.height.max(1));
|
||||
let k = reduce.max(1);
|
||||
let x0 = region[0].min(frame.0 - 1);
|
||||
let y0 = region[1].min(frame.1 - 1);
|
||||
let rw = region[2].clamp(1, frame.0 - x0);
|
||||
let rh = region[3].clamp(1, frame.1 - y0);
|
||||
let (width, height) = (rw.div_ceil(k), rh.div_ceil(k));
|
||||
|
||||
let limits = ctx.device.limits();
|
||||
if width > limits.max_texture_dimension_2d || height > limits.max_texture_dimension_2d {
|
||||
return Err(GpuError::TooLarge(format!(
|
||||
@@ -328,19 +499,49 @@ impl DemosaicedImage {
|
||||
}
|
||||
let black = black_per_cell(raw);
|
||||
let inv = inv_range_per_cell(raw);
|
||||
// Per channel rather than per CFA cell: R, G, B are the first three.
|
||||
let mut half: Vec<u16> = Vec::with_capacity((width * height * 4) as usize);
|
||||
for y in 0..height as usize {
|
||||
let row = (raw.crop.y as usize + y) * stride + raw.crop.x as usize * 3;
|
||||
for x in 0..width as usize {
|
||||
let p = &raw.data[row + x * 3..row + x * 3 + 3];
|
||||
for c in 0..3 {
|
||||
let v = (f32::from(p[c]) - black[c]) * inv[c];
|
||||
half.push(f32_to_f16_bits_unclamped(v));
|
||||
|
||||
// One output row per task: a 200-megapixel reduction is a second of
|
||||
// one core, and the rows are independent.
|
||||
let row_texels = width as usize * 4;
|
||||
let mut half = vec![0u16; row_texels * height as usize];
|
||||
let fill_row = |ty: usize, out: &mut [u16]| {
|
||||
let sy0 = y0 as usize + ty * k as usize;
|
||||
let sy1 = (sy0 + k as usize).min((y0 + rh) as usize);
|
||||
for tx in 0..width as usize {
|
||||
let sx0 = x0 as usize + tx * k as usize;
|
||||
let sx1 = (sx0 + k as usize).min((x0 + rw) as usize);
|
||||
let mut acc = [0f32; 3];
|
||||
for sy in sy0..sy1 {
|
||||
let row = (raw.crop.y as usize + sy) * stride + raw.crop.x as usize * 3;
|
||||
for sx in sx0..sx1 {
|
||||
let p = &raw.data[row + sx * 3..row + sx * 3 + 3];
|
||||
for c in 0..3 {
|
||||
acc[c] += f32::from(p[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
half.push(f32_to_f16_bits(1.0));
|
||||
let n = ((sy1 - sy0) * (sx1 - sx0)).max(1) as f32;
|
||||
let texel = &mut out[tx * 4..tx * 4 + 4];
|
||||
for c in 0..3 {
|
||||
let v = (acc[c] / n - black[c]) * inv[c];
|
||||
texel[c] = f32_to_f16_bits_unclamped(v);
|
||||
}
|
||||
texel[3] = f32_to_f16_bits(1.0);
|
||||
}
|
||||
}
|
||||
};
|
||||
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||
let rows_per = (height as usize).div_ceil(threads).max(1);
|
||||
std::thread::scope(|scope| {
|
||||
for (chunk, rows) in half.chunks_mut(rows_per * row_texels).enumerate() {
|
||||
let fill_row = &fill_row;
|
||||
scope.spawn(move || {
|
||||
for (i, out) in rows.chunks_mut(row_texels).enumerate() {
|
||||
fill_row(chunk * rows_per + i, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let texture = ctx.device.create_texture_with_data(
|
||||
&ctx.queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
@@ -361,20 +562,50 @@ impl DemosaicedImage {
|
||||
bytemuck::cast_slice(&half),
|
||||
);
|
||||
let view = texture.create_view(&Default::default());
|
||||
// The extent is the texels' own, `width × k`, not the region's: the
|
||||
// last block of a reduction may run past the frame's edge, and
|
||||
// stretching it to fit would put every texel slightly off the
|
||||
// pixels it averaged. The shader's bounds test is on the frame, so
|
||||
// nothing past the edge is ever read.
|
||||
let window = [
|
||||
x0 as f32 / frame.0 as f32,
|
||||
y0 as f32 / frame.1 as f32,
|
||||
(width * k) as f32 / frame.0 as f32,
|
||||
(height * k) as f32 / frame.1 as f32,
|
||||
];
|
||||
Ok(Self {
|
||||
texture,
|
||||
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]],
|
||||
base_curve: raw.base_curve,
|
||||
non_linear: false,
|
||||
id: next_image_id(),
|
||||
frame,
|
||||
window,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
@@ -636,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
|
||||
@@ -730,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,
|
||||
@@ -752,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"),
|
||||
@@ -779,21 +954,186 @@ 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
|
||||
// model are known.
|
||||
base_curve: raw.base_curve,
|
||||
// Sensor data is linear by construction — the demosaic shader
|
||||
// normalises against black and white levels and applies no
|
||||
// transfer function.
|
||||
non_linear: false,
|
||||
id: next_image_id(),
|
||||
frame: (width, height),
|
||||
window: WHOLE_FRAME,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -1284,9 +1624,10 @@ mod tests {
|
||||
white_level: white,
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: None,
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
baseline_exposure: 0.0,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
@@ -1400,9 +1741,10 @@ mod tests {
|
||||
white_level: white,
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: None,
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
baseline_exposure: 0.0,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
@@ -1697,9 +2039,10 @@ mod tests {
|
||||
white_level: 16383,
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: None,
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
baseline_exposure: 0.0,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
@@ -1782,9 +2125,10 @@ mod tests {
|
||||
1.0,
|
||||
],
|
||||
color_matrix: None,
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
baseline_exposure: 0.0,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
|
||||
+32
-46
@@ -43,12 +43,12 @@
|
||||
//! dispatch is skipped, and dragging a sharpening slider costs the detail
|
||||
//! passes alone (FR-DEV-3d).
|
||||
//!
|
||||
//! The remaining passes alternate between slots 1 and 2, and the last one
|
||||
//! writes the display texture directly rather than an intermediate — so a
|
||||
//! chain of *N* passes costs *N* dispatches and not *N* + 1, and there is no
|
||||
//! resolve pass to pay for. That leaves the allocation at `1 + min(N-1, 2)`
|
||||
//! textures: one for a single-pass operation, two for a separable blur, three
|
||||
//! however long the chain gets after that.
|
||||
//! The passes alternate between slots 1 and 2, the last one included: since
|
||||
//! D19 it hands its result to the adjust pass's **view pass**, which performs
|
||||
//! the view transform and the output transform after every kernel, so no
|
||||
//! detail pass writes the display texture. A chain of *N* passes costs *N*
|
||||
//! dispatches plus that one, and the allocation is `1 + min(N, 2)` textures.
|
||||
//! An empty chain costs the view pass alone, reading slot 0.
|
||||
//!
|
||||
//! # The reduced chain, and why a second one was needed
|
||||
//!
|
||||
@@ -186,10 +186,9 @@ impl Intermediates {
|
||||
/// intermediate against a fresh colour result and never be told.
|
||||
pub(crate) struct DetailRunner {
|
||||
ctx: GpuContext,
|
||||
/// Layout for a pass writing another linear intermediate.
|
||||
/// Layout for every pass: each writes a linear intermediate, the last
|
||||
/// one included, and the adjust pass's view pass reads the last (D19).
|
||||
to_linear: Layout,
|
||||
/// Layout for the last pass, which writes the display texture.
|
||||
to_output: Layout,
|
||||
/// Compiled pipelines by pass structure hash.
|
||||
cache: HashMap<u64, wgpu::ComputePipeline>,
|
||||
pool: Intermediates,
|
||||
@@ -255,7 +254,6 @@ impl DetailRunner {
|
||||
Self {
|
||||
ctx: ctx.clone(),
|
||||
to_linear: Layout::new(ctx, INTERMEDIATE_FORMAT, "detail-linear"),
|
||||
to_output: Layout::new(ctx, crate::AdjustPass::FORMAT, "detail-output"),
|
||||
cache: HashMap::new(),
|
||||
pool: Intermediates::new(),
|
||||
reduced: Intermediates::new(),
|
||||
@@ -274,27 +272,30 @@ impl DetailRunner {
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> &wgpu::TextureView {
|
||||
// One for the colour pass's result, then one per hand-off between
|
||||
// detail passes, capped at two because a ping-pong needs no more: the
|
||||
// last pass writes the display texture rather than an intermediate.
|
||||
let needed = 1 + passes.saturating_sub(1).min(2);
|
||||
// One for the colour pass's result, then one per pass, capped at two
|
||||
// because a ping-pong needs no more. The last pass writes an
|
||||
// intermediate like the others since D19 — the view pass reads it —
|
||||
// so a one-pass chain needs two slots where it used to need one.
|
||||
let needed = 1 + passes.min(2);
|
||||
self.pool.ensure(&self.ctx, needed, width, height);
|
||||
&self.pool.slots[0].view
|
||||
}
|
||||
|
||||
/// Encode every pass of `chain`, the last one writing `output`.
|
||||
/// Encode every pass of `chain`, and return how many ran and the view
|
||||
/// the last one wrote — slot 0, the colour pass's own result, for an
|
||||
/// empty chain.
|
||||
///
|
||||
/// The caller must already have run the fused colour pass into
|
||||
/// [`Self::colour_target`] — or established that a previous frame's is
|
||||
/// still valid, which is the whole point of keeping slot 0.
|
||||
/// still valid, which is the whole point of keeping slot 0 — and reads the
|
||||
/// returned view in the view pass that finishes the render (D19).
|
||||
pub(crate) fn encode(
|
||||
&mut self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
chain: &ComposedDetail,
|
||||
output: &wgpu::TextureView,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<usize, GpuError> {
|
||||
) -> Result<(usize, wgpu::TextureView), GpuError> {
|
||||
for pass in &chain.passes {
|
||||
self.compile(pass)?;
|
||||
}
|
||||
@@ -340,17 +341,15 @@ impl DetailRunner {
|
||||
for pass in chain.passes.iter() {
|
||||
let scaled = pass.output_scale > 1;
|
||||
|
||||
// The last pass carries the output transform into the display
|
||||
// texture, which is the render size by definition. A scaled pass
|
||||
// there would bind a shader dispatching over a quarter-size grid
|
||||
// to a full-size target and write a quarter of the picture — a
|
||||
// wrong image rather than a validation failure, so it is caught
|
||||
// here and named.
|
||||
if scaled && pass.writes_output {
|
||||
// The last pass hands the view pass its input, which is read at
|
||||
// the render size by definition. A scaled pass there would leave
|
||||
// the result in the reduced chain and the view pass would read the
|
||||
// full-size slot before it — a wrong image rather than a
|
||||
// validation failure, so it is caught here and named.
|
||||
if scaled && std::ptr::eq(pass, chain.passes.last().expect("iterating")) {
|
||||
return Err(GpuError::ShaderCompilation(format!(
|
||||
"detail pass {} declares output_scale {} and is last in \
|
||||
the chain; the output transform is written at the render \
|
||||
size",
|
||||
the chain; the view pass reads the render size",
|
||||
pass.label, pass.output_scale
|
||||
)));
|
||||
}
|
||||
@@ -365,17 +364,14 @@ impl DetailRunner {
|
||||
};
|
||||
|
||||
// Read what the previous pass in *this pass's own chain* wrote;
|
||||
// write the next slot of it, or the display texture if this is the
|
||||
// last pass. Alternating slots is what stops a pass reading the
|
||||
// write the next slot of it. Alternating slots is what stops a pass reading the
|
||||
// texture it is writing — on a compute pass that is not an error
|
||||
// the driver reports, merely a picture that depends on scheduling.
|
||||
let source = match (scaled, carried) {
|
||||
(true, Some(slot)) => &self.reduced.slots[slot].view,
|
||||
_ => &self.pool.slots[full].view,
|
||||
};
|
||||
let destination = if pass.writes_output {
|
||||
output
|
||||
} else if scaled {
|
||||
let destination = if scaled {
|
||||
&self.reduced.slots[reduced_writes % 2].view
|
||||
} else {
|
||||
&self.pool.slots[1 + (full_writes % 2)].view
|
||||
@@ -387,11 +383,7 @@ impl DetailRunner {
|
||||
Some(slot) => &self.reduced.slots[slot].view,
|
||||
None => &self.no_reduced,
|
||||
};
|
||||
let layout = if pass.writes_output {
|
||||
&self.to_output
|
||||
} else {
|
||||
&self.to_linear
|
||||
};
|
||||
let layout = &self.to_linear;
|
||||
|
||||
let params = self
|
||||
.ctx
|
||||
@@ -464,9 +456,7 @@ impl DetailRunner {
|
||||
compute.dispatch_workgroups(dispatch_w.div_ceil(8), dispatch_h.div_ceil(8), 1);
|
||||
drop(compute);
|
||||
|
||||
if pass.writes_output {
|
||||
// Nothing downstream to hand anything to.
|
||||
} else if scaled {
|
||||
if scaled {
|
||||
carried = Some(reduced_writes % 2);
|
||||
reduced_writes += 1;
|
||||
} else {
|
||||
@@ -480,7 +470,7 @@ impl DetailRunner {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(chain.passes.len())
|
||||
Ok((chain.passes.len(), self.pool.slots[full].view.clone()))
|
||||
}
|
||||
|
||||
/// Compile one pass, or leave the cached pipeline in place.
|
||||
@@ -508,11 +498,7 @@ impl DetailRunner {
|
||||
source: wgpu::ShaderSource::Wgsl(pass.source.as_str().into()),
|
||||
});
|
||||
|
||||
let layout = if pass.writes_output {
|
||||
&self.to_output
|
||||
} else {
|
||||
&self.to_linear
|
||||
};
|
||||
let layout = &self.to_linear;
|
||||
|
||||
let pipeline = self
|
||||
.ctx
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -928,7 +928,7 @@ impl MaskPass {
|
||||
// the only readers and they are skipped in that case.
|
||||
let source_step = match source {
|
||||
Some(image) => {
|
||||
let (sw, sh) = image.size();
|
||||
let (sw, sh) = image.texture_size();
|
||||
[
|
||||
sw as f32 / width.max(1) as f32,
|
||||
sh as f32 / height.max(1) as f32,
|
||||
|
||||
+122
-10
@@ -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,10 +78,19 @@ 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.
|
||||
pub sample_scale: f32,
|
||||
/// The white balance the composite will be developed with — the
|
||||
/// inverse of its `AsShotNeutral` — so that a blown sample can be
|
||||
/// written as the camera value that balance calls grey.
|
||||
pub balance: [f32; 3],
|
||||
}
|
||||
|
||||
impl MergeOutput {
|
||||
@@ -109,7 +127,14 @@ struct WarpParams {
|
||||
tile_origin: [f32; 2],
|
||||
tile_size: [u32; 2],
|
||||
feather: f32,
|
||||
_pad: 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)]
|
||||
@@ -177,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 =
|
||||
@@ -274,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);
|
||||
@@ -334,9 +395,21 @@ impl MergePass {
|
||||
tile_origin: [rect.0 as f32, rect.1 as f32],
|
||||
tile_size: [rect.2, rect.3],
|
||||
feather: output.feather,
|
||||
_pad: 0.0,
|
||||
clip_onset: dr_pipeline::CLIP_ONSET,
|
||||
balance: [
|
||||
output.balance[0].max(1e-3),
|
||||
output.balance[1].max(1e-3),
|
||||
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)?;
|
||||
@@ -374,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
|
||||
@@ -406,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());
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
//! than leaving the specification and the code silently disagreeing.
|
||||
//!
|
||||
//! That texture is the right one on the merits. It is camera-native: no white
|
||||
//! balance has been applied, no camera matrix, no base curve, no tone curve,
|
||||
//! no output transform. It is normalised by the sensor's own black and white
|
||||
//! balance has been applied, no camera matrix, no tone curve, no view
|
||||
//! transform, no output transform. It is normalised by the sensor's own black and white
|
||||
//! levels, so 1.0 is saturation by construction and the distribution below it
|
||||
//! *is* the headroom question, with no calibration to carry and no origin to
|
||||
//! choose.
|
||||
|
||||
@@ -208,7 +208,7 @@ impl SegmentPass {
|
||||
source: &DemosaicedImage,
|
||||
opts: SegmentOptions,
|
||||
) -> Result<Segmentation, GpuError> {
|
||||
let (src_w, src_h) = source.size();
|
||||
let (src_w, src_h) = source.texture_size();
|
||||
let (width, height) = proxy_size(src_w, src_h, opts.max_edge);
|
||||
let n = (width * height) as u64;
|
||||
|
||||
|
||||
@@ -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).
|
||||
//
|
||||
@@ -46,13 +47,87 @@ struct Params {
|
||||
tile_size: vec2<u32>,
|
||||
// Pixels over which the weight ramps from the edge to full.
|
||||
feather: f32,
|
||||
_pad: f32,
|
||||
// Where a sample starts to count as blown (`CLIP_ONSET`), and the
|
||||
// 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;
|
||||
@@ -95,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;
|
||||
@@ -125,7 +210,19 @@ fn warp(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
return;
|
||||
}
|
||||
// Colour is the alpha-weighted mean of the texels that exist.
|
||||
let rgb = s.rgb / s.a * p.gain;
|
||||
let cam = s.rgb / s.a;
|
||||
// **A blown sample is written as grey, before the gain.** A clipped
|
||||
// photosite arrives as (1, 1, 1), which is not a colour: balanced, it
|
||||
// is magenta, and the develop's highlight desaturation only rescues it
|
||||
// while it is still at the white level. A gain below one moved it off
|
||||
// that level, and a feather mixed it into a neighbour's real sky, so
|
||||
// the composite's blown clouds came out pink. Written instead as the
|
||||
// camera value the balance maps to grey — the develop pipeline's own
|
||||
// neutral, the brightest balanced channel — it survives both.
|
||||
let clipped = smoothstep(p.clip_onset, 1.0, max(cam.r, max(cam.g, cam.b)));
|
||||
let balanced = cam * p.balance.rgb;
|
||||
let grey = vec3<f32>(max(balanced.r, max(balanced.g, balanced.b))) / p.balance.rgb;
|
||||
let rgb = mix(cam, grey, clipped) * p.gain;
|
||||
let wa = w * s.a;
|
||||
let i = gid.y * p.chunk_size.x + gid.x;
|
||||
acc[i] = acc[i] + vec4<f32>(rgb * wa, wa);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// The shader beside this one, `histogram.wgsl`, counts the frame the display
|
||||
// is about to show: an 8-bit code value, after white balance, the camera
|
||||
// matrix, the base curve, the tone curve and the output transform. This one
|
||||
// matrix, the tone curve, the view transform and the output transform. This one
|
||||
// counts the texture the demosaic wrote, before any of that. The two differ in
|
||||
// exactly one place — the axis — and everything else here is deliberately the
|
||||
// same construction, because the two reductions have the same shape and any
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
//! TRACES: FR-DEV-3e
|
||||
//! The camera profile's base curve, end to end on a device.
|
||||
//!
|
||||
//! The unit tests either side of this one check halves. `dr-decode` asserts
|
||||
//! that the shipped database parses and that every curve in it lifts its
|
||||
//! midtones; `dr-pipeline` asserts that the generated WGSL evaluates a curve
|
||||
//! in the right place. Neither would notice if the two agreed with each other
|
||||
//! and both were wrong — a curve packed into the wrong uniform slots, or a
|
||||
//! flag read from the wrong component, satisfies both and renders nothing.
|
||||
//!
|
||||
//! So this renders real pixels twice, once with a profiled body's curve and
|
||||
//! once with the identity, and asserts the difference is the one a base curve
|
||||
//! is for: midtones lifted, black still black, white still white.
|
||||
|
||||
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
|
||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||||
use dr_pipeline::EditGraph;
|
||||
|
||||
const SIZE: u32 = 16;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
pollster::block_on(GpuContext::new_headless()).ok()
|
||||
}
|
||||
|
||||
/// A flat RGGB frame at `level` out of 65535, carrying `curve`.
|
||||
///
|
||||
/// Every photosite the same value, so the demosaic result is a uniform grey
|
||||
/// and the only thing that can move a pixel is the curve. The colour matrix is
|
||||
/// the identity and the balance is neutral for the same reason: this test is
|
||||
/// about one stage, and a real body's matrix would make every assertion below
|
||||
/// a statement about that body instead.
|
||||
fn flat_raw(level: u16, curve: BaseCurve) -> RawImage {
|
||||
RawImage {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
data: vec![level; (SIZE * SIZE) as usize],
|
||||
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]),
|
||||
base_curve: curve,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a neutral edit over a flat frame and return the centre pixel's red.
|
||||
///
|
||||
/// The centre rather than a corner: a demosaic has to invent its edges, and
|
||||
/// the interpolated border of a 16×16 frame is not where anyone should be
|
||||
/// reading a tone off.
|
||||
fn rendered_level(ctx: &GpuContext, level: u16, curve: BaseCurve) -> u8 {
|
||||
let raw = flat_raw(level, curve);
|
||||
let source = Demosaicer::new(ctx)
|
||||
.expect("demosaicer")
|
||||
.run(&raw)
|
||||
.expect("demosaic");
|
||||
let shader = EditGraph::default_chain().compose();
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
||||
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
|
||||
pixels[centre as usize]
|
||||
}
|
||||
|
||||
/// The Canon EOS 6D's curve, from the shipped profile database.
|
||||
///
|
||||
/// Looked up by name rather than written out, so this also asserts the thing
|
||||
/// no other test can: that a curve travels from the YAML, through the body
|
||||
/// match, onto the decoded image and into the uniform block that the shader
|
||||
/// actually reads.
|
||||
fn six_d() -> BaseCurve {
|
||||
let curve = dr_decode::base_curve::for_body("Canon", "EOS 6D");
|
||||
assert!(
|
||||
!curve.is_identity(),
|
||||
"the shipped database must have a curve for the EOS 6D"
|
||||
);
|
||||
curve
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profiled_body_renders_brighter_midtones_than_a_flat_one() {
|
||||
// **The whole requirement, in one assertion.** A linear midtone renders
|
||||
// roughly half a stop dark, which is the flat, lifeless look FR-DEV-3e
|
||||
// exists to get away from. If the curve did not reach the shader — wrong
|
||||
// slot, wrong flag, wrong stage — this is the only test that would fail.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
|
||||
// 13% of full scale: roughly where a camera places middle grey, leaving
|
||||
// about two and a half stops of highlight headroom above it.
|
||||
let level = (0.13 * 65535.0) as u16;
|
||||
let flat = rendered_level(&ctx, level, BaseCurve::IDENTITY);
|
||||
let profiled = rendered_level(&ctx, level, six_d());
|
||||
|
||||
assert!(
|
||||
profiled > flat + 8,
|
||||
"the profile lifted middle grey from {flat} only to {profiled}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_leaves_black_black_and_white_white() {
|
||||
// A base curve renders the range between the endpoints; it must not move
|
||||
// the endpoints themselves. A curve that lifted black would put a grey
|
||||
// veil over every night photograph, and one that pulled white down would
|
||||
// make a correctly exposed frame look underexposed.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
|
||||
let curve = six_d();
|
||||
assert_eq!(rendered_level(&ctx, 0, curve), 0, "black moved");
|
||||
assert_eq!(rendered_level(&ctx, u16::MAX, curve), 255, "white moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unprofiled_body_renders_exactly_as_it_did_before_profiles_existed() {
|
||||
// The graceful fallback, asserted as a number rather than as a promise.
|
||||
// With no curve the pipeline must still be a pass-through: black level
|
||||
// out, white level in, sRGB encoding on the way to the screen and nothing
|
||||
// else. "Never worse than today" is the one property this change was not
|
||||
// allowed to trade away, and the way it would break is silently — a flag
|
||||
// read from the wrong component would apply a curve nobody asked for.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
|
||||
for level in [0u16, 4_000, 8_520, 32_768, 60_000, u16::MAX] {
|
||||
let scene = f32::from(level) / f32::from(u16::MAX);
|
||||
let expected = (dr_types::Transfer::Srgb.encode(scene) * 255.0).round() as i32;
|
||||
let got = i32::from(rendered_level(&ctx, level, BaseCurve::IDENTITY));
|
||||
// Two 8-bit steps: the texture holding the demosaiced frame is
|
||||
// `Rgba16Float`, so a value round-trips through eleven mantissa bits
|
||||
// before it is encoded. That is well under one step at any level, and
|
||||
// the tolerance is for the rounding either side of it rather than for
|
||||
// the transform being approximate.
|
||||
assert!(
|
||||
(got - expected).abs() <= 2,
|
||||
"raw {level} rendered as {got}, expected about {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_is_monotone_through_the_whole_range() {
|
||||
// The property the spline's tangent limiting exists to guarantee, checked
|
||||
// where it actually matters: on the device, through the real uniform
|
||||
// packing. A curve that dipped anywhere would put a dark band across a
|
||||
// smooth gradient — a sky, most visibly — and it would read as a
|
||||
// rendering fault rather than as a bad profile.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
|
||||
let curve = six_d();
|
||||
let mut previous = 0u8;
|
||||
for step in 0..=16u32 {
|
||||
let level = (step * 65535 / 16) as u16;
|
||||
let value = rendered_level(&ctx, level, curve);
|
||||
assert!(
|
||||
value >= previous,
|
||||
"the curve fell from {previous} to {value} at raw level {level}"
|
||||
);
|
||||
previous = value;
|
||||
}
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -87,8 +87,7 @@ fn sharpened(amount: f32, radius: f32, threshold: f32) -> EditGraph {
|
||||
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale(source.size(), (out, out));
|
||||
let detail =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||
.expect("render");
|
||||
@@ -413,7 +412,7 @@ fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() {
|
||||
let pipelines = pass.cached_detail_pipelines();
|
||||
let allocations = pass.detail_allocations();
|
||||
assert_eq!(pipelines, 2, "one per axis of the separable mask");
|
||||
assert_eq!(allocations, 2, "the colour result, and one hand-off");
|
||||
assert_eq!(allocations, 3, "the colour result, and the ping-pong pair");
|
||||
assert_eq!(pass.detail_dispatches(), 2);
|
||||
assert_eq!(pass.colour_dispatches(), 1);
|
||||
|
||||
@@ -453,13 +452,12 @@ fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() {
|
||||
|
||||
#[test]
|
||||
fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() {
|
||||
// The failure mode that the pass-through exists to prevent, proved on a
|
||||
// device rather than argued about. With the radius finer than a render
|
||||
// pixel the operation declines to sharpen — but it is still active, so the
|
||||
// fused pass has already been composed to hand on unclipped linear values,
|
||||
// and something must still perform the output transform. An empty chain
|
||||
// here would not be a soft preview: it would be a hard error out of
|
||||
// `render_detailed`, on the most ordinary develop view there is.
|
||||
// Proved on a device rather than argued about. With the radius finer than
|
||||
// a render pixel the operation declines to sharpen — but it is still
|
||||
// active, so the fused pass has already been composed to hand on
|
||||
// unclipped linear values, and something must still perform the output
|
||||
// transform. Since D19 that is the view pass, whatever the chain holds:
|
||||
// the chain is empty and the frame is still whole.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
const SOURCE: u32 = 128;
|
||||
const RENDER: u32 = 32; // a quarter scale, as a fit view of a large frame
|
||||
@@ -471,7 +469,16 @@ fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() {
|
||||
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let sharp = render(&mut pass, &graph, &source, RENDER);
|
||||
assert_eq!(pass.detail_dispatches(), 1, "one pass, and it only encodes");
|
||||
assert_eq!(
|
||||
pass.detail_dispatches(),
|
||||
0,
|
||||
"nothing to sharpen at this scale"
|
||||
);
|
||||
assert_eq!(
|
||||
pass.view_dispatches(),
|
||||
1,
|
||||
"and the view pass finishes the frame"
|
||||
);
|
||||
|
||||
// And what reaches the screen is the unsharpened picture, not a black
|
||||
// frame, a linear one, or a guess.
|
||||
|
||||
@@ -38,15 +38,17 @@ fn grey(ctx: &GpuContext) -> DemosaicedImage {
|
||||
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
|
||||
}
|
||||
|
||||
/// A pass that sums the instance list into the red channel and writes the
|
||||
/// output. Deliberately trivial: the value on screen is then a direct readout
|
||||
/// of what arrived in the buffer.
|
||||
/// A pass that sums the instance list into the red channel and writes a
|
||||
/// linear intermediate, which the view pass then encodes (D19). Deliberately
|
||||
/// trivial: the value on screen is then a direct readout of what arrived in the
|
||||
/// buffer, through the sRGB encode — the source is an 8-bit upload, so the
|
||||
/// view transform is skipped for it and the encode is the only thing between.
|
||||
fn summing_pass(storage: Vec<[f32; 4]>, structure: u64) -> ComposedDetailPass {
|
||||
let source = "
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
struct Params { detail_base: vec4<f32> }
|
||||
@group(0) @binding(1) var<uniform> u: Params;
|
||||
@group(0) @binding(2) var output: texture_storage_2d<rgba8unorm, write>;
|
||||
@group(0) @binding(2) var output: texture_storage_2d<rgba16float, write>;
|
||||
@group(0) @binding(3) var<storage, read> instances: array<vec4<f32>>;
|
||||
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
@@ -61,7 +63,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
for (var i = 0u; i < n; i = i + 1u) {
|
||||
total = total + instances[i].x * f32(i + 1u);
|
||||
}
|
||||
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(total, f32(n) / 255.0, 0.0, 1.0));
|
||||
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(total, f32(n) * 0.1, 0.0, 1.0));
|
||||
}
|
||||
"
|
||||
.to_string();
|
||||
@@ -73,13 +75,17 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
uniforms: vec![SIZE as f32, SIZE as f32, 1.0, 0.0],
|
||||
storage,
|
||||
radius: 0,
|
||||
writes_output: true,
|
||||
// Any distinct number: the hash is a cache key, and these tests are
|
||||
// what decide whether two chains share a pipeline.
|
||||
structure_hash: structure,
|
||||
}
|
||||
}
|
||||
|
||||
/// A linear value as the view pass leaves it in the 8-bit output.
|
||||
fn encoded(linear: f32) -> u8 {
|
||||
(dr_types::Transfer::Srgb.encode(linear) * 255.0).round() as u8
|
||||
}
|
||||
|
||||
fn render(pass: &mut AdjustPass, source: &DemosaicedImage, chain: &ComposedDetail) -> Vec<u8> {
|
||||
// The fused half has to be composed knowing a detail stage follows it, or
|
||||
// it encodes its own output and the chain would quantise twice — a mismatch
|
||||
@@ -118,12 +124,15 @@ fn a_pass_reads_the_list_it_was_given() {
|
||||
let pixels = render(&mut pass, &source, &chain);
|
||||
let (red, green) = (pixels[0], pixels[1]);
|
||||
|
||||
// 0.05·1 + 0.1·2 = 0.25, written straight to an rgba8 target.
|
||||
// 0.05·1 + 0.1·2 = 0.25.
|
||||
assert!(
|
||||
red.abs_diff((0.25 * 255.0) as u8) <= 1,
|
||||
red.abs_diff(encoded(0.25)) <= 1,
|
||||
"the shader summed {red}, not the list it was handed"
|
||||
);
|
||||
assert_eq!(green, 2, "arrayLength saw both entries");
|
||||
assert!(
|
||||
green.abs_diff(encoded(0.2)) <= 1,
|
||||
"arrayLength saw both entries"
|
||||
);
|
||||
}
|
||||
|
||||
/// A convolution declares no list and must still run: it is bound to the
|
||||
@@ -142,7 +151,10 @@ fn a_pass_with_no_list_still_runs() {
|
||||
|
||||
let pixels = render(&mut pass, &source, &chain);
|
||||
assert_eq!(pixels[0], 0, "the placeholder is zeroed");
|
||||
assert_eq!(pixels[1], 1, "and is exactly one element long");
|
||||
assert!(
|
||||
pixels[1].abs_diff(encoded(0.1)) <= 1,
|
||||
"and is exactly one element long"
|
||||
);
|
||||
}
|
||||
|
||||
/// The property that makes placing the tenth spot as cheap as moving a slider:
|
||||
|
||||
@@ -101,8 +101,7 @@ fn render(
|
||||
let _ = ctx;
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale(source.size(), (out, out));
|
||||
let detail =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||
.expect("render");
|
||||
@@ -301,7 +300,7 @@ fn dragging_a_slider_recompiles_nothing_and_reallocates_nothing() {
|
||||
let pipelines = pass.cached_detail_pipelines();
|
||||
let allocations = pass.detail_allocations();
|
||||
assert_eq!(pipelines, 2, "one per pass of the separable blur");
|
||||
assert_eq!(allocations, 2, "the colour result, and one hand-off");
|
||||
assert_eq!(allocations, 3, "the colour result, and the ping-pong pair");
|
||||
|
||||
for radius in [0.06, 0.07, 0.08, 0.09] {
|
||||
graph.set_param(PROBE, RADIUS, radius);
|
||||
@@ -403,8 +402,7 @@ fn an_empty_chain_falls_through_to_the_ordinary_render() {
|
||||
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale((SIZE, SIZE), (SIZE, SIZE));
|
||||
let detail =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||
assert!(detail.is_empty());
|
||||
|
||||
pass.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, 0)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
//! model is checked against the reference, and the shader is checked against
|
||||
//! the CPU model.
|
||||
|
||||
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
|
||||
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||
use dr_film::bake::{bake, Recipe, Settings};
|
||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext, LabelField, MaskPass};
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource};
|
||||
@@ -48,9 +48,10 @@ fn flat_raw(level: u16) -> RawImage {
|
||||
// Off deliberately: a film replaces the camera's rendering, and
|
||||
// leaving a curve here would test the suppression rather than the
|
||||
// film. `dr-pipeline` asserts the suppression on the generated source.
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
//! anything: the repair happens on the mosaic, and what a photographer would
|
||||
//! see of a defect it missed is the coloured cross the demosaic makes of it.
|
||||
|
||||
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
|
||||
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||||
use dr_pipeline::EditGraph;
|
||||
|
||||
@@ -32,9 +32,10 @@ fn frame(pattern: CfaPattern, level: u16, set: &[(u32, u32, u16)]) -> RawImage {
|
||||
white_level: WHITE,
|
||||
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]),
|
||||
base_curve: BaseCurve::IDENTITY,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
baseline_exposure: 0.0,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
@@ -140,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]));
|
||||
}
|
||||
|
||||
@@ -109,8 +109,7 @@ fn row_rgb(pixels: &[u8], size: u32, y: u32) -> Vec<[u8; 3]> {
|
||||
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let scale = graph.render_scale(source.size(), (out, out));
|
||||
let detail =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, out, out, None, &detail, key)
|
||||
.expect("render");
|
||||
@@ -683,28 +682,21 @@ fn texture_contributes_nothing_where_its_scale_does_not_exist() {
|
||||
// `render_masked`, and was rejected for handing a linear-working shader
|
||||
// to the plain path — so texture alone on a thumbnail did not render.
|
||||
//
|
||||
// The seam was closed where that note said it would have to be, at the
|
||||
// composition boundary: `compose_detail` now emits a bodyless
|
||||
// `detail/resolve` pass in exactly this case, which reads only the pixel
|
||||
// it writes and performs the output transform the fused pass declined to
|
||||
// do. So the chain is no longer empty — it carries precisely the one pass
|
||||
// that finishes the render and no kernel at all, which is the honest
|
||||
// description of "a two-pixel surface structure is not present in a
|
||||
// 128-pixel rendering".
|
||||
// The seam was closed at the composition boundary, and closed again,
|
||||
// more simply, by D19: no detail pass encodes any more, the fused pass's
|
||||
// view pass performs the output transform whatever the chain holds, and
|
||||
// so the empty chain is a whole render. That is the honest description of
|
||||
// "a two-pixel surface structure is not present in a 128-pixel
|
||||
// rendering".
|
||||
let scale = graph.render_scale(source.size(), (128, 128));
|
||||
let composed =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
assert_eq!(
|
||||
composed.len(),
|
||||
1,
|
||||
"the chain must carry the resolve pass and nothing else"
|
||||
);
|
||||
assert_eq!(composed.passes[0].label, "detail/resolve");
|
||||
assert_eq!(
|
||||
composed.radius(),
|
||||
0,
|
||||
let composed = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||
assert!(
|
||||
composed.is_empty(),
|
||||
"texture claimed a kernel it cannot draw"
|
||||
);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
render(&mut pass, &graph, &source, 128);
|
||||
assert_eq!(pass.view_dispatches(), 1, "the view pass still finishes it");
|
||||
|
||||
// With clarity on as well the edit is renderable again, and the dispatch
|
||||
// count says what the assertion above says: two passes, not four. Texture
|
||||
|
||||
@@ -73,8 +73,7 @@ fn render_at(
|
||||
scale: RenderScale,
|
||||
) -> Vec<u8> {
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let detail =
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail(scale.full_size(), scale.render_size());
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key)
|
||||
.expect("render");
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! TRACES: FR-DEV-2 | FR-DEV-3j
|
||||
//! Scene-referred until the view transform (D19, ARCH §6.14), on a device.
|
||||
//!
|
||||
//! The rule is about every operation between the camera matrix and the view
|
||||
//! transform, so this runs each of them over a ramp that reaches sixteen
|
||||
//! times sensor saturation and asserts the two things a clip or an early
|
||||
//! encode would break: the output still increases with the input, and values
|
||||
//! above 1.0 still differ from one another.
|
||||
//!
|
||||
//! A clip above 1.0 cannot be seen through an 8-bit display encode on its
|
||||
//! own, so each operation is wrapped: a gain of sixteen ahead of it puts the
|
||||
//! ramp into the range the rule is about, a gain of one sixty-fourth after it
|
||||
//! brings the result back under 1.0 — with two stops to spare, for the
|
||||
//! operations that brighten — and an identity in the view transform's
|
||||
//! place stops the sigmoid compressing what is being measured. A fragment
|
||||
//! that clamps, or encodes and decodes through a clamped range, flattens the
|
||||
//! top of the ramp, and the last few steps come out equal.
|
||||
//!
|
||||
//! The view stage and the detail stage are excluded. The view transform and
|
||||
//! film simulation clip into a display range because that is their job, and
|
||||
//! a neighbourhood operation is a pass of its own that a flat frame cannot
|
||||
//! exercise.
|
||||
|
||||
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, ParamKind};
|
||||
use dr_pipeline::operation::{Operation, Stage, Uniform};
|
||||
|
||||
const SIZE: u32 = 16;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
pollster::block_on(GpuContext::new_headless()).ok()
|
||||
}
|
||||
|
||||
/// A gain, as a scene-stage operation, or an identity in the view stage.
|
||||
struct Probe {
|
||||
id: &'static str,
|
||||
gain: f32,
|
||||
stage: Stage,
|
||||
}
|
||||
|
||||
impl Operation for Probe {
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
Arc::new(OpDescriptor {
|
||||
id: OpId(self.id),
|
||||
label: LocalizedKey(self.id),
|
||||
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 {
|
||||
self.stage
|
||||
}
|
||||
/// The identity view claims the view transform's place: while it is
|
||||
/// active the composer emits it rather than the sigmoid.
|
||||
fn renders(&self) -> bool {
|
||||
self.stage == Stage::View
|
||||
}
|
||||
fn wgsl_body(&self) -> String {
|
||||
"c = c * gain;".into()
|
||||
}
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
vec![Uniform {
|
||||
name: "gain",
|
||||
value: self.gain,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
fn probe(id: &'static str, gain: f32, stage: Stage) -> Box<dyn Operation> {
|
||||
Box::new(Probe { id, gain, stage })
|
||||
}
|
||||
|
||||
/// A flat frame at `level` of sensor saturation, identity matrix, neutral
|
||||
/// balance.
|
||||
fn flat(ctx: &GpuContext, level: f32) -> dr_gpu::DemosaicedImage {
|
||||
let raw = RawImage {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
data: vec![(level * f32::from(u16::MAX)).round() as u16; (SIZE * SIZE) as usize],
|
||||
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: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
baseline_exposure: 0.0,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
},
|
||||
};
|
||||
Demosaicer::new(ctx)
|
||||
.expect("demosaicer")
|
||||
.run(&raw)
|
||||
.expect("demosaic")
|
||||
}
|
||||
|
||||
/// Every parameter moved off its default, a third of the way toward its
|
||||
/// maximum — or toward its minimum where the default is the maximum.
|
||||
///
|
||||
/// The tone curve is the exception, because its neutral is a relationship:
|
||||
/// its parameters are point coordinates, and moving every x and y the same
|
||||
/// fraction leaves the points on the diagonal. It gets a lifted midpoint on
|
||||
/// the master and on the red curve instead — the two helpers that clamped.
|
||||
fn non_neutral(op: &mut dyn Operation) {
|
||||
use dr_pipeline::ops::curve::{coordinate, Axis, Channel};
|
||||
if op.descriptor().id == dr_pipeline::ops::curve::ID {
|
||||
op.set_param(coordinate(Channel::Master, 2, Axis::Y), 0.65);
|
||||
op.set_param(coordinate(Channel::Red, 2, Axis::Y), 0.6);
|
||||
return;
|
||||
}
|
||||
for p in &op.descriptor().params {
|
||||
if let ParamKind::Scalar { min, max, .. } = p.kind {
|
||||
let toward = if p.default < max { max } else { min };
|
||||
op.set_param(p.id, p.default + (toward - p.default) / 3.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The ramp, as scene values after the sixteenfold gain: 0.4 to 16.
|
||||
///
|
||||
/// Kept below 1.0 at the sensor, and away from its last 1.5%, because the
|
||||
/// prologue's highlight desaturation fades a photosite toward neutral there —
|
||||
/// a sensor fact, not an operation's, and flat grey is neutral already.
|
||||
const LEVELS: [f32; 8] = [0.025, 0.05, 0.1, 0.2, 0.4, 0.6, 0.8, 0.95];
|
||||
|
||||
#[test]
|
||||
fn scene_referred_until_the_view() {
|
||||
// TRACES: FR-DEV-2 | FR-DEV-3j
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let sources: Vec<_> = LEVELS.iter().map(|&l| flat(&ctx, l)).collect();
|
||||
let mut adjust = AdjustPass::new(&ctx);
|
||||
|
||||
let mut checked = 0;
|
||||
for mut op in dr_pipeline::ops::chain() {
|
||||
if op.detail().is_some() || op.stage() == Stage::View {
|
||||
continue;
|
||||
}
|
||||
let id = op.descriptor().id.0;
|
||||
non_neutral(op.as_mut());
|
||||
assert!(op.is_active(), "{id}: the edit above left it neutral");
|
||||
let ops = vec![
|
||||
probe("probe_up", 16.0, Stage::Scene),
|
||||
op,
|
||||
probe("probe_down", 1.0 / 64.0, Stage::Scene),
|
||||
probe("probe_view", 1.0, Stage::View),
|
||||
];
|
||||
let shader = dr_pipeline::compose(&ops);
|
||||
assert!(
|
||||
!shader.source.contains("view_sigmoid"),
|
||||
"the identity must take the view transform's place"
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
for source in &sources {
|
||||
adjust.render(source, &shader, SIZE, SIZE).expect("render");
|
||||
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||
let centre = (((SIZE / 2) * SIZE + SIZE / 2) * 4) as usize;
|
||||
out.push([pixels[centre], pixels[centre + 1], pixels[centre + 2]]);
|
||||
}
|
||||
|
||||
for channel in 0..3 {
|
||||
let ramp: Vec<u8> = out.iter().map(|p| p[channel]).collect();
|
||||
assert!(
|
||||
ramp.windows(2).all(|w| w[1] >= w[0]),
|
||||
"{id} is not monotone in channel {channel}: {ramp:?}"
|
||||
);
|
||||
// The top three levels are scene 9.6, 12.8 and 15.2: all far
|
||||
// above 1.0, and a clip anywhere below them makes them equal.
|
||||
let top = &ramp[LEVELS.len() - 3..];
|
||||
assert!(
|
||||
top[0] < top[1] && top[1] < top[2],
|
||||
"{id} flattens values above 1.0 in channel {channel}: {ramp:?}"
|
||||
);
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
assert!(checked >= 10, "only {checked} operations were checked");
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//! TRACES: FR-DSP-2 | NFR-RES-2
|
||||
//! A photograph larger than one texture, developed from windows of it.
|
||||
//!
|
||||
//! The claim under test is that the window is invisible: a frame rendered a
|
||||
//! tile at a time, each tile from only the part of the source it reads, is the
|
||||
//! frame rendered whole. `dr-pipeline` can check the plan — the tiles cover
|
||||
//! the frame once, each is grown by the reach — but not that the shader's
|
||||
//! mapping into a window lands on the texel the whole texture would have
|
||||
//! given, which only a device answers.
|
||||
//!
|
||||
//! The frames here are small and the "device limit" is a number passed in,
|
||||
//! so the tiling is exercised on any adapter, including one whose real limit
|
||||
//! a test image could never approach.
|
||||
|
||||
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
|
||||
use dr_pipeline::descriptor::{OpId, ParamId};
|
||||
use dr_pipeline::framing::ANGLE;
|
||||
use dr_pipeline::{tiles, Affects, EditGraph};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
match pollster::block_on(GpuContext::new_headless()) {
|
||||
Ok(c) => Some(c),
|
||||
Err(e) => {
|
||||
eprintln!("skipping: no GPU adapter ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A linear RGB frame with detail at every scale: a slow gradient for the
|
||||
/// tone controls and a hash for the kernels, so a tile that read one pixel
|
||||
/// off would show.
|
||||
fn linear_frame(w: u32, h: u32, noise: bool) -> RawImage {
|
||||
let mut data = Vec::with_capacity((w * h * 3) as usize);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let base = 4000.0 + 30000.0 * (x as f32 / w as f32) + 12000.0 * (y as f32 / h as f32);
|
||||
let hash = if noise {
|
||||
((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) % 8000) as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
for c in 0..3 {
|
||||
data.push((base * (0.7 + 0.15 * c as f32) + hash) as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
RawImage {
|
||||
width: w,
|
||||
height: h,
|
||||
data,
|
||||
cfa_pattern: CfaPattern::Unknown,
|
||||
black_level: [512; 4],
|
||||
white_level: 65535,
|
||||
wb_coeffs: [2.0, 1.0, 1.5, 1.0],
|
||||
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 {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: w,
|
||||
height: h,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `graph` over `source` at `size` and read it back.
|
||||
fn render(
|
||||
pass: &mut AdjustPass,
|
||||
graph: &EditGraph,
|
||||
source: &DemosaicedImage,
|
||||
size: (u32, u32),
|
||||
) -> Vec<u8> {
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail(source.size(), size);
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, size.0, size.1, None, &detail, key)
|
||||
.expect("render");
|
||||
pass.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
/// The frame at full resolution, a tile at a time, each from its own window.
|
||||
fn render_tiled(
|
||||
ctx: &GpuContext,
|
||||
pass: &mut AdjustPass,
|
||||
graph: &mut EditGraph,
|
||||
raw: &RawImage,
|
||||
max_edge: u32,
|
||||
) -> (Vec<u8>, usize) {
|
||||
let frame = (raw.crop.width, raw.crop.height);
|
||||
let out = graph.output_size(frame.0, frame.1);
|
||||
let reach = graph.compose_detail(frame, out).reach();
|
||||
let plan = tiles::plan(out, max_edge, reach).expect("a plan");
|
||||
let mut pixels = vec![0u8; (out.0 * out.1 * 4) as usize];
|
||||
for t in &plan {
|
||||
graph.framing_mut().set_view(t.view(out));
|
||||
let r = graph.source_region(frame, 0);
|
||||
let x0 = (r.x * frame.0 as f32).floor() as u32;
|
||||
let y0 = (r.y * frame.1 as f32).floor() as u32;
|
||||
let x1 = ((r.x + r.width) * frame.0 as f32).ceil() as u32;
|
||||
let y1 = ((r.y + r.height) * frame.1 as f32).ceil() as u32;
|
||||
let window = DemosaicedImage::linear_rgb16_window(ctx, raw, [x0, y0, x1 - x0, y1 - y0], 1)
|
||||
.expect("window");
|
||||
assert_eq!(window.size(), frame, "a window measures the frame");
|
||||
let tile = render(pass, graph, &window, (t.grown[2], t.grown[3]));
|
||||
let (ox, oy) = t.keep_offset();
|
||||
for row in 0..t.keep[3] {
|
||||
let src = (((oy + row) * t.grown[2] + ox) * 4) as usize;
|
||||
let dst = (((t.keep[1] + row) * out.0 + t.keep[0]) * 4) as usize;
|
||||
let n = (t.keep[2] * 4) as usize;
|
||||
pixels[dst..dst + n].copy_from_slice(&tile[src..src + n]);
|
||||
}
|
||||
}
|
||||
graph
|
||||
.framing_mut()
|
||||
.set_view(dr_pipeline::CropRect::default());
|
||||
(pixels, plan.len())
|
||||
}
|
||||
|
||||
fn largest_difference(a: &[u8], b: &[u8]) -> u8 {
|
||||
a.iter()
|
||||
.zip(b)
|
||||
.map(|(x, y)| x.abs_diff(*y))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_of_windows_are_the_whole_frame() {
|
||||
// Point operations only, unrotated: every output pixel is an exact load
|
||||
// of one source texel, so the tiled frame has to be the whole one to
|
||||
// the bit.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let raw = linear_frame(200, 120, true);
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.set_param(OpId("exposure"), ParamId("exposure"), 0.7);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let whole = DemosaicedImage::from_linear_rgb16(&ctx, &raw).unwrap();
|
||||
assert!(whole.is_whole());
|
||||
let reference = render(&mut pass, &graph, &whole, (200, 120));
|
||||
let (tiled, n) = render_tiled(&ctx, &mut pass, &mut graph, &raw, 64);
|
||||
assert!(n > 4, "the frame should have been cut, got {n} tile(s)");
|
||||
assert_eq!(largest_difference(&reference, &tiled), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_straightened_frame_with_clarity_tiles_without_seams() {
|
||||
// The hard case: a free angle samples between texels, and clarity reads
|
||||
// a wide neighbourhood on a reduced grid. The halo and the grid
|
||||
// alignment are what keep the tiles' edges out of the picture; a code
|
||||
// value of rounding is all that may differ.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let raw = linear_frame(320, 208, true);
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.set_param(OpId("clarity"), ParamId("amount"), 60.0);
|
||||
graph.framing_mut().set_param(ANGLE, 3.0);
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let whole = DemosaicedImage::from_linear_rgb16(&ctx, &raw).unwrap();
|
||||
let out = graph.output_size(320, 208);
|
||||
let reference = render(&mut pass, &graph, &whole, out);
|
||||
let (tiled, n) = render_tiled(&ctx, &mut pass, &mut graph, &raw, 160);
|
||||
assert!(n > 1, "the frame should have been cut, got {n} tile(s)");
|
||||
let worst = largest_difference(&reference, &tiled);
|
||||
assert!(
|
||||
worst <= 1,
|
||||
"tiles differ from the whole frame by {worst} code values"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reduced_copy_stands_for_the_whole_frame() {
|
||||
// The canvas at fit renders from a copy reduced to fit the device. It
|
||||
// must measure the photograph, not itself, or a crop drawn on it lands
|
||||
// somewhere else in the export; and rendered small it must look like the
|
||||
// full frame rendered small.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let raw = linear_frame(400, 240, false);
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.set_crop(dr_pipeline::CropRect {
|
||||
x: 0.25,
|
||||
y: 0.1,
|
||||
width: 0.5,
|
||||
height: 0.6,
|
||||
});
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
|
||||
let whole = DemosaicedImage::from_linear_rgb16(&ctx, &raw).unwrap();
|
||||
let reduced = DemosaicedImage::linear_rgb16_window(&ctx, &raw, [0, 0, 400, 240], 3).unwrap();
|
||||
assert_eq!(reduced.size(), (400, 240));
|
||||
assert_eq!(reduced.texture_size(), (134, 80));
|
||||
assert!(!reduced.is_whole());
|
||||
|
||||
let size = (50, 36);
|
||||
let a = render(&mut pass, &graph, &whole, size);
|
||||
let b = render(&mut pass, &graph, &reduced, size);
|
||||
let worst = largest_difference(&a, &b);
|
||||
assert!(
|
||||
worst <= 3,
|
||||
"the reduced copy renders {worst} code values away"
|
||||
);
|
||||
}
|
||||
@@ -72,7 +72,7 @@ fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, ou
|
||||
let shader = graph.compose_for(ColourSpace::Srgb);
|
||||
let (w, h) = graph.output_size(source.size().0, source.size().1);
|
||||
let (w, h) = (w.min(out), h.min(out));
|
||||
let detail = graph.compose_detail_for(source.size(), (w, h), ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail(source.size(), (w, h));
|
||||
let key = graph.invalidation().through(Affects::Colour);
|
||||
pass.render_detailed(source, &shader, w, h, None, &detail, key)
|
||||
.expect("render");
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! TRACES: FR-DEV-3j | FR-DEV-2
|
||||
//! The view transform, end to end on a device.
|
||||
//!
|
||||
//! `dr-pipeline` checks the curve on the CPU and that the composer emits it in
|
||||
//! the right place. Neither would notice a shader that disagreed with the CPU
|
||||
//! reference, or a clamp somewhere upstream that made two highlights the same
|
||||
//! number before the curve ever saw them — which is exactly what the retired
|
||||
//! base curve did, and why D19 exists. So this renders real pixels.
|
||||
|
||||
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||||
use dr_pipeline::view::Sigmoid;
|
||||
use dr_pipeline::EditGraph;
|
||||
|
||||
const SIZE: u32 = 16;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
pollster::block_on(GpuContext::new_headless()).ok()
|
||||
}
|
||||
|
||||
/// A flat RGGB frame at `level` out of 65535, with an identity matrix and a
|
||||
/// neutral balance, so the only things that move a pixel are the edit and the
|
||||
/// view transform.
|
||||
fn flat_raw(level: u16) -> RawImage {
|
||||
RawImage {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
data: vec![level; (SIZE * SIZE) as usize],
|
||||
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: 1,
|
||||
profile: None,
|
||||
profile_tables: None,
|
||||
baseline_exposure: 0.0,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn rendered(ctx: &GpuContext, level: u16, graph: &EditGraph) -> u8 {
|
||||
let source = Demosaicer::new(ctx)
|
||||
.expect("demosaicer")
|
||||
.run(&flat_raw(level))
|
||||
.expect("demosaic");
|
||||
let shader = graph.compose();
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
||||
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
|
||||
pixels[centre as usize]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shader_agrees_with_the_cpu_reference() {
|
||||
// TRACES: FR-DEV-3j
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let curve = Sigmoid::default_curve();
|
||||
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);
|
||||
let expected = (dr_types::Transfer::Srgb.encode(display) * 255.0).round() as i32;
|
||||
let got = i32::from(rendered(&ctx, level, &graph));
|
||||
// Two 8-bit steps, for the `Rgba16Float` intermediate and the
|
||||
// rounding either side of the encode.
|
||||
assert!(
|
||||
(got - expected).abs() <= 2,
|
||||
"raw {level} rendered as {got}, expected about {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlights_above_one_stay_distinct() {
|
||||
// TRACES: FR-DEV-2 | FR-DEV-3j
|
||||
// The failure D19 names first. Two stops of exposure put these two
|
||||
// frames at 1.0 and 1.5 of sensor saturation. The base curve was flat
|
||||
// past 1.0, so both rendered as the same white; the view transform's
|
||||
// shoulder still separates them.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let mut graph = sigmoid_chain();
|
||||
graph.set_param(
|
||||
dr_pipeline::ops::exposure::ID,
|
||||
dr_pipeline::ops::exposure::EXPOSURE,
|
||||
2.0,
|
||||
);
|
||||
let lower = rendered(&ctx, u16::MAX / 4, &graph);
|
||||
let upper = rendered(&ctx, (u16::MAX / 8) * 3, &graph);
|
||||
assert!(
|
||||
upper > lower,
|
||||
"scene 1.0 rendered {lower} and scene 1.5 rendered {upper}"
|
||||
);
|
||||
assert!(upper < 255, "scene 1.5 is below the default white point");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rendering_is_monotone_through_the_whole_range() {
|
||||
// TRACES: FR-DEV-3j
|
||||
// A dip anywhere puts a dark band across a smooth gradient — a sky, most
|
||||
// visibly.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let graph = EditGraph::default_chain();
|
||||
let mut last = 0u8;
|
||||
for step in 0..=32u32 {
|
||||
let level = (step * u32::from(u16::MAX) / 32) as u16;
|
||||
let got = rendered(&ctx, level, &graph);
|
||||
assert!(got >= last, "raw {level} rendered {got}, below {last}");
|
||||
last = got;
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `graph` over a flat frame through `render_detailed`, the path every
|
||||
/// frontend takes, and return the centre pixel's red.
|
||||
fn rendered_detailed(ctx: &GpuContext, level: u16, graph: &EditGraph) -> (u8, AdjustPass) {
|
||||
let source = Demosaicer::new(ctx)
|
||||
.expect("demosaicer")
|
||||
.run(&flat_raw(level))
|
||||
.expect("demosaic");
|
||||
let shader = graph.compose_for(dr_types::ColourSpace::Srgb);
|
||||
let detail = graph.compose_detail(source.size(), (SIZE, SIZE));
|
||||
let key = graph.invalidation().through(dr_pipeline::Affects::Colour);
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, key)
|
||||
.expect("render");
|
||||
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
|
||||
(pixels[centre as usize], adjust)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_detail_stage_renders_through_the_view_pass_unchanged() {
|
||||
// TRACES: FR-DEV-3j | FR-DEV-2
|
||||
// With a detail stage the view transform is a dispatch of its own after
|
||||
// it (D19). Sharpening a flat field changes nothing, so the same frame
|
||||
// with and without it must render the same: the view pass read the detail
|
||||
// stage's result, applied the view transform once, and encoded once.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let plain = rendered(&ctx, 8_520, &EditGraph::default_chain());
|
||||
|
||||
let mut sharpened = EditGraph::default_chain();
|
||||
let id = dr_pipeline::ops::capture_sharpen::ID;
|
||||
sharpened.set_param(id, dr_pipeline::ops::capture_sharpen::AMOUNT, 100.0);
|
||||
sharpened.set_param(id, dr_pipeline::ops::capture_sharpen::RADIUS, 1.0);
|
||||
let (detailed, pass) = rendered_detailed(&ctx, 8_520, &sharpened);
|
||||
|
||||
assert!(
|
||||
pass.detail_dispatches() > 0,
|
||||
"the premise: a detail stage ran"
|
||||
);
|
||||
assert_eq!(pass.view_dispatches(), 1);
|
||||
assert!(
|
||||
detailed.abs_diff(plain) <= 1,
|
||||
"with a detail stage {detailed}, without {plain}"
|
||||
);
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+142
-6
@@ -12,6 +12,11 @@
|
||||
//! best-connected frame; rotations chained along it.
|
||||
//! 5. Bundle adjustment over every link's inliers (`bundle`).
|
||||
//!
|
||||
//! Steps 1 and 2 are [`match_pairs`] and most of the time; 3 to 5 are
|
||||
//! [`solve`], which takes a subset of the frames. Leaving a frame out is
|
||||
//! then a solve over the pairs already measured — the same links, not a
|
||||
//! fresh RANSAC whose seeds would move with the frames' positions.
|
||||
//!
|
||||
//! What it refuses to do is guess. A frame the tree does not reach is
|
||||
//! reported by index with the reason (FR-MRG-5) and left out of the
|
||||
//! cameras; the caller decides whether a set with a hole is worth
|
||||
@@ -125,13 +130,45 @@ impl Alignment {
|
||||
}
|
||||
}
|
||||
|
||||
/// Align a set of frames from their features.
|
||||
/// Every pair of a set measured: steps 1 and 2, the expensive part, kept
|
||||
/// so that a solve over a subset reuses it.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Pairs {
|
||||
/// Each frame's long edge, for the focal length's clamp.
|
||||
long_edges: Vec<f64>,
|
||||
/// Pairs with enough matches to try a geometry, whether or not it held.
|
||||
matched: Vec<(usize, usize)>,
|
||||
links: Vec<Link>,
|
||||
/// Every link's inliers, in pixels, centred.
|
||||
observations: Vec<Observation>,
|
||||
}
|
||||
|
||||
impl Pairs {
|
||||
/// How many frames were measured.
|
||||
pub fn len(&self) -> usize {
|
||||
self.long_edges.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.long_edges.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Align a set of frames from their features: [`match_pairs`], then
|
||||
/// [`solve`] over all of them.
|
||||
///
|
||||
/// Every `Features` must be in its own frame's pixel coordinates with the
|
||||
/// image size filled in; points are centred on the image centre here. The
|
||||
/// frames must all come from the same lens at the same focal length, which
|
||||
/// is the panorama assumption and not checked — the caller has the EXIF.
|
||||
pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, PanoError> {
|
||||
let pairs = match_pairs(frames, opts)?;
|
||||
solve(&pairs, &vec![true; frames.len()], opts)
|
||||
}
|
||||
|
||||
/// Steps 1 and 2: every pair matched, and a robust homography for each
|
||||
/// pair with enough matches.
|
||||
pub fn match_pairs(frames: &[Features], opts: &AlignOptions) -> Result<Pairs, PanoError> {
|
||||
let n = frames.len();
|
||||
if n < 2 {
|
||||
return Err(PanoError::Input(
|
||||
@@ -156,7 +193,7 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
|
||||
// 1 + 2: every pair.
|
||||
let mut links = Vec::new();
|
||||
let mut observations: Vec<Observation> = Vec::new();
|
||||
let mut matched_any = vec![false; n];
|
||||
let mut matched = Vec::new();
|
||||
let t_match = std::time::Instant::now();
|
||||
for i in 0..n {
|
||||
for j in i + 1..n {
|
||||
@@ -165,8 +202,7 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
|
||||
if matches.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
matched_any[i] = true;
|
||||
matched_any[j] = true;
|
||||
matched.push((i, j));
|
||||
let pairs: Vec<((f64, f64), (f64, f64))> = matches
|
||||
.iter()
|
||||
.map(|m| {
|
||||
@@ -214,6 +250,69 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
|
||||
}
|
||||
|
||||
log::debug!("matching and pairwise geometry in {:?}", t_match.elapsed());
|
||||
Ok(Pairs {
|
||||
long_edges: frames
|
||||
.iter()
|
||||
.map(|f| f.width.max(f.height) as f64)
|
||||
.collect(),
|
||||
matched,
|
||||
links,
|
||||
observations,
|
||||
})
|
||||
}
|
||||
|
||||
/// Steps 3 to 5 over the frames `keep` marks, from pairs already measured.
|
||||
///
|
||||
/// The result is indexed by the kept frames in order: its frame `k` is the
|
||||
/// `k`-th frame `keep` marks. Only pairs whose frames are both kept take
|
||||
/// part, so a frame whose only overlap was with one left out is reported
|
||||
/// as unaligned, as it would be had it never been measured with it.
|
||||
pub fn solve(pairs: &Pairs, keep: &[bool], opts: &AlignOptions) -> Result<Alignment, PanoError> {
|
||||
if keep.len() != pairs.len() {
|
||||
return Err(PanoError::Input(format!(
|
||||
"{} flags for {} frames",
|
||||
keep.len(),
|
||||
pairs.len()
|
||||
)));
|
||||
}
|
||||
// Input index to the solve's.
|
||||
let mut slot = vec![None; keep.len()];
|
||||
let mut n = 0usize;
|
||||
for (k, &kept) in keep.iter().enumerate() {
|
||||
if kept {
|
||||
slot[k] = Some(n);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if n < 2 {
|
||||
return Err(PanoError::Input(
|
||||
"a panorama needs at least two frames".into(),
|
||||
));
|
||||
}
|
||||
let both = |i: usize, j: usize| Some((slot[i]?, slot[j]?));
|
||||
let mut matched_any = vec![false; n];
|
||||
for &(i, j) in &pairs.matched {
|
||||
if let Some((i, j)) = both(i, j) {
|
||||
matched_any[i] = true;
|
||||
matched_any[j] = true;
|
||||
}
|
||||
}
|
||||
let links: Vec<Link> = pairs
|
||||
.links
|
||||
.iter()
|
||||
.filter_map(|l| {
|
||||
let (i, j) = both(l.i, l.j)?;
|
||||
Some(Link { i, j, ..l.clone() })
|
||||
})
|
||||
.collect();
|
||||
let observations: Vec<Observation> = pairs
|
||||
.observations
|
||||
.iter()
|
||||
.filter_map(|o| {
|
||||
let (i, j) = both(o.i, o.j)?;
|
||||
Some(Observation { i, j, ..*o })
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 3: the focal length.
|
||||
let mut estimates: Vec<f64> = links
|
||||
@@ -221,9 +320,12 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
|
||||
.filter_map(|l| homography::focal_from_homography(&l.h))
|
||||
.filter(|f| f.is_finite() && *f > 0.0)
|
||||
.collect();
|
||||
let longest = frames
|
||||
let longest = pairs
|
||||
.long_edges
|
||||
.iter()
|
||||
.map(|f| f.width.max(f.height) as f64)
|
||||
.zip(keep)
|
||||
.filter(|(_, &kept)| kept)
|
||||
.map(|(&e, _)| e)
|
||||
.fold(0.0, f64::max);
|
||||
let focal = if !estimates.is_empty() {
|
||||
estimates.sort_by(f64::total_cmp);
|
||||
@@ -448,6 +550,40 @@ mod tests {
|
||||
assert!(out.rotations[..3].iter().all(Option::is_some));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_left_out_is_solved_without_measuring_again() {
|
||||
let (frames, truth) = synthetic_sweep(6, 0.3, 1400.0, 1024, 768);
|
||||
let opts = AlignOptions::default();
|
||||
let pairs = match_pairs(&frames, &opts).expect("measured");
|
||||
// The first frame left out: five cameras, indexed as the kept
|
||||
// frames, and the links among them only.
|
||||
let keep = [false, true, true, true, true, true];
|
||||
let out = solve(&pairs, &keep, &opts).expect("solved");
|
||||
assert!(out.is_complete(), "unaligned: {:?}", out.unaligned);
|
||||
assert_eq!(out.rotations.len(), 5);
|
||||
assert_eq!(out.links.len(), 4 + 3, "links: {}", out.links.len());
|
||||
let root = out
|
||||
.rotations
|
||||
.iter()
|
||||
.position(|r| *r == Some(Mat3::IDENTITY))
|
||||
.unwrap();
|
||||
for k in 0..5 {
|
||||
let rel_truth = truth.rotations[root + 1].transpose() * truth.rotations[k + 1];
|
||||
let err = angle_between(rel_truth, out.rotations[k].unwrap());
|
||||
assert!(err < 2e-3, "frame {k} off by {err} rad");
|
||||
}
|
||||
// A frame in the middle left out splits the sweep only if nothing
|
||||
// spans the gap; at 0.3 rad steps its neighbours still overlap.
|
||||
let keep = [true, true, false, true, true, true];
|
||||
let out = solve(&pairs, &keep, &opts).expect("solved");
|
||||
assert!(out.is_complete(), "unaligned: {:?}", out.unaligned);
|
||||
// And the whole set solved from the pairs is `align`'s answer.
|
||||
assert_eq!(
|
||||
solve(&pairs, &[true; 6], &opts).expect("solved"),
|
||||
align(&frames, &opts).expect("aligned")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_frame_is_refused() {
|
||||
let (frames, _) = synthetic_sweep(1, 0.3, 1400.0, 640, 480);
|
||||
|
||||
@@ -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,15 +44,17 @@ pub mod matching;
|
||||
#[cfg(feature = "xfeat")]
|
||||
pub mod migan;
|
||||
pub mod projection;
|
||||
pub mod seam;
|
||||
#[cfg(feature = "xfeat")]
|
||||
pub mod xfeat;
|
||||
|
||||
pub use align::{align, AlignOptions, Alignment, Link, Unaligned};
|
||||
pub use align::{align, match_pairs, solve, AlignOptions, Alignment, Link, Pairs, Unaligned};
|
||||
pub use bundle::Cameras;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -405,6 +405,7 @@ fn emit_node(out: &mut String, node: &Declaration) {
|
||||
active,
|
||||
tests,
|
||||
presentation,
|
||||
camera_stage,
|
||||
..
|
||||
} = node;
|
||||
|
||||
@@ -569,6 +570,15 @@ fn emit_node(out: &mut String, node: &Declaration) {
|
||||
" fn is_active(&self) -> bool {{\n {active_expr}\n }}\n"
|
||||
);
|
||||
|
||||
// Only a camera-stage node says anything: the trait's default is the
|
||||
// scene, which is every other node (D19).
|
||||
if *camera_stage {
|
||||
out.push_str(
|
||||
" fn stage(&self) -> crate::operation::Stage {\n \
|
||||
crate::operation::Stage::Camera\n }\n\n",
|
||||
);
|
||||
}
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" fn wgsl_body(&self) -> String {{\n {}.into()\n }}\n",
|
||||
|
||||
@@ -228,9 +228,11 @@ interpolated points — master, red, green, blue — each reaching the shader on
|
||||
when it has been moved), `colour_mixer` (thirty-six faceted parameters from
|
||||
twelve computed hue bands), `film_sim` (a stock's measured tables, which are
|
||||
not parameters, and the one node that declares `Operation::renders` — see
|
||||
below), `capture_sharpen` (a separable convolution) and `noise_reduction` (a
|
||||
kernel, and one that decides how many dispatches to emit at each resolution) —
|
||||
the last two for the reason the next section gives. `vignetting` is
|
||||
below), `view_transform` (composed at its defaults, which a declaration cannot
|
||||
say — see [What is not a node](#what-is-not-a-node-and-why)), and the five
|
||||
kernels — `capture_sharpen` (a separable convolution), `noise_reduction` (one
|
||||
that decides how many dispatches to emit at each resolution), `clarity`,
|
||||
`texture` and `dehaze` — for the reason the next section gives. `vignetting` is
|
||||
hand-written too but is not in the develop chain — it carries lens-profile
|
||||
coefficients that are not parameters.
|
||||
|
||||
@@ -240,18 +242,16 @@ coefficients that are not parameters.
|
||||
`Operation::renders`, and it is worth knowing why before writing a second one.
|
||||
|
||||
Every other node *adjusts* a picture. That one *makes* it: a film stock's
|
||||
characteristic curve does the camera profile's base curve's job, from
|
||||
measurements rather than from a curve somebody drew. Running both renders the
|
||||
scene twice — the camera's rendering, and then a film's rendering of *that* —
|
||||
which looks like neither and reads as a colour-management bug with no
|
||||
colour-management bug to find.
|
||||
characteristic curve does the view transform's job, from measurements rather
|
||||
than from a curve somebody chose. Running both renders the scene twice — the
|
||||
default rendering, and then a film's rendering of *that* — which looks like
|
||||
neither and reads as a colour-management bug with no colour-management bug to
|
||||
find.
|
||||
|
||||
So a node declaring `renders` takes camera RGB and hands back linear sRGB, and
|
||||
in exchange the composer emits neither the base curve nor the conversion out of
|
||||
camera space. Both halves move to the node, together: the base curve is defined
|
||||
in camera RGB and the matrix is what leaves it, so a node replacing one has
|
||||
necessarily replaced the other. `compose_full` keeps them as a single string
|
||||
for exactly that reason — it is what makes getting half of it right impossible. `distortion` and
|
||||
So `film_sim` is in `Stage::View` beside `view_transform`, and while a stock
|
||||
is loaded the composer emits it in the view transform's place, last, after the
|
||||
detail stage, and not the sigmoid (D19). It is handed working-space colour and
|
||||
hands back display-referred linear sRGB for the output transform. `distortion` and
|
||||
`aberration` are `Warp`s rather than operations: they rewrite coordinates
|
||||
before sampling rather than transforming a colour after it.
|
||||
|
||||
@@ -264,7 +264,8 @@ clarity, texture, dehaze and spot removal are all defined by what the
|
||||
of `c` at any price.
|
||||
|
||||
They go in the **detail stage**, which runs after the fused pass, in linear
|
||||
light, at render resolution, before the output transform — see
|
||||
light, at render resolution, before the view transform and the output
|
||||
transform — see
|
||||
[`../src/detail.rs`](../src/detail.rs) for why each of those is a decision
|
||||
rather than a convenience. A node of this kind:
|
||||
|
||||
@@ -294,41 +295,38 @@ in raw pixels is a different photograph on screen and in the exported file.
|
||||
|
||||
## What is not a node, and why
|
||||
|
||||
Three things act on every pixel and are deliberately not in this directory:
|
||||
the as-shot white balance, the camera matrix, and the **base curve**
|
||||
(FR-DEV-3e). They are emitted by [`../src/operation.rs`](../src/operation.rs)
|
||||
into the composed shader's fixed preamble, around the block of nodes.
|
||||
Two things act on every pixel and are deliberately not in this directory: the
|
||||
as-shot white balance and the camera matrix. They are emitted by
|
||||
[`../src/operation.rs`](../src/operation.rs) into the composed shader around the
|
||||
block of nodes. They are properties of the *file*, at the same standing as the
|
||||
masked-photosite crop (FR-RAW-3) and the stored orientation (FR-DEV-3h): nobody
|
||||
chose the sensor's green sensitivity, and reading the file correctly means
|
||||
undoing it.
|
||||
|
||||
The test is not "does it transform a colour" — all three do. It is **whose
|
||||
decision is it**. A node is something a photographer chose: it has parameters,
|
||||
it moves off a neutral, it lands in the sidecar, it can be undone. These three
|
||||
are properties of the *file*, at the same standing as the masked-photosite crop
|
||||
(FR-RAW-3) and the stored orientation (FR-DEV-3h). Nobody chose the sensor's
|
||||
green sensitivity or the body's rendering; they are what reading the file
|
||||
correctly means.
|
||||
The **view transform** (FR-DEV-3j) *is* a node — `view_transform.yaml`, a
|
||||
`rust:` one — and that is a change of mind worth knowing about. It replaced the
|
||||
per-body base curve, which was kept out of this directory because it belonged
|
||||
to the camera: as a node it would have carried one body's rendering onto
|
||||
another body's file through a shared sidecar. D19 retired the per-body curves,
|
||||
and with them the argument. One view transform serves every body, so its
|
||||
settings are a decision about the picture like any other. What is still
|
||||
special about it is `Stage::View`: the composer emits it at the end of the
|
||||
chain *whatever its state*, because a photograph with no view transform is a
|
||||
scan and not a picture. Its neutral is its defaults, like every other node's,
|
||||
so an untouched photograph writes nothing for it.
|
||||
|
||||
Making the base curve a node would have said the opposite in four places at
|
||||
once. It would have appeared in the develop panel as a control, so an
|
||||
unprofiled body would show a slider that does nothing. Its values would have
|
||||
gone into the sidecar, and sidecars are shared between devices and bodies
|
||||
(FR-NC-9) — one camera's rendering would follow an edit onto another camera's
|
||||
file. Its neutral would have had to be "the identity", so a profiled body would
|
||||
open reporting itself modified. And there is no seam through which a node could
|
||||
learn which camera took the frame: the profile arrives on the decoded image,
|
||||
travels through `DemosaicedImage` beside the matrix it belongs with, and is
|
||||
written into the uniform block by the same three lines in `dr-gpu` — which is
|
||||
exactly the path the matrix already took, because it is exactly the same kind
|
||||
of thing.
|
||||
## Stages
|
||||
|
||||
What it *does* share with the tone curve node is the spline. The composer asks
|
||||
`ToneCurve` for its `curve_span`/`curve_eval` helpers rather than emitting a
|
||||
second copy, so a profile author placing a control point and a photographer
|
||||
dragging one mean the same thing by it.
|
||||
|
||||
The order still reads correctly from this directory: the base curve runs after
|
||||
every node in the chain and before the conversion out of camera space. That is
|
||||
the same reasoning `exposure` records under `placement:` — corrections to
|
||||
capture are only meaningful on linear values, so the rendering goes last.
|
||||
`stage: camera` puts a node in camera RGB, ahead of the camera matrix; the
|
||||
default, `stage: scene`, hands it working-space colour — linear sRGB
|
||||
primaries, scene-referred and unbounded. White balance is the only camera
|
||||
node, because its multipliers scale the sensor's own channels. Everything else
|
||||
belongs in the scene, where a hue or a luminance weight means the same thing
|
||||
whichever body took the frame (D19). The composer emits the camera nodes, then
|
||||
the matrix, then the scene nodes, each group in `order:`, and the view
|
||||
transform last. `stage: view` is not offered to a declaration: a node that
|
||||
maps into a display range is exactly what ARCH §6.14 forbids of everything
|
||||
before the end, and the one that is allowed to is hand-written.
|
||||
|
||||
## Errors
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -64,7 +64,8 @@ wgsl: |
|
||||
// to grey and its noise stays the size it was.
|
||||
//
|
||||
// The grey is (1, 1, 1) scaled, because this runs after white balance
|
||||
// in the camera's space, where that is what neutral is.
|
||||
// and the camera matrix, which carries a balanced neutral to equal
|
||||
// channels.
|
||||
c = mix(c, vec3<f32>(0.18), -amount);
|
||||
} else {
|
||||
let luma = luminance(c);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
id: film_sim
|
||||
order: 25
|
||||
order: 190
|
||||
# What this node is *about* is not written here, and cannot be: a `rust:` node
|
||||
# publishes its own descriptor, so `attributes:` in this file would be read,
|
||||
# validated and then ignored. See `Attribute::Effect` on `FilmSim`'s descriptor
|
||||
@@ -11,15 +11,17 @@ why_rust: |
|
||||
characteristic curves and a density lookup — which are not parameters and
|
||||
which no `uniforms:` expression could produce. Its neutral is "no stock
|
||||
loaded" rather than a set of values, and it is the one node that declares
|
||||
`Operation::renders`, so the composer omits the camera profile's base curve
|
||||
and the conversion out of camera space on its behalf.
|
||||
`Operation::renders`, so while a stock is loaded the composer emits it in
|
||||
the view transform's place instead of the default sigmoid.
|
||||
|
||||
placement: |
|
||||
After white balance and exposure, and before everything else.
|
||||
Last, in the view transform's place (D19, FR-DEV-3j), after every other
|
||||
operation and after the detail stage.
|
||||
|
||||
Those two are what the camera did — interpreting the sensor, and correcting
|
||||
the amount of light that reached it — and they are only meaningful on
|
||||
scene-linear values, which is what a film has to be handed. Everything below
|
||||
is a decision about the picture, and a decision about the picture belongs
|
||||
after the film has rendered it, exactly as it does when you scan a frame and
|
||||
then work on the scan.
|
||||
Before D19 it sat at order 25, after white balance and exposure, and every
|
||||
decision below it acted on the film's output, as though the frame had been
|
||||
scanned and then worked on. That put a display-referred rendering in the
|
||||
middle of the chain, which is what D19 removes: every operation is now handed
|
||||
the scene, and the film is the last thing that happens to the picture — an
|
||||
edit is a decision about the exposure the negative receives. `Stage::View`
|
||||
is what puts it there; this number only places it in the panel's order.
|
||||
|
||||
@@ -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,18 @@
|
||||
id: view_transform
|
||||
order: 200
|
||||
# A `rust:` node publishes its own descriptor; its attributes are on the type
|
||||
# in `../src/ops/view_transform.rs`.
|
||||
rust: ViewTransform
|
||||
|
||||
why_rust: |
|
||||
It is composed at its defaults — a photograph with no view transform is a
|
||||
scan, not a picture — which is `Stage::View`, and a declaration has no way to
|
||||
say it. Its three uniforms are also the solution of two equations rather than
|
||||
expressions over its parameters (`dr_pipeline::view::Sigmoid::new`).
|
||||
|
||||
placement: |
|
||||
Last, after every scene operation and, when there is one, after the detail
|
||||
stage (D19, FR-DEV-3j). It is the one stage allowed to map scene-linear colour
|
||||
to a display range, so anything after it would be working on a rendering.
|
||||
The order here only places it in the panel; the composer puts every
|
||||
`Stage::View` node at the end whatever its number says.
|
||||
@@ -20,6 +20,13 @@ placement: |
|
||||
First. It is a correction to how the scene was captured, and every tonal
|
||||
operation after it should act on a correctly balanced image.
|
||||
|
||||
# In camera RGB, ahead of the camera matrix, and the only node there (D19).
|
||||
# Its multipliers scale the sensor's own channels — that is what the as-shot
|
||||
# ones are, and what the picker solves for — and a matrix that mixes the
|
||||
# channels, which is every body's, would turn the same numbers into a
|
||||
# different correction once it had run.
|
||||
stage: camera
|
||||
|
||||
params:
|
||||
temperature:
|
||||
label: param.temperature
|
||||
|
||||
@@ -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
|
||||
@@ -49,7 +49,9 @@ pub struct Section {
|
||||
/// A stable identifier, for a frontend that remembers which sections a
|
||||
/// photographer folded away. Never shown.
|
||||
pub id: &'static str,
|
||||
/// What the section is called on screen.
|
||||
/// What the section is called on screen, as a category path: `/`
|
||||
/// separates the levels, so `Film/Colour` is a folder inside `Film`. The
|
||||
/// same spelling a photographer's own preset names use for theirs.
|
||||
pub title: &'static str,
|
||||
/// The presets in it, every one reaching only what it names.
|
||||
pub presets: PresetLibrary,
|
||||
@@ -63,19 +65,20 @@ 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",
|
||||
"Colour film",
|
||||
"Film/Colour",
|
||||
include_str!("../presets/colour_film.drpl"),
|
||||
),
|
||||
(
|
||||
"cinema_film",
|
||||
"Cinema film",
|
||||
"Film/Cinema",
|
||||
include_str!("../presets/cinema_film.drpl"),
|
||||
),
|
||||
(
|
||||
"bw_film",
|
||||
"Black and white film",
|
||||
"Film/Black and white",
|
||||
include_str!("../presets/bw_film.drpl"),
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -271,6 +271,32 @@ pub struct Declaration {
|
||||
/// Boxed so the rare node that declares one does not widen every
|
||||
/// declaration by the size of a presentation it does not have.
|
||||
pub presentation: Option<Box<PresentationDef>>,
|
||||
/// Whether the node runs in camera RGB, ahead of the camera matrix —
|
||||
/// `stage: camera`. See [`read_stage`].
|
||||
pub camera_stage: bool,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e | FR-DEV-2
|
||||
/// Where in the chain a declared node's colour comes from: `stage: camera` or
|
||||
/// `stage: scene`, the default.
|
||||
///
|
||||
/// Camera RGB is where white balance's multipliers are defined, and it is the
|
||||
/// only thing that belongs there (D19): every other operation is handed
|
||||
/// working-space colour, so that a hue or a luminance weight means the same
|
||||
/// thing whichever body took the frame. `view` is not offered. The view
|
||||
/// transform is hand-written, and a declared node that clipped into a display
|
||||
/// range would be exactly what ARCH §6.14 forbids of every node before it.
|
||||
fn read_stage(root: &Mapping) -> Result<bool, String> {
|
||||
match root.get("stage") {
|
||||
None => Ok(false),
|
||||
Some(v) => match as_str(v, "stage")? {
|
||||
"camera" => Ok(true),
|
||||
"scene" => Ok(false),
|
||||
other => Err(format!(
|
||||
"unknown stage {other:?}; expected \"camera\" or \"scene\""
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
impl Declaration {
|
||||
@@ -512,6 +538,7 @@ pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result<Node,
|
||||
"define",
|
||||
"label",
|
||||
"attributes",
|
||||
"stage",
|
||||
] {
|
||||
if root.contains_key(key) {
|
||||
return Err(format!(
|
||||
@@ -564,6 +591,7 @@ pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result<Node,
|
||||
.collect();
|
||||
let tests = read_tests(root, ¶ms, &uniform_names, &helper_names)?;
|
||||
let presentation = read_presentation(root, ¶m_names)?;
|
||||
let camera_stage = read_stage(root)?;
|
||||
|
||||
Ok(Node::Declared(Box::new(Declaration {
|
||||
id,
|
||||
@@ -580,6 +608,7 @@ pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result<Node,
|
||||
active,
|
||||
tests,
|
||||
presentation,
|
||||
camera_stage,
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,8 @@ pub struct DeclaredOp {
|
||||
helpers: Vec<Helper>,
|
||||
presentation: Option<Presentation>,
|
||||
order: i64,
|
||||
/// See `decl::read_stage`.
|
||||
camera_stage: bool,
|
||||
}
|
||||
|
||||
/// One uniform: the name the fragment reads it by, and how to compute it.
|
||||
@@ -208,6 +210,7 @@ impl DeclaredOp {
|
||||
wgsl: declaration.wgsl_body(),
|
||||
helpers,
|
||||
presentation: declaration.presentation.as_deref().map(presentation),
|
||||
camera_stage: declaration.camera_stage,
|
||||
order: declaration.order,
|
||||
})
|
||||
}
|
||||
@@ -292,6 +295,14 @@ impl Operation for DeclaredOp {
|
||||
fn presentation(&self) -> Option<Presentation> {
|
||||
self.presentation.clone()
|
||||
}
|
||||
|
||||
fn stage(&self) -> crate::operation::Stage {
|
||||
if self.camera_stage {
|
||||
crate::operation::Stage::Camera
|
||||
} else {
|
||||
crate::operation::Stage::Scene
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A declared parameter as the descriptor the panel reads.
|
||||
|
||||
@@ -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
|
||||
@@ -659,10 +668,10 @@ impl Attribute {
|
||||
///
|
||||
/// `Effect` after `Colour` is a look laid over a settled picture — and is
|
||||
/// the one arguable slot. A spectral film simulation declares
|
||||
/// [`crate::Operation::renders`] and replaces the base curve, which is an
|
||||
/// argument for treating it as foundational rather than final; an array of
|
||||
/// six cannot say "last, except when it is first". The tension is recorded
|
||||
/// here rather than settled.
|
||||
/// [`crate::Operation::renders`] and takes the view transform's place at
|
||||
/// the very end of the chain (D19), which is an argument for treating it as
|
||||
/// the rendering rather than one effect among others; an array of six
|
||||
/// cannot say that. The tension is recorded here rather than settled.
|
||||
///
|
||||
/// Both ends were wrong for as long as this list only fed a row of chips
|
||||
/// nobody reads in order. It stopped being harmless when the same list
|
||||
|
||||
+192
-252
@@ -24,9 +24,10 @@
|
||||
//! v
|
||||
//! +------------------------------------------+
|
||||
//! | the fused point-operation pass | one dispatch
|
||||
//! | white balance, exposure, tone, colour |
|
||||
//! | the mask layers |
|
||||
//! | white balance (camera RGB) |
|
||||
//! | camera RGB -> linear sRGB |
|
||||
//! | exposure, tone, colour |
|
||||
//! | the mask layers |
|
||||
//! +------------------------------------------+
|
||||
//! | rgba16float, linear, **unclipped**, at render resolution
|
||||
//! v
|
||||
@@ -34,7 +35,13 @@
|
||||
//! | the detail stage - this module | one dispatch per pass
|
||||
//! | sharpen, NR, clarity, texture, spots |
|
||||
//! +------------------------------------------+
|
||||
//! | the last pass applies the output transform
|
||||
//! | rgba16float, still scene-linear and unclipped
|
||||
//! v
|
||||
//! +------------------------------------------+
|
||||
//! | the view pass | one dispatch
|
||||
//! | view transform, or the film stock |
|
||||
//! | output transform, mask reveal |
|
||||
//! +------------------------------------------+
|
||||
//! v
|
||||
//! rgba8unorm display or export texture
|
||||
//! ```
|
||||
@@ -52,14 +59,13 @@
|
||||
//! texture, clarity, spot removal and sharpen/NR sit below the tone curve and
|
||||
//! the colour mixer.
|
||||
//!
|
||||
//! **In linear light, after the camera matrix.** The fused pass works in
|
||||
//! *camera* space, because white balance and exposure are physically
|
||||
//! meaningful there and nowhere else. A detail pass is the opposite case: it
|
||||
//! wants a luminance, and camera RGB has no luminance — the three channels are
|
||||
//! **In linear light, after the camera matrix.** A detail pass wants a
|
||||
//! luminance, and camera RGB has no luminance — the three channels are
|
||||
//! whatever the CFA's dyes passed, and weighting them 0.2126/0.7152/0.0722
|
||||
//! would be numerology. So the split is taken *after* the `cam_to_srgb`
|
||||
//! multiply, where the working space is linear sRGB and a luminance is a
|
||||
//! luminance.
|
||||
//! would be numerology. Since D19 only white balance runs in camera RGB; the
|
||||
//! `cam_to_srgb` multiply follows it, so every point operation, and every
|
||||
//! detail pass after them, works in linear sRGB primaries, where a luminance
|
||||
//! is a luminance.
|
||||
//!
|
||||
//! **Before the output transform, and before the clip.** FR-DEV-2 allows
|
||||
//! exactly one quantisation, at the display or export stage. A detail pass
|
||||
@@ -70,9 +76,11 @@
|
||||
//! therefore `rgba16float` and holds linear values that have **not** been
|
||||
//! clamped to `0..=1`: a recovered highlight is still above one at this point,
|
||||
//! and clipping it before the sharpener sees it would put a hard edge exactly
|
||||
//! where the sharpener is most visible. The last detail pass performs the
|
||||
//! primaries conversion, the clip and the encode, so the single quantisation
|
||||
//! stays single.
|
||||
//! where the sharpener is most visible. Every detail pass writes such an
|
||||
//! intermediate, the last one included, and the view pass after them — the
|
||||
//! view transform, then the output transform's primaries, clip and encode —
|
||||
//! is the one place the scene is fitted to a display (D19, ARCH §6.14), so
|
||||
//! the single quantisation stays single.
|
||||
//!
|
||||
//! **After framing, at render resolution.** The alternative — running detail
|
||||
//! on the demosaiced source before the framing prologue — is superficially
|
||||
@@ -122,8 +130,6 @@
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
use crate::operation::{Helper, Operation, Uniform};
|
||||
|
||||
/// Floats the generated detail uniform block always carries, before an
|
||||
@@ -199,6 +205,10 @@ pub const DETAIL_BASE_UNIFORM_FIELDS: usize = 4;
|
||||
pub struct RenderScale {
|
||||
render: (u32, u32),
|
||||
full: (u32, u32),
|
||||
/// The whole framed photograph at source resolution: `full` before the
|
||||
/// zoom and the tile were folded in. What a frame fraction is a fraction
|
||||
/// of — see [`Self::frame_fraction`].
|
||||
frame: (u32, u32),
|
||||
}
|
||||
|
||||
impl RenderScale {
|
||||
@@ -210,9 +220,27 @@ impl RenderScale {
|
||||
/// [`crate::EditGraph::render_scale`] works both out from the framing, and
|
||||
/// is what a caller should normally use.
|
||||
pub fn new(render: (u32, u32), full: (u32, u32)) -> Self {
|
||||
let full = (full.0.max(1), full.1.max(1));
|
||||
Self {
|
||||
render: (render.0.max(1), render.1.max(1)),
|
||||
full: (full.0.max(1), full.1.max(1)),
|
||||
full,
|
||||
frame: full,
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-1 | FR-DSP-2
|
||||
/// The same scale, for a render that shows only part of a larger frame.
|
||||
///
|
||||
/// `frame` is the whole framed photograph at source resolution — the crop
|
||||
/// folded in, the zoom and any tile not. A zoomed view and an export tile
|
||||
/// both look at part of the frame, and a clarity radius is a fraction of
|
||||
/// the *frame*, not of the part: measured against the part, zooming in
|
||||
/// shrinks the halo to a fraction of what the file will get, and two
|
||||
/// neighbouring tiles of an export would each draw their own.
|
||||
pub fn within(self, frame: (u32, u32)) -> Self {
|
||||
Self {
|
||||
frame: (frame.0.max(1), frame.1.max(1)),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,8 +289,19 @@ impl RenderScale {
|
||||
/// For the compositional family — clarity, texture, dehaze — and the same
|
||||
/// unit `dr-gpu`'s mask rasteriser already converts feathers in. An edit
|
||||
/// stored this way is resolution-independent by construction.
|
||||
///
|
||||
/// Measured against the whole frame ([`Self::within`]), scaled by the
|
||||
/// render's own short edge over the viewed region's. When the render shows
|
||||
/// the whole frame the two sizes cancel and this is `fraction` of the
|
||||
/// render's short edge exactly.
|
||||
pub fn frame_fraction(&self, fraction: f32) -> f32 {
|
||||
fraction * self.render.0.min(self.render.1) as f32
|
||||
let render = self.render.0.min(self.render.1) as f32;
|
||||
let viewed = self.full.0.min(self.full.1) as f32;
|
||||
let frame = self.frame.0.min(self.frame.1) as f32;
|
||||
if self.frame == self.full {
|
||||
return fraction * render;
|
||||
}
|
||||
fraction * render * (frame / viewed)
|
||||
}
|
||||
|
||||
/// Whether a radius stated in source pixels survives this render.
|
||||
@@ -491,15 +530,6 @@ pub struct ComposedDetailPass {
|
||||
pub radius: u32,
|
||||
/// See [`DetailPass::output_scale`].
|
||||
pub output_scale: u32,
|
||||
/// Whether this pass writes the display/export texture rather than another
|
||||
/// linear intermediate.
|
||||
///
|
||||
/// True for exactly the last pass in the chain, which carries the output
|
||||
/// transform — the primaries conversion, the clip and the encode that the
|
||||
/// fused pass performs when there is no detail stage at all. Folding them
|
||||
/// into the last pass rather than adding a resolve dispatch keeps the cost
|
||||
/// of the stage at one dispatch per pass, not one plus one.
|
||||
pub writes_output: bool,
|
||||
/// Identifies this pass's *structure*, for the pipeline cache. Covers the
|
||||
/// generated source, not the uniform values — so moving a slider uploads a
|
||||
/// buffer and reuses the compiled pipeline, exactly as the fused pass does.
|
||||
@@ -537,6 +567,26 @@ impl ComposedDetail {
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-2
|
||||
/// How far the whole chain reads from the pixel it finally writes, in
|
||||
/// render pixels: the halo a tile has to be grown by so that its interior
|
||||
/// renders exactly as the untiled frame does.
|
||||
///
|
||||
/// The **sum** of the passes' reaches, not the widest of them. The passes
|
||||
/// run one after another, so a pixel of the last one depends on pixels of
|
||||
/// the one before it `r` away, each of which depends on pixels a further
|
||||
/// `r'` away. A separable blur's two halves each reach `r` along one axis
|
||||
/// and the sum over-counts them by a factor of two; that is the price of a
|
||||
/// bound that is always safe, and it is paid only by export tiles.
|
||||
///
|
||||
/// One pixel per pass on top, for the reduced grids' bilinear taps.
|
||||
pub fn reach(&self) -> u32 {
|
||||
self.passes
|
||||
.iter()
|
||||
.map(|p| p.radius.saturating_mul(p.output_scale).saturating_add(1))
|
||||
.fold(0u32, u32::saturating_add)
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3 | FR-DSP-1
|
||||
@@ -547,10 +597,11 @@ impl ComposedDetail {
|
||||
/// an edit with no sharpening produces an empty chain and `dr-gpu` runs the
|
||||
/// single dispatch it always did.
|
||||
///
|
||||
/// `output` is the space the **last** pass encodes into, and it is a parameter
|
||||
/// for the same reason it is a parameter to [`crate::compose_with_framing`]: a
|
||||
/// screen render and a Display P3 export are the same edit and different
|
||||
/// shaders, and neither is more authoritative than the other.
|
||||
/// No pass encodes. Every pass writes a linear intermediate, the last one
|
||||
/// included, and the fused pass's view pass ([`crate::ComposedShader::view`])
|
||||
/// reads the last and performs the view transform and the output transform
|
||||
/// (D19). So the output space is not a parameter here: a screen render and a
|
||||
/// Display P3 export share one detail stage.
|
||||
///
|
||||
/// # The generated uniform block
|
||||
///
|
||||
@@ -562,12 +613,8 @@ impl ComposedDetail {
|
||||
/// is there because a two-pass operation emitting one body for both directions
|
||||
/// is a reasonable thing to want, and would otherwise need a uniform of its
|
||||
/// own purely to say which half it is in.
|
||||
pub fn compose_detail(
|
||||
ops: &[Box<dyn Operation>],
|
||||
scale: RenderScale,
|
||||
output: ColourSpace,
|
||||
) -> ComposedDetail {
|
||||
compose_detail_with(ops, &[], scale, output)
|
||||
pub fn compose_detail(ops: &[Box<dyn Operation>], scale: RenderScale) -> ComposedDetail {
|
||||
compose_detail_with(ops, &[], scale)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-8
|
||||
@@ -592,7 +639,6 @@ pub fn compose_detail_with(
|
||||
ops: &[Box<dyn Operation>],
|
||||
spots: &[DetailPass],
|
||||
scale: RenderScale,
|
||||
output: ColourSpace,
|
||||
) -> ComposedDetail {
|
||||
// Every pass of every active detail operation, flattened, carrying the
|
||||
// operation it came from for the uniform prefix and the helper set.
|
||||
@@ -624,45 +670,13 @@ pub fn compose_detail_with(
|
||||
}
|
||||
}
|
||||
|
||||
// An active detail operation that emitted nothing at this scale.
|
||||
//
|
||||
// Legal, and the honest answer for an acutance operation on a heavy proxy
|
||||
// — a one-source-pixel radius is a third of a render pixel there and no
|
||||
// kernel represents a third of a pixel (see [`RenderScale`]). But it opens
|
||||
// a hole between the two halves of the composition: [`compose_full`]
|
||||
// decides to hand on linear working values from the *operations*, which it
|
||||
// must, having no scale to consult, so the fused pass has already stopped
|
||||
// short of the output transform. Returning an empty chain here would leave
|
||||
// that transform undone and bind an `rgba16float` shader to an
|
||||
// `rgba8unorm` target, which surfaces as a wgpu validation failure a long
|
||||
// way from the cause.
|
||||
//
|
||||
// So the chain is never empty when the fused pass is expecting one: a
|
||||
// single pass with no body, which reads the intermediate and performs the
|
||||
// output transform the fused pass skipped. One dispatch, in the uncommon
|
||||
// case where a photographer has a kernel switched on at a scale that
|
||||
// cannot draw it — against the alternative of the preview failing outright
|
||||
// or `compose_full` growing a resolution argument it has no other use for.
|
||||
if planned.is_empty() && ops.iter().any(|o| o.is_active() && o.detail().is_some()) {
|
||||
return ComposedDetail {
|
||||
passes: vec![compose_one(
|
||||
RESOLVE_ID,
|
||||
&[],
|
||||
&DetailPass {
|
||||
output_scale: 1,
|
||||
label: "resolve",
|
||||
radius: 0,
|
||||
wgsl: String::new(),
|
||||
uniforms: Vec::new(),
|
||||
storage: Vec::new(),
|
||||
},
|
||||
0,
|
||||
scale,
|
||||
output,
|
||||
true,
|
||||
)],
|
||||
};
|
||||
}
|
||||
// An active detail operation may emit nothing at this scale — an
|
||||
// acutance operation on a heavy proxy, whose one-source-pixel radius is a
|
||||
// third of a render pixel (see [`RenderScale`]). The chain is then empty
|
||||
// while the fused pass has stopped at linear working values, and that is
|
||||
// fine: the fused pass's view pass reads the fused result directly and
|
||||
// performs the output transform. Before D19 the last detail pass encoded,
|
||||
// and this case needed a body-less resolve pass to do it.
|
||||
|
||||
// TRACES: NFR-P5
|
||||
// A pass whose body is empty changes nothing but where the pixels are: it
|
||||
@@ -674,12 +688,10 @@ pub fn compose_detail_with(
|
||||
// 2560 x 1600 frame on the reference laptop with its clocks held down.
|
||||
//
|
||||
// Dropped here, where the chain is still a list, and only where dropping
|
||||
// it is exact:
|
||||
// it is exact. The last pass is no exception since D19: it writes an
|
||||
// `rgba16float` intermediate like the others, and the view pass reads
|
||||
// whichever one the chain last wrote.
|
||||
//
|
||||
// - **Not the last pass.** The last pass performs the output transform on
|
||||
// what it read from an `rgba16float` intermediate. Moving that transform
|
||||
// onto the pass before would apply it to that pass's `f32` result
|
||||
// instead, which is a different rounding of the same picture.
|
||||
// - **Not after a reduced pass.** A full-resolution pass ends the reduced
|
||||
// chain (see `DetailRunner::encode`), so one that follows a scaled pass
|
||||
// is what stops the next operation reading the last one's base. None of
|
||||
@@ -688,45 +700,29 @@ pub fn compose_detail_with(
|
||||
// Everywhere else the pass before and the pass after exchange the same
|
||||
// `rgba16float` texels either way, `aux` included.
|
||||
let mut kept: Vec<(&str, &[Helper], DetailPass, usize)> = Vec::with_capacity(planned.len());
|
||||
let total = planned.len();
|
||||
for (position, entry) in planned.into_iter().enumerate() {
|
||||
for entry in planned {
|
||||
let after_full = kept.last().is_none_or(|(_, _, p, _)| p.output_scale <= 1);
|
||||
let droppable = position + 1 < total && after_full && entry.2.is_identity();
|
||||
let droppable = after_full && entry.2.is_identity();
|
||||
if !droppable {
|
||||
kept.push(entry);
|
||||
}
|
||||
}
|
||||
let planned = kept;
|
||||
|
||||
let last = planned.len().saturating_sub(1);
|
||||
let passes = planned
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(position, (id, helpers, pass, index))| {
|
||||
compose_one(id, helpers, &pass, index, scale, output, position == last)
|
||||
})
|
||||
.map(|(id, helpers, pass, index)| compose_one(id, helpers, &pass, index, scale))
|
||||
.collect();
|
||||
|
||||
ComposedDetail { passes }
|
||||
}
|
||||
|
||||
/// The operation id the resolve pass is labelled with.
|
||||
///
|
||||
/// Not an operation: no `ops/*.yaml` declares it and nothing in the chain
|
||||
/// answers to it. It exists so the generated label reads `detail/resolve`
|
||||
/// rather than borrowing the id of whichever operation happened to fall
|
||||
/// through, which would send a reader looking for a bug in that operation.
|
||||
const RESOLVE_ID: &str = "detail";
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn compose_one(
|
||||
id: &str,
|
||||
helpers: &[Helper],
|
||||
pass: &DetailPass,
|
||||
index: usize,
|
||||
scale: RenderScale,
|
||||
output: ColourSpace,
|
||||
writes_output: bool,
|
||||
) -> ComposedDetailPass {
|
||||
let prefix = format!("{}_{index}", crate::operation::sanitise(id));
|
||||
|
||||
@@ -772,41 +768,13 @@ fn compose_one(
|
||||
let _ = writeln!(helper_src, "{}\n", h.source.trim_end());
|
||||
}
|
||||
|
||||
// The storage format and the tail are the *only* difference between an
|
||||
// intermediate pass and the final one. Everything above — the taps, the
|
||||
// uniforms, the body — is identical, which is what lets an operation write
|
||||
// one kernel without knowing whether it happens to be last in the chain.
|
||||
let (store_format, tail) = if writes_output {
|
||||
(
|
||||
"rgba8unorm",
|
||||
format!(
|
||||
"{} // Clip to the output gamut and encode. The one quantisation\n\
|
||||
\x20 // the pipeline performs (FR-DEV-2), and it is here rather than\n\
|
||||
\x20 // in the fused pass because this is now the last thing to run.\n\
|
||||
\x20 c = clamp(c, vec3<f32>(0.0), vec3<f32>(1.0));\n\
|
||||
\x20 textureStore(output, coord, vec4<f32>(encode_output(c), 1.0));",
|
||||
crate::operation::primaries_conversion(output)
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"rgba16float",
|
||||
" // Another linear intermediate: no clip and no encode, because\n\
|
||||
\x20 // the pass after this one still has to read real values.\n\
|
||||
\x20 //\n\
|
||||
\x20 // `aux` rides in alpha. A pass that never touches it hands on\n\
|
||||
\x20 // whatever it was given, so the lane costs an operation that\n\
|
||||
\x20 // does not want it exactly one copy of a value it already read.\n\
|
||||
\x20 textureStore(output, coord, vec4<f32>(c, aux));"
|
||||
.to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
let encode_fn = if writes_output {
|
||||
crate::operation::encode_output_fn(output)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
// Every pass writes another linear intermediate: no clip and no encode,
|
||||
// because the view pass after the last one still has to read real values
|
||||
// (D19). `aux` rides in alpha. A pass that never touches it hands on
|
||||
// whatever it was given, so the lane costs an operation that does not want
|
||||
// it exactly one copy of a value it already read.
|
||||
let store_format = "rgba16float";
|
||||
let tail = " textureStore(output, coord, vec4<f32>(c, aux));";
|
||||
|
||||
let label = format!("{id}/{}", pass.label);
|
||||
let indented = body
|
||||
@@ -823,7 +791,7 @@ fn compose_one(
|
||||
// way back to a coordinate.
|
||||
//
|
||||
// In: linear sRGB, scene-referred, **unclipped**, at render resolution.
|
||||
// Out: {}
|
||||
// Out: the same, for the next pass or for the view pass after the last.
|
||||
|
||||
struct Params {{
|
||||
{uniform_fields}}}
|
||||
@@ -907,7 +875,7 @@ fn reduced_at(coord: vec2<i32>) -> f32 {{
|
||||
return mix(mix(s00, s10, f.x), mix(s01, s11, f.x), f.y);
|
||||
}}
|
||||
|
||||
{helper_src}{encode_fn}
|
||||
{helper_src}
|
||||
@compute @workgroup_size(8, 8, 1)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
||||
let dims = textureDimensions(output);
|
||||
@@ -936,12 +904,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
||||
|
||||
{tail}
|
||||
}}
|
||||
",
|
||||
if writes_output {
|
||||
"display-encoded, in the output space."
|
||||
} else {
|
||||
"linear sRGB, for the next pass."
|
||||
},
|
||||
"
|
||||
);
|
||||
|
||||
let structure_hash = crate::operation::hash_source(&source);
|
||||
@@ -956,7 +919,6 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
||||
// dispatch size and a declaration is data, which since FR-PLG-2 can
|
||||
// come from a file this build did not write.
|
||||
output_scale: pass.output_scale.max(1),
|
||||
writes_output,
|
||||
structure_hash,
|
||||
}
|
||||
}
|
||||
@@ -1013,6 +975,21 @@ mod tests {
|
||||
assert!((export.frame_fraction(0.01) - 40.0).abs() < 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_fraction_does_not_shrink_with_the_zoom_or_the_tile() {
|
||||
// TRACES: FR-DSP-1 | FR-DSP-2
|
||||
// A 6000×4000 frame. At fit in a 1500×1000 panel, 1% of it is 10
|
||||
// render pixels; zoomed to 1:1 on a 1500×1000 corner of it, the same
|
||||
// 1% is 40 — the 40 the file gets — and an export tile of that corner
|
||||
// must say 40 too, or each tile draws its own halo and the seams show.
|
||||
let fit = RenderScale::new((1500, 1000), (6000, 4000));
|
||||
assert!((fit.frame_fraction(0.01) - 10.0).abs() < 1e-3);
|
||||
let zoomed = RenderScale::new((1500, 1000), (1500, 1000)).within((6000, 4000));
|
||||
assert!((zoomed.frame_fraction(0.01) - 40.0).abs() < 1e-3);
|
||||
let tile = RenderScale::full((1024, 1024)).within((6000, 4000));
|
||||
assert!((tile.frame_fraction(0.01) - 40.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zooming_to_one_to_one_makes_the_preview_exact() {
|
||||
// The reason there is no separate full-resolution preview path: the
|
||||
@@ -1095,27 +1072,19 @@ mod tests {
|
||||
// dispatch. An unedited photograph must not pay for a sharpener it is
|
||||
// not using.
|
||||
let ops = with_blur(0.0);
|
||||
let composed = compose_detail(
|
||||
&ops,
|
||||
RenderScale::full((512, 512)),
|
||||
dr_types::ColourSpace::Srgb,
|
||||
);
|
||||
let composed = compose_detail(&ops, RenderScale::full((512, 512)));
|
||||
assert!(composed.is_empty());
|
||||
assert_eq!(fused(&ops).output_mode, OutputMode::Encoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_separable_blur_becomes_two_passes_and_only_the_last_encodes() {
|
||||
// The multi-pass case, which is the one the ping-pong exists for. The
|
||||
// first pass writes a linear intermediate and the second writes the
|
||||
// display texture — so the output transform happens exactly once, at
|
||||
// the end, wherever the end happens to be.
|
||||
fn a_separable_blur_becomes_two_passes_and_neither_encodes() {
|
||||
// The multi-pass case, which is the one the ping-pong exists for. Both
|
||||
// passes write linear intermediates, and the fused pass's view pass
|
||||
// reads the second and performs the view transform and the output
|
||||
// transform — so those happen exactly once, after every kernel (D19).
|
||||
let ops = with_blur(0.05);
|
||||
let composed = compose_detail(
|
||||
&ops,
|
||||
RenderScale::full((512, 512)),
|
||||
dr_types::ColourSpace::Srgb,
|
||||
);
|
||||
let composed = compose_detail(&ops, RenderScale::full((512, 512)));
|
||||
assert_eq!(composed.len(), 2);
|
||||
|
||||
let first = &composed.passes[0];
|
||||
@@ -1123,13 +1092,16 @@ mod tests {
|
||||
assert_eq!(first.label, "detail_probe/horizontal");
|
||||
assert_eq!(last.label, "detail_probe/vertical");
|
||||
|
||||
assert!(!first.writes_output);
|
||||
assert!(first.source.contains("texture_storage_2d<rgba16float"));
|
||||
assert!(!first.source.contains("fn encode_output"));
|
||||
|
||||
assert!(last.writes_output);
|
||||
assert!(last.source.contains("texture_storage_2d<rgba8unorm"));
|
||||
assert!(last.source.contains("fn encode_output"));
|
||||
for pass in [first, last] {
|
||||
assert!(pass.source.contains("texture_storage_2d<rgba16float"));
|
||||
assert!(!pass.source.contains("fn encode_output"));
|
||||
assert!(!pass.source.contains("view_sigmoid"));
|
||||
}
|
||||
let view = fused(&ops)
|
||||
.view
|
||||
.expect("a view pass follows the detail stage");
|
||||
assert!(view.source.contains("fn encode_output"));
|
||||
assert!(view.source.contains("c = view_sigmoid("));
|
||||
|
||||
// Two passes of one operation are two shaders, so they must not share
|
||||
// a pipeline-cache entry — the classic way a second pass silently runs
|
||||
@@ -1143,11 +1115,7 @@ mod tests {
|
||||
// and the composer rewrites it to a prefixed struct field, so two
|
||||
// operations may both call a uniform `radius` and neither has to know.
|
||||
let ops = with_blur(0.05);
|
||||
let composed = compose_detail(
|
||||
&ops,
|
||||
RenderScale::full((512, 512)),
|
||||
dr_types::ColourSpace::Srgb,
|
||||
);
|
||||
let composed = compose_detail(&ops, RenderScale::full((512, 512)));
|
||||
let src = &composed.passes[0].source;
|
||||
assert!(src.contains("detail_probe_0_radius: f32,"));
|
||||
assert!(src.contains("let r = i32(u.detail_probe_0_radius);"));
|
||||
@@ -1164,13 +1132,7 @@ mod tests {
|
||||
// outright by the WGSL uniform address space rules, and the failure
|
||||
// arrives as a shader compilation error against generated source.
|
||||
let ops = with_blur(0.05);
|
||||
for pass in compose_detail(
|
||||
&ops,
|
||||
RenderScale::full((512, 512)),
|
||||
dr_types::ColourSpace::Srgb,
|
||||
)
|
||||
.passes
|
||||
{
|
||||
for pass in compose_detail(&ops, RenderScale::full((512, 512))).passes {
|
||||
assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label);
|
||||
assert!(pass.uniforms.iter().all(|v| v.is_finite()));
|
||||
// The base block is first and fixed, so a pass never addresses a
|
||||
@@ -1189,7 +1151,7 @@ mod tests {
|
||||
// the truth rather than zero.
|
||||
let ops = with_blur(0.05);
|
||||
let scale = RenderScale::full((400, 400));
|
||||
let composed = compose_detail(&ops, scale, dr_types::ColourSpace::Srgb);
|
||||
let composed = compose_detail(&ops, scale);
|
||||
let expected = BoxBlur::with_radius(0.05).kernel(scale);
|
||||
assert_eq!(expected, 20, "5% of a 400px edge");
|
||||
assert_eq!(composed.radius(), expected);
|
||||
@@ -1208,7 +1170,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|&(w, h)| {
|
||||
let scale = RenderScale::full((w, h));
|
||||
let composed = compose_detail(&ops, scale, dr_types::ColourSpace::Srgb);
|
||||
let composed = compose_detail(&ops, scale);
|
||||
composed.radius() as f32 / w.min(h) as f32
|
||||
})
|
||||
.collect();
|
||||
@@ -1226,14 +1188,14 @@ mod tests {
|
||||
// cannot see each other. `compose_full` decides to hand on linear
|
||||
// working values from the *operations* — it has no resolution to
|
||||
// consult — while this composer converts a radius and can legitimately
|
||||
// decide there is nothing to draw at this size. An empty chain would
|
||||
// then leave the output transform undone: the fused pass writes
|
||||
// `rgba16float` and the frontend binds an `rgba8unorm` target to it.
|
||||
// decide there is nothing to draw at this size.
|
||||
//
|
||||
// A photographer meets this by turning on capture sharpening or
|
||||
// luminance noise reduction while the develop view is fitted to a
|
||||
// large file, which is the normal way to work, so it is not an edge
|
||||
// case that can be left to fail.
|
||||
// large file, which is the normal way to work. Before D19 an empty
|
||||
// chain left the output transform undone and needed a resolve pass;
|
||||
// now the view pass does the output transform whatever the chain
|
||||
// holds.
|
||||
let ops = with_blur(0.001);
|
||||
let scale = RenderScale::full((400, 400));
|
||||
assert!(ops.last().expect("the blur").is_active());
|
||||
@@ -1243,74 +1205,59 @@ mod tests {
|
||||
"the premise: a radius too small to draw emits no pass"
|
||||
);
|
||||
|
||||
let composed = compose_detail(&ops, scale, dr_types::ColourSpace::Srgb);
|
||||
assert_eq!(composed.len(), 1, "the chain must not be empty here");
|
||||
assert_eq!(composed.radius(), 0, "it reads only the pixel it writes");
|
||||
|
||||
let resolve = &composed.passes[0];
|
||||
assert_eq!(resolve.label, "detail/resolve");
|
||||
assert!(resolve.writes_output);
|
||||
assert!(resolve.source.contains("texture_storage_2d<rgba8unorm"));
|
||||
assert!(resolve.source.contains("fn encode_output"));
|
||||
// Exactly the fixed base block and no more: a pass with no body has
|
||||
// nothing of its own to upload, and the block still has to be a
|
||||
// multiple of sixteen bytes.
|
||||
assert_eq!(resolve.uniforms.len(), DETAIL_BASE_UNIFORM_FIELDS);
|
||||
assert_eq!(resolve.uniforms.len() % 4, 0);
|
||||
|
||||
// And it really is a copy: the fused pass composed alongside it is the
|
||||
// one that stopped short, so the two agree about who encodes.
|
||||
assert_eq!(fused(&ops).output_mode, OutputMode::LinearWorking);
|
||||
// Empty, and that is fine since D19: nothing in the chain encodes, so
|
||||
// there is no output transform for an empty chain to leave undone.
|
||||
// The fused pass stopped at linear values and its view pass reads
|
||||
// them directly.
|
||||
let composed = compose_detail(&ops, scale);
|
||||
assert!(composed.is_empty());
|
||||
let fused = fused(&ops);
|
||||
assert_eq!(fused.output_mode, OutputMode::LinearWorking);
|
||||
let view = fused
|
||||
.view
|
||||
.expect("the view pass performs the output transform");
|
||||
assert_eq!(view.output_mode, OutputMode::Encoded);
|
||||
assert!(view.source.contains("fn encode_output"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pass_that_changes_nothing_is_dropped_where_that_is_exact() {
|
||||
// TRACES: NFR-P5
|
||||
// Capture sharpening at a scale too coarse to draw its radius emits a
|
||||
// pass with an empty body. Between two other passes it costs a
|
||||
// render-sized read and write and changes no texel, so it goes; as the
|
||||
// last pass it performs the output transform on the intermediate, and
|
||||
// moving that onto the pass before would round differently, so it
|
||||
// stays.
|
||||
use crate::ops::{capture_sharpen, CaptureSharpen, NoiseReduction};
|
||||
let sharpen = || -> Box<dyn Operation> {
|
||||
let mut op = CaptureSharpen::new();
|
||||
op.set_param(capture_sharpen::AMOUNT, 60.0);
|
||||
Box::new(op)
|
||||
};
|
||||
// A pass with an empty body costs a render-sized read and write and
|
||||
// changes no texel, so it goes — wherever it falls since D19, the last
|
||||
// position included, because the last pass writes an intermediate like
|
||||
// every other and the view pass reads whichever the chain last wrote.
|
||||
// Built by hand, and run as a repair so it goes first: no operation
|
||||
// emits one any more (capture sharpening at a scale too coarse to draw
|
||||
// its radius used to, and now emits nothing).
|
||||
use crate::ops::NoiseReduction;
|
||||
let chroma = || -> Box<dyn Operation> { Box::new(NoiseReduction::with_amounts(0.0, 60.0)) };
|
||||
// A 24 MP frame fitted to a panel: a one-source-pixel radius is a
|
||||
// quarter of a render pixel.
|
||||
let scale = RenderScale::new((1500, 1000), (6000, 4000));
|
||||
let unresolved = sharpen().detail().expect("a detail stage").passes(scale);
|
||||
assert!(
|
||||
unresolved.len() == 1 && unresolved[0].is_identity(),
|
||||
"the premise: sharpening at this scale is one pass that does nothing"
|
||||
);
|
||||
let labels = |ops: &[Box<dyn Operation>]| -> Vec<String> {
|
||||
compose_detail(ops, scale, dr_types::ColourSpace::Srgb)
|
||||
let nothing = DetailPass {
|
||||
output_scale: 1,
|
||||
label: "nothing",
|
||||
radius: 0,
|
||||
wgsl: "// `c` already holds this pixel.".to_string(),
|
||||
uniforms: Vec::new(),
|
||||
storage: Vec::new(),
|
||||
};
|
||||
assert!(nothing.is_identity(), "the premise");
|
||||
let labels: Vec<String> =
|
||||
compose_detail_with(&[chroma()], std::slice::from_ref(¬hing), scale)
|
||||
.passes
|
||||
.iter()
|
||||
.map(|p| p.label.clone())
|
||||
.collect()
|
||||
};
|
||||
|
||||
// First, ahead of the chroma passes: dropped.
|
||||
let first = labels(&[sharpen(), chroma()]);
|
||||
.collect();
|
||||
assert_eq!(
|
||||
first,
|
||||
labels,
|
||||
[
|
||||
"noise_reduction/chroma-horizontal",
|
||||
"noise_reduction/chroma-vertical"
|
||||
]
|
||||
);
|
||||
// Last, after them: kept, and it is the pass that encodes.
|
||||
let last = labels(&[chroma(), sharpen()]);
|
||||
assert_eq!(last.len(), 3);
|
||||
assert_eq!(last[2], "capture_sharpen/unresolved");
|
||||
// Alone: kept, because the fused pass stopped short and something has
|
||||
// to finish the frame.
|
||||
assert_eq!(labels(&[sharpen()]), ["capture_sharpen/unresolved"]);
|
||||
// Alone: dropped too, and the chain is empty — the view pass
|
||||
// finishes the frame.
|
||||
assert!(compose_detail_with(&[], &[nothing], scale).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1325,11 +1272,7 @@ mod tests {
|
||||
// A pass that says nothing about `aux` hands on what it was given,
|
||||
// which is why the box blur below needs no knowledge of it.
|
||||
let ops = with_blur(0.05);
|
||||
let composed = compose_detail(
|
||||
&ops,
|
||||
RenderScale::full((512, 512)),
|
||||
dr_types::ColourSpace::Srgb,
|
||||
);
|
||||
let composed = compose_detail(&ops, RenderScale::full((512, 512)));
|
||||
|
||||
for pass in &composed.passes {
|
||||
assert!(
|
||||
@@ -1344,11 +1287,12 @@ mod tests {
|
||||
.contains("textureStore(output, coord, vec4<f32>(c, aux));"),
|
||||
"an intermediate must carry the lane to the pass after it"
|
||||
);
|
||||
// The last pass writes the display texture, whose alpha is opacity and
|
||||
// not scratch space. Readable there, not written — which is the right
|
||||
// way round, because the combining pass is the one that reads it.
|
||||
assert!(composed.passes[1].writes_output);
|
||||
assert!(!composed.passes[1].source.contains("vec4<f32>(c, aux)"));
|
||||
// The last pass carries it too: since D19 it writes an intermediate
|
||||
// for the view pass rather than the display texture, whose alpha is
|
||||
// opacity. The view pass reads only the colour.
|
||||
assert!(composed.passes[1]
|
||||
.source
|
||||
.contains("textureStore(output, coord, vec4<f32>(c, aux));"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1357,11 +1301,7 @@ mod tests {
|
||||
// overwhelmingly common edit: no sharpening means no chain, which
|
||||
// means `dr-gpu` runs the single fused dispatch it always did.
|
||||
let ops = crate::ops::chain();
|
||||
let composed = compose_detail(
|
||||
&ops,
|
||||
RenderScale::full((64, 64)),
|
||||
dr_types::ColourSpace::Srgb,
|
||||
);
|
||||
let composed = compose_detail(&ops, RenderScale::full((64, 64)));
|
||||
assert!(composed.is_empty());
|
||||
assert_eq!(composed.radius(), 0);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -1298,7 +1311,8 @@ impl Framing {
|
||||
// count active stages, and a neutral graph must generate none.
|
||||
if !self.is_active() {
|
||||
return " // Source position, normalised and centred: the whole frame, unrotated.
|
||||
let src_dims = textureDimensions(source);
|
||||
let tex_dims = textureDimensions(source);
|
||||
let src_dims = select(tex_dims, vec2<u32>(u.source_full.xy), u.source_full.x > 0.5);
|
||||
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
|
||||
let uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
|
||||
var p = (uv - vec2<f32>(0.5)) * aspect;
|
||||
@@ -1314,7 +1328,8 @@ impl Framing {
|
||||
// warp chain expects: the centre is (0, 0) and the radius is 1 at the
|
||||
// corner. Working here rather than in pixels is what makes the map
|
||||
// independent of the resolution being rendered at.
|
||||
let src_dims = textureDimensions(source);
|
||||
let tex_dims = textureDimensions(source);
|
||||
let src_dims = select(tex_dims, vec2<u32>(u.source_full.xy), u.source_full.x > 0.5);
|
||||
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
|
||||
var uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
|
||||
",
|
||||
@@ -2253,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
|
||||
|
||||
+212
-25
@@ -83,6 +83,13 @@ impl ParamCapability {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-2
|
||||
/// How far past the framing's own footprint [`EditGraph::source_region`]
|
||||
/// reaches when a lens warp is active, as a fraction of the frame on each
|
||||
/// side. Distortion profiles move a corner by a few per cent of the frame; a
|
||||
/// window short of what the warp reads would render the missing strip black.
|
||||
pub const WARP_MARGIN: f32 = 0.04;
|
||||
|
||||
/// An ordered pipeline of operations, plus how the result is framed.
|
||||
pub struct EditGraph {
|
||||
ops: Vec<Box<dyn Operation>>,
|
||||
@@ -163,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
|
||||
@@ -219,6 +238,9 @@ impl EditGraph {
|
||||
],
|
||||
lens_profile: None,
|
||||
lens_profile_applied: true,
|
||||
denoise_available: false,
|
||||
denoise_applied: false,
|
||||
denoise_grain: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +314,57 @@ impl EditGraph {
|
||||
self.framing.output_size(width, height)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-2 | NFR-RES-2
|
||||
/// The part of the source the visible region reads, as a rectangle in
|
||||
/// normalised source coordinates, clamped to the frame.
|
||||
///
|
||||
/// For a photograph larger than one texture: a render of part of it —
|
||||
/// the canvas zoomed in, one tile of an export — binds only this window
|
||||
/// of the source (see `dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS`).
|
||||
///
|
||||
/// The framing is walked on the CPU with [`Framing::source_at`], along
|
||||
/// the border and across the interior, so a straightened or keystoned
|
||||
/// view gets the box around the quadrilateral it actually reads. The lens
|
||||
/// warps have no CPU mirror, so when one is active the box is widened
|
||||
/// by [`WARP_MARGIN`] of the frame on each side: a distortion profile
|
||||
/// moves a corner by a few per cent of the frame at most. `halo`, in
|
||||
/// source pixels, is added on top — the detail stage's reach, which reads
|
||||
/// beyond the pixels it writes.
|
||||
pub fn source_region(&self, source: (u32, u32), halo: u32) -> crate::framing::CropRect {
|
||||
const STEPS: usize = 16;
|
||||
let (sw, sh) = (source.0.max(1), source.1.max(1));
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
|
||||
for j in 0..=STEPS {
|
||||
for i in 0..=STEPS {
|
||||
let out = (i as f32 / STEPS as f32, j as f32 / STEPS as f32);
|
||||
let (x, y) = self.framing.source_at(out, sw, sh);
|
||||
x0 = x0.min(x);
|
||||
y0 = y0.min(y);
|
||||
x1 = x1.max(x);
|
||||
y1 = y1.max(y);
|
||||
}
|
||||
}
|
||||
let warp = if crate::lens::compose_warps(&self.warps).is_active() {
|
||||
WARP_MARGIN
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Two pixels beyond the halo: the bilinear tap's second texel, and
|
||||
// the rounding of the box to whole pixels by the caller.
|
||||
let px = (halo as f32 + 2.0) / sw as f32;
|
||||
let py = (halo as f32 + 2.0) / sh as f32;
|
||||
let x0 = (x0 - warp - px).clamp(0.0, 1.0);
|
||||
let y0 = (y0 - warp - py).clamp(0.0, 1.0);
|
||||
let x1 = (x1 + warp + px).clamp(0.0, 1.0);
|
||||
let y1 = (y1 + warp + py).clamp(0.0, 1.0);
|
||||
crate::framing::CropRect {
|
||||
x: x0,
|
||||
y: y0,
|
||||
width: (x1 - x0).max(0.0),
|
||||
height: (y1 - y0).max(0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Descriptors for every operation, in order.
|
||||
///
|
||||
/// Operations only — framing is not one, and is reached through
|
||||
@@ -341,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 {
|
||||
@@ -523,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()
|
||||
@@ -617,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,
|
||||
@@ -688,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");
|
||||
@@ -745,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 });
|
||||
@@ -791,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.
|
||||
@@ -952,46 +1101,35 @@ impl EditGraph {
|
||||
((fw as f32 * view.width).round() as u32).max(1),
|
||||
((fh as f32 * view.height).round() as u32).max(1),
|
||||
);
|
||||
crate::detail::RenderScale::new(render, full)
|
||||
crate::detail::RenderScale::new(render, full).within((fw, fh))
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3 | FR-DSP-1
|
||||
/// Generate the detail stage for this edit at one resolution, to sRGB.
|
||||
/// Generate the detail stage for this edit at one resolution.
|
||||
///
|
||||
/// Empty for every edit with no active neighbourhood operation, which is
|
||||
/// almost all of them — and in that case [`Self::compose`] emits the
|
||||
/// single encoded dispatch it always has.
|
||||
pub fn compose_detail(
|
||||
&self,
|
||||
source: (u32, u32),
|
||||
render: (u32, u32),
|
||||
) -> crate::detail::ComposedDetail {
|
||||
self.compose_detail_for(source, render, dr_types::ColourSpace::Srgb)
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-2
|
||||
/// The detail stage, encoded into a chosen output space.
|
||||
///
|
||||
/// The space belongs here as well as on [`Self::compose_for`] because when
|
||||
/// a detail stage exists it is the *last* pass that performs the output
|
||||
/// transform — the fused pass stops at linear working values. Composing
|
||||
/// the two halves for different spaces would encode the edit twice, or
|
||||
/// not at all.
|
||||
/// No output space: since D19 no detail pass encodes. The fused pass's
|
||||
/// view pass reads what the last one wrote and performs the view transform
|
||||
/// and the output transform, so it is [`Self::compose_for`] alone that
|
||||
/// names the space.
|
||||
///
|
||||
/// `source` is the demosaiced image's size and `render` the size being
|
||||
/// drawn. The scale is worked out here rather than handed in, because the
|
||||
/// repairs need the *source* size as well — a spot is stored in normalised
|
||||
/// source coordinates and has to be put through the framing to find out
|
||||
/// where it lands on this render, and a [`crate::detail::RenderScale`]
|
||||
/// describes the region on screen rather than the photograph.
|
||||
pub fn compose_detail_for(
|
||||
pub fn compose_detail(
|
||||
&self,
|
||||
source: (u32, u32),
|
||||
render: (u32, u32),
|
||||
output: dr_types::ColourSpace,
|
||||
) -> crate::detail::ComposedDetail {
|
||||
let scale = self.render_scale(source, render);
|
||||
let spots = self.spots.passes(&self.framing, source, scale);
|
||||
crate::detail::compose_detail_with(&self.ops, &spots, scale, output)
|
||||
crate::detail::compose_detail_with(&self.ops, &spots, scale)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3d
|
||||
@@ -1122,13 +1260,23 @@ mod tests {
|
||||
fn a_fresh_graph_is_neutral() {
|
||||
// Opening an unedited image must produce the image, not an
|
||||
// interpretation of it.
|
||||
//
|
||||
// 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!(
|
||||
g.compose().source.matches("---- ").count(),
|
||||
0,
|
||||
"a neutral graph must generate no operation blocks"
|
||||
source.matches("---- ").count(),
|
||||
2,
|
||||
"a neutral graph must generate no adjustment blocks"
|
||||
);
|
||||
assert!(source.contains("---- camera_profile ----"));
|
||||
assert!(source.contains("---- view_transform ----"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1207,13 +1355,16 @@ mod tests {
|
||||
#[test]
|
||||
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.
|
||||
// available must generate a shader doing exactly two things — and
|
||||
// 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(), 2);
|
||||
assert_eq!(shader.source.matches("---- ").count(), 4);
|
||||
assert!(shader.source.contains("---- view_transform ----"));
|
||||
assert!(shader.source.contains("---- exposure ----"));
|
||||
assert!(shader.source.contains("---- white_balance ----"));
|
||||
assert!(!shader.source.contains("---- saturation ----"));
|
||||
@@ -1935,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;
|
||||
@@ -50,6 +52,8 @@ pub mod preset;
|
||||
pub mod sidecar;
|
||||
pub mod spot;
|
||||
pub mod state;
|
||||
pub mod tiles;
|
||||
pub mod view;
|
||||
|
||||
pub use coverage::Coverage;
|
||||
pub use declared::{Declaration, DeclaredOp};
|
||||
@@ -66,8 +70,8 @@ pub use history::{Edit, Entry as HistoryEntry, History, Step};
|
||||
pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
|
||||
pub use operation::{
|
||||
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
|
||||
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, CLIP_ONSET,
|
||||
RESERVED_UNIFORM_FIELDS, SAMPLE_CACHE_UNIFORM_OFFSET,
|
||||
OutputMode, Stage, Uniform, CLIP_ONSET, RESERVED_UNIFORM_FIELDS, SAMPLE_CACHE_UNIFORM_OFFSET,
|
||||
SOURCE_WINDOW_UNIFORM_FIELDS, SOURCE_WINDOW_UNIFORM_OFFSET, WHOLE_SOURCE_WINDOW,
|
||||
};
|
||||
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Reach, Scope};
|
||||
pub use sidecar::{Sidecar, Version};
|
||||
@@ -113,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.
|
||||
@@ -169,7 +183,21 @@ mod tests {
|
||||
let mut fused_blocks = 0;
|
||||
for desc in g.descriptors() {
|
||||
let id = desc.id.0;
|
||||
let point = shader.source.contains(&format!("---- {id} ----"));
|
||||
// The film is loaded here, and a stock is a rendering: the view
|
||||
// transform it replaces is correctly in neither stage (FR-DEV-3f,
|
||||
// FR-DEV-3j).
|
||||
if id == crate::ops::view_transform::ID.0 {
|
||||
assert!(!shader.source.contains("---- view_transform ----"));
|
||||
continue;
|
||||
}
|
||||
// A view operation is in the view pass when a detail stage
|
||||
// follows, which it does here (D19).
|
||||
let block = format!("---- {id} ----");
|
||||
let point = shader.source.contains(&block)
|
||||
|| shader
|
||||
.view
|
||||
.as_ref()
|
||||
.is_some_and(|v| v.source.contains(&block));
|
||||
let neighbourhood = detail
|
||||
.passes
|
||||
.iter()
|
||||
@@ -182,8 +210,12 @@ mod tests {
|
||||
);
|
||||
fused_blocks += usize::from(point);
|
||||
}
|
||||
let view_blocks = shader
|
||||
.view
|
||||
.as_ref()
|
||||
.map_or(0, |v| v.source.matches("---- ").count());
|
||||
assert_eq!(
|
||||
shader.source.matches("---- ").count(),
|
||||
shader.source.matches("---- ").count() + view_blocks,
|
||||
fused_blocks,
|
||||
"the fused shader carries a block nothing in the chain asked for"
|
||||
);
|
||||
|
||||
@@ -2068,8 +2068,8 @@ pub(crate) struct LayerShader {
|
||||
///
|
||||
/// Kept apart from the rest because it belongs at the other end of the
|
||||
/// shader. Everything else runs on scene-referred colour in the working
|
||||
/// space, where a flat tint would then be pushed through the base curve
|
||||
/// and the camera matrix and arrive as some other colour, and a
|
||||
/// space, where a flat tint would then be pushed through the view
|
||||
/// transform and arrive as some other colour, and a
|
||||
/// white-on-black alpha would arrive as neither. This runs after the
|
||||
/// output transform, so what is written is what is seen.
|
||||
pub reveal: String,
|
||||
|
||||
@@ -82,8 +82,8 @@ const FLOOR: f32 = 1e-4;
|
||||
/// Move the graph so that `sample` renders neutral.
|
||||
///
|
||||
/// `sample` is the linear triple the operation's own gains multiply — camera
|
||||
/// RGB with the camera's as-shot balance on, *before* the body's base curve
|
||||
/// and matrix, and with the sampling operation at its defaults. Not the
|
||||
/// RGB with the camera's as-shot balance on, *before* the camera matrix and
|
||||
/// the view transform, and with the sampling operation at its defaults. Not the
|
||||
/// pixel on the screen: the matrix mixes the channels on the way there, so
|
||||
/// a colour read after it does not answer to these gains, and a solve over
|
||||
/// one lands somewhere no sample asked for. Returns whether the graph was
|
||||
|
||||
+539
-338
File diff suppressed because it is too large
Load Diff
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -347,8 +347,15 @@ impl Operation for CaptureSharpen {
|
||||
|
||||
impl DetailStage for CaptureSharpen {
|
||||
fn passes(&self, scale: RenderScale) -> Vec<DetailPass> {
|
||||
// A radius finer than one pixel of this render: the detail it would
|
||||
// act on is not in this texture — it was lost to the downscale before
|
||||
// this stage ran (FR-DSP-1). Guessing at it would put sharpening on
|
||||
// screen that the exported file will not contain, so there is no
|
||||
// pass, and the interface is free to say `zoom to 1:1`. An empty
|
||||
// chain is a whole render since D19: the fused pass's view pass
|
||||
// performs the output transform whatever the chain holds.
|
||||
if !self.resolves(scale) {
|
||||
return vec![nothing_to_sharpen()];
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let extent = self.kernel(scale);
|
||||
@@ -396,41 +403,6 @@ impl DetailStage for CaptureSharpen {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pass emitted when the radius is finer than a render pixel.
|
||||
///
|
||||
/// One dispatch that changes nothing, rather than an empty chain, and the
|
||||
/// difference is not stylistic. [`crate::operation::compose_full`] decides
|
||||
/// from the *operations* — before any resolution is known — that an active
|
||||
/// detail operation means the fused pass hands on unclipped linear values
|
||||
/// instead of encoding its own output. If this returned no passes at all,
|
||||
/// that decision would still stand and nothing downstream would ever perform
|
||||
/// the output transform: `dr-gpu` would be handed a linear-working shader
|
||||
/// with an empty chain and refuse it.
|
||||
///
|
||||
/// So the honest "nothing survives at this scale" still has to carry the
|
||||
/// encode, and one pass that does only that is exactly the resolve step the
|
||||
/// stage would otherwise need. It costs a single copy of a proxy-sized
|
||||
/// texture, which is a rounding error against the dispatches around it.
|
||||
fn nothing_to_sharpen() -> DetailPass {
|
||||
DetailPass {
|
||||
output_scale: 1,
|
||||
label: "unresolved",
|
||||
// Reads only the pixel it writes, so a tile needs no halo at all.
|
||||
radius: 0,
|
||||
// A convolution, not a list: nothing to bind at binding 3.
|
||||
storage: Vec::new(),
|
||||
uniforms: Vec::new(),
|
||||
wgsl: "// The chosen radius is finer than one pixel of this render, so the detail
|
||||
// it would act on is not in this texture — it was lost to the downscale
|
||||
// before this stage ran (FR-DSP-1). Guessing at it would put sharpening on
|
||||
// screen that the exported file will not contain, so this pass passes the
|
||||
// colour through unchanged and the interface is free to say `zoom to 1:1`.
|
||||
//
|
||||
// `c` already holds this pixel; leaving it alone is the whole body."
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One axis of the separable unsharp mask.
|
||||
///
|
||||
/// Emitted verbatim for both passes — see [`DetailStage::passes`] for why the
|
||||
@@ -528,7 +500,6 @@ c = select(c, scaled, centre > 1e-5);"#;
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::EditGraph;
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
/// The develop chain with the sharpener turned up.
|
||||
///
|
||||
@@ -549,7 +520,7 @@ mod tests {
|
||||
// The scale is what these tests vary, so it is rebuilt into the two
|
||||
// sizes it stands for rather than handed over: a render of the full
|
||||
// frame at `render_size`, from a source of `full_size`.
|
||||
graph.compose_detail_for(scale.full_size(), scale.render_size(), ColourSpace::Srgb)
|
||||
graph.compose_detail(scale.full_size(), scale.render_size())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -591,13 +562,11 @@ mod tests {
|
||||
assert_eq!(first.label, "capture_sharpen/horizontal");
|
||||
assert_eq!(last.label, "capture_sharpen/vertical");
|
||||
|
||||
assert!(!first.writes_output);
|
||||
assert!(first.source.contains("texture_storage_2d<rgba16float"));
|
||||
assert!(!first.source.contains("fn encode_output"));
|
||||
|
||||
assert!(last.writes_output);
|
||||
assert!(last.source.contains("texture_storage_2d<rgba8unorm"));
|
||||
assert!(last.source.contains("fn encode_output"));
|
||||
// Neither encodes: the view pass after the detail stage does (D19).
|
||||
for pass in [first, last] {
|
||||
assert!(pass.source.contains("texture_storage_2d<rgba16float"));
|
||||
assert!(!pass.source.contains("fn encode_output"));
|
||||
}
|
||||
|
||||
// Two shaders, so two pipeline-cache entries. Sharing one would run
|
||||
// the horizontal pass's uniforms through the vertical pass's slots.
|
||||
@@ -723,20 +692,11 @@ mod tests {
|
||||
let proxy = RenderScale::new((1000, 1000), (4000, 4000));
|
||||
assert!(!proxy.resolves(1.0));
|
||||
|
||||
// Empty: since D19 nothing in the detail stage encodes, so a chain
|
||||
// with nothing to draw is a whole render — the fused pass's view pass
|
||||
// finishes it.
|
||||
let composed = chain_at(&graph, proxy);
|
||||
// Not empty, though. See `nothing_to_sharpen`: the fused pass has
|
||||
// already been composed to hand on linear values, so *something* must
|
||||
// still perform the output transform.
|
||||
assert_eq!(composed.len(), 1);
|
||||
assert_eq!(composed.radius(), 0, "it reads no neighbours");
|
||||
let pass = &composed.passes[0];
|
||||
assert_eq!(pass.label, "capture_sharpen/unresolved");
|
||||
assert!(pass.writes_output);
|
||||
assert!(pass.source.contains("fn encode_output"));
|
||||
assert!(
|
||||
!pass.source.contains("for (var i ="),
|
||||
"the pass-through must not walk a kernel it has decided not to run"
|
||||
);
|
||||
assert!(composed.is_empty());
|
||||
|
||||
// Zooming to 1:1 is what brings it back — the view rect shrinks while
|
||||
// the render target keeps its size — so there is no separate
|
||||
|
||||
@@ -488,6 +488,36 @@ fn curve_eval(
|
||||
}",
|
||||
};
|
||||
|
||||
/// TRACES: FR-DEV-2
|
||||
/// The curve continued past its last point, for scene values above the
|
||||
/// widget's axis.
|
||||
const CURVE_EXTEND: Helper = Helper {
|
||||
name: "curve_extend",
|
||||
source: "\
|
||||
// A five-point curve at `x`, continued past its last point along the slope of
|
||||
// its last span.
|
||||
//
|
||||
// The widget draws a 0..1 axis, and scene-referred values do not stop at 1
|
||||
// (D19): exposure and highlight recovery put them above it, and the view
|
||||
// transform after every operation is what brings them down. Flat past the last
|
||||
// point — which is what `curve_eval` gives, and what this curve did until
|
||||
// D19 — made every one of them the same number, a hard clip in the middle of
|
||||
// the chain. Continued along the last span instead, an identity curve stays
|
||||
// the identity to any height, and a curve that lifts the highlights keeps
|
||||
// lifting them. The slope is the last span's secant, which is also the
|
||||
// tangent `curve_eval` gives the last point, so the join is smooth; monotone
|
||||
// points make it non-negative.
|
||||
fn curve_extend(
|
||||
x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32,
|
||||
x3: f32, y3: f32, x4: f32, y4: f32, x: f32,
|
||||
) -> f32 {
|
||||
if (x <= x4) {
|
||||
return curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, x);
|
||||
}
|
||||
return y4 + (x - x4) * max((y4 - y3) / (x4 - x3), 0.0);
|
||||
}",
|
||||
};
|
||||
|
||||
/// One colour component through its own curve.
|
||||
const CHANNEL_CURVE: Helper = Helper {
|
||||
name: "channel_curve",
|
||||
@@ -504,20 +534,21 @@ const CHANNEL_CURVE: Helper = Helper {
|
||||
// it changes the proportions between the components, which is what makes it
|
||||
// chromatic where the master is tonal.
|
||||
//
|
||||
// The clamp is the curve's promise rather than an oversight: its last point
|
||||
// *is* white, so a component arriving above the axis takes the value the curve
|
||||
// gives at 1. The master does the same to a luminance above 1, through the
|
||||
// gain it applies; a channel curve that instead let highlights past unchanged
|
||||
// would tint them differently from every tone below them, which reads as a
|
||||
// coloured fringe along a blown edge.
|
||||
// Above the axis the curve continues along its last span (`curve_extend`),
|
||||
// exactly as the master's does, so a highlight is tinted the way every tone
|
||||
// just below it is — a component that stopped at the curve's top instead
|
||||
// would put a coloured fringe along a blown edge, and flattened every
|
||||
// scene-referred highlight into one value besides (D19). Below zero there is
|
||||
// no light to curve; the floor is the one clamp left, and it is at zero, not
|
||||
// at one.
|
||||
fn channel_curve(
|
||||
v: f32,
|
||||
x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32,
|
||||
x3: f32, y3: f32, x4: f32, y4: f32,
|
||||
) -> f32 {
|
||||
let encoded = pow(clamp(v, 0.0, 1.0), 1.0 / 2.2);
|
||||
let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
|
||||
return pow(clamp(curved, 0.0, 1.0), 2.2);
|
||||
let encoded = pow(max(v, 0.0), 1.0 / 2.2);
|
||||
let curved = curve_extend(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
|
||||
return pow(max(curved, 0.0), 2.2);
|
||||
}",
|
||||
};
|
||||
|
||||
@@ -535,10 +566,11 @@ static MASTER_HELPERS: &[Helper] = &[
|
||||
helpers::APPLY_TONE_GAIN,
|
||||
CURVE_SPAN,
|
||||
CURVE_EVAL,
|
||||
CURVE_EXTEND,
|
||||
];
|
||||
|
||||
/// The per-channel curves alone.
|
||||
static CHANNEL_HELPERS: &[Helper] = &[CURVE_SPAN, CURVE_EVAL, CHANNEL_CURVE];
|
||||
static CHANNEL_HELPERS: &[Helper] = &[CURVE_SPAN, CURVE_EVAL, CURVE_EXTEND, CHANNEL_CURVE];
|
||||
|
||||
/// Both.
|
||||
static ALL_HELPERS: &[Helper] = &[
|
||||
@@ -546,6 +578,7 @@ static ALL_HELPERS: &[Helper] = &[
|
||||
helpers::APPLY_TONE_GAIN,
|
||||
CURVE_SPAN,
|
||||
CURVE_EVAL,
|
||||
CURVE_EXTEND,
|
||||
CHANNEL_CURVE,
|
||||
];
|
||||
|
||||
@@ -553,16 +586,17 @@ static ALL_HELPERS: &[Helper] = &[
|
||||
const MASTER_BODY: &str = "\
|
||||
let luma = luminance(c);
|
||||
if (luma > 0.0001) {
|
||||
// The curve is authored on a display-referred 0..1 axis, which is where
|
||||
// the eye reads tone and where the widget's grid lives. Scene-referred
|
||||
// luminance is unbounded, so it is encoded to that axis, curved, and
|
||||
// decoded back — otherwise a point placed at the middle of the grid
|
||||
// would not correspond to the middle of the visible range.
|
||||
let encoded = pow(clamp(luma, 0.0, 1.0), 1.0 / 2.2);
|
||||
// The curve is authored on a 0..1 axis, which is where the widget's grid
|
||||
// lives, with a 2.2 gamma so that a point placed at the middle of the
|
||||
// grid means the middle of the visible range. Scene-referred luminance
|
||||
// does not stop at 1: above the axis the curve continues along its last
|
||||
// span (`curve_extend`) rather than clipping, because the view transform
|
||||
// after every operation is what brings a highlight down (D19).
|
||||
let encoded = pow(luma, 1.0 / 2.2);
|
||||
|
||||
let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
|
||||
let curved = curve_extend(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
|
||||
|
||||
let decoded = pow(clamp(curved, 0.0, 1.0), 2.2);
|
||||
let decoded = pow(max(curved, 0.0), 2.2);
|
||||
// Applied as a ratio so hue is preserved, exactly as contrast does.
|
||||
c = apply_tone_gain(c, decoded / luma);
|
||||
}";
|
||||
@@ -1288,7 +1322,10 @@ mod tests {
|
||||
c.set_param(P2_Y, 0.7);
|
||||
|
||||
let body = c.wgsl_body();
|
||||
assert!(body.contains("curve_eval("), "the master curve is missing");
|
||||
assert!(
|
||||
body.contains("curve_extend("),
|
||||
"the master curve is missing"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("channel_curve("),
|
||||
"an untouched channel reached the shader:\n{body}"
|
||||
|
||||
@@ -541,14 +541,13 @@ c = (c - lifted) / t;";
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::detail::compose_detail;
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
fn ops(amount: f32) -> Vec<Box<dyn Operation>> {
|
||||
vec![Box::new(Dehaze::with_amount(amount))]
|
||||
}
|
||||
|
||||
fn composed(amount: f32, scale: RenderScale) -> crate::ComposedDetail {
|
||||
compose_detail(&ops(amount), scale, ColourSpace::Srgb)
|
||||
compose_detail(&ops(amount), scale)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -658,11 +657,6 @@ mod tests {
|
||||
let split = Split::of(Dehaze::with_amount(60.0).patch(RenderScale::full((2000, 1500))));
|
||||
assert!(composed.passes.iter().all(|p| p.radius == split.extent()));
|
||||
|
||||
// Only the last writes the display texture, so the output transform
|
||||
// happens exactly once (FR-DEV-2).
|
||||
assert!(!composed.passes[0].writes_output);
|
||||
assert!(composed.passes[1].writes_output);
|
||||
|
||||
// Nothing here uses the reduced chain — see the module documentation
|
||||
// for why a second operation cannot pick its own `output_scale` while
|
||||
// the runner holds one reduced buffer.
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
//! TRACES: FR-DEV-3f
|
||||
//! Film simulation — the stock renders the picture.
|
||||
//!
|
||||
//! # Why this one replaces the base curve
|
||||
//! # Why this one is the view transform
|
||||
//!
|
||||
//! [`crate::ops`]' other nodes adjust a picture. This one *makes* it. The base
|
||||
//! curve exists because sensor data is scene-referred and nothing anybody looks
|
||||
//! at is (FR-DEV-3e); a film stock's characteristic curve does the same job,
|
||||
//! from measurements, with a toe and a shoulder that were coated onto acetate
|
||||
//! rather than drawn. Running both renders the image twice — the camera's
|
||||
//! JPEG-ish rendering, and then a film's rendering of that — which is not what
|
||||
//! [`crate::ops`]' other nodes adjust a picture. This one *makes* it. The view
|
||||
//! transform exists because sensor data is scene-referred and nothing anybody
|
||||
//! looks at is (FR-DEV-3j); a film stock's characteristic curve does the same
|
||||
//! job, from measurements, with a toe and a shoulder that were coated onto
|
||||
//! acetate rather than drawn. Running both renders the image twice — the
|
||||
//! default rendering, and then a film's rendering of that — which is not what
|
||||
//! either is for and looks like neither.
|
||||
//!
|
||||
//! So this node declares [`Operation::renders`], and the composer answers by
|
||||
//! emitting neither the base curve nor the camera matrix. Both jobs move here:
|
||||
//! the fragment takes camera RGB, converts it to linear sRGB itself with the
|
||||
//! matrix already in the uniform block, and returns linear sRGB. That is a
|
||||
//! contract worth stating plainly, because a node that got half of it wrong
|
||||
//! would produce a picture that renders perfectly and is wrong everywhere.
|
||||
//! So this node is in [`Stage::View`] and declares [`Operation::renders`]: when
|
||||
//! a stock is loaded the composer puts it at the end of the chain in place of
|
||||
//! the default sigmoid (D19). It is handed working-space colour — linear sRGB
|
||||
//! primaries, scene-referred, after every other operation and after the detail
|
||||
//! stage — and returns display-referred linear sRGB for the output transform.
|
||||
//! Before D19 it ran at order 25, after exposure and before everything else,
|
||||
//! and the operations below it acted on its output. They now act on the scene
|
||||
//! it is shown: an edit is a decision about the exposure the negative
|
||||
//! receives, and the film is the last thing that happens to the picture.
|
||||
//!
|
||||
//! # Why the tables are not parameters
|
||||
//!
|
||||
@@ -34,7 +37,7 @@
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||
use crate::operation::{Operation, Uniform};
|
||||
use crate::operation::{Operation, Stage, Uniform};
|
||||
|
||||
pub const ID: OpId = OpId("film_sim");
|
||||
pub const EXPOSURE: ParamId = ParamId("exposure");
|
||||
@@ -304,11 +307,17 @@ impl Operation for FilmSim {
|
||||
self.tables.is_some()
|
||||
}
|
||||
|
||||
/// This node renders; the camera's own rendering must not also run.
|
||||
/// This node renders; the default view transform must not also run.
|
||||
fn renders(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3f | FR-DEV-3j
|
||||
/// The view transform's place, at the end of the chain (D19).
|
||||
fn stage(&self) -> Stage {
|
||||
Stage::View
|
||||
}
|
||||
|
||||
fn set_film_tables(&mut self, tables: Option<&FilmTables>) {
|
||||
self.set_tables(tables.cloned());
|
||||
}
|
||||
@@ -396,14 +405,10 @@ impl Operation for FilmSim {
|
||||
// sampler binding, and adding one to interpolate two lookups would
|
||||
// cost a binding in every shader whether or not a film is loaded.
|
||||
"\
|
||||
// Camera RGB to linear sRGB. The film's exposure matrix is defined against
|
||||
// sRGB primaries, and this node has taken over the conversion the composer
|
||||
// would otherwise have emitted at the end — see `Operation::renders`.
|
||||
let scene = vec3<f32>(
|
||||
dot(u.cam_to_srgb_0.rgb, c),
|
||||
dot(u.cam_to_srgb_1.rgb, c),
|
||||
dot(u.cam_to_srgb_2.rgb, c),
|
||||
);
|
||||
// Working-space colour, which is linear sRGB primaries — what the film's
|
||||
// exposure matrix is defined against. The composer converted out of camera
|
||||
// RGB before any scene-stage operation ran (D19).
|
||||
let scene = c;
|
||||
|
||||
// What each emulsion layer was exposed to. A matrix, exactly: the scene
|
||||
// spectrum reconstructed from an sRGB triple is linear in that triple, so the
|
||||
@@ -701,10 +706,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn it_declares_itself_a_rendering_transform() {
|
||||
// The whole reason the composer skips the base curve and the camera
|
||||
// matrix. If this ever returned false the picture would be rendered
|
||||
// twice and converted twice, which looks like a colour management bug
|
||||
// a long way from here.
|
||||
// The whole reason the composer emits the stock in the view
|
||||
// transform's place rather than beside it. If this ever returned
|
||||
// false the picture would be rendered twice, which looks like a
|
||||
// colour management bug a long way from here.
|
||||
assert!(FilmSim::new().renders());
|
||||
}
|
||||
|
||||
@@ -727,13 +732,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fragment_converts_out_of_camera_space_itself() {
|
||||
// It has to: it has taken over the conversion the composer would
|
||||
// otherwise emit at the end.
|
||||
fn the_fragment_is_handed_working_space_colour() {
|
||||
// TRACES: FR-DEV-3f
|
||||
// D19: the composer leaves camera space before any scene-stage
|
||||
// operation, so a film converting again would apply the camera
|
||||
// matrix twice.
|
||||
let mut op = FilmSim::new();
|
||||
op.set_tables(Some(tables()));
|
||||
let wgsl = op.wgsl_body();
|
||||
assert!(wgsl.contains("cam_to_srgb_0"), "{wgsl}");
|
||||
assert!(!wgsl.contains("cam_to_srgb"), "{wgsl}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -878,7 +878,6 @@ c = c * exp2(stops);"
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::detail::compose_detail;
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
/// The two controls, as the graph would hold them.
|
||||
fn ops(clarity: f32, texture: f32) -> Vec<Box<dyn Operation>> {
|
||||
@@ -889,7 +888,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn composed(clarity: f32, texture: f32, scale: RenderScale) -> crate::ComposedDetail {
|
||||
compose_detail(&ops(clarity, texture), scale, ColourSpace::Srgb)
|
||||
compose_detail(&ops(clarity, texture), scale)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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;
|
||||
@@ -74,9 +75,11 @@ pub mod distortion;
|
||||
pub mod film_sim;
|
||||
pub mod local_contrast;
|
||||
pub mod noise_reduction;
|
||||
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;
|
||||
@@ -87,6 +90,7 @@ pub use film_sim::{FilmSim, FilmTables, PaperTables};
|
||||
// documentation for why that is two nodes and not one.
|
||||
pub use local_contrast::{Clarity, Texture};
|
||||
pub use noise_reduction::NoiseReduction;
|
||||
pub use view_transform::ViewTransform;
|
||||
pub use vignetting::Vignetting;
|
||||
|
||||
// The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by
|
||||
|
||||
@@ -615,7 +615,6 @@ c = vec3<f32>(y0) + chroma_sum / weight_sum;";
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
/// A 24 MP frame, and the panel a develop view might show it in.
|
||||
const FULL: (u32, u32) = (6000, 4000);
|
||||
@@ -629,7 +628,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn compose(op: NoiseReduction, scale: RenderScale) -> crate::detail::ComposedDetail {
|
||||
crate::detail::compose_detail(&chain_with(op), scale, ColourSpace::Srgb)
|
||||
crate::detail::compose_detail(&chain_with(op), scale)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -674,11 +673,6 @@ mod tests {
|
||||
// The luminance pass runs first, so the chroma guide is the denoised
|
||||
// luminance rather than the raw one.
|
||||
assert_eq!(both.passes[0].label, "noise_reduction/luminance");
|
||||
// And only the last pass in the whole chain performs the output
|
||||
// transform, whichever pass that happens to be.
|
||||
assert!(!both.passes[0].writes_output);
|
||||
assert!(!both.passes[1].writes_output);
|
||||
assert!(both.passes[2].writes_output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
//! TRACES: FR-DEV-3j
|
||||
//! The view transform as an operation: the photographer's two numbers for
|
||||
//! the curve [`crate::view`] defines.
|
||||
//!
|
||||
//! # Why it is a node now, when the base curve could not be
|
||||
//!
|
||||
//! The base curve was kept out of the chain for reasons that were all about
|
||||
//! the *body*: it was looked up by camera model, so as a node it would have
|
||||
//! carried one camera's rendering onto another camera's file through a shared
|
||||
//! sidecar, shown a dead slider on an unprofiled body, and opened a profiled
|
||||
//! one reporting itself modified. D19 removed the premise. There is one view
|
||||
//! transform for every body, so its settings are a decision about the picture
|
||||
//! like any other, and they belong in the sidecar, the history and a mask
|
||||
//! layer.
|
||||
//!
|
||||
//! # Why it is composed at its defaults
|
||||
//!
|
||||
//! A neutral operation is normally left out of the shader, and "active" means
|
||||
//! "moved from its defaults". Both stay true here — an untouched photograph
|
||||
//! writes no view transform parameters, and `every_node_starts_neutral` still
|
||||
//! holds — but the composer emits this node whatever its state, because a
|
||||
//! photograph with no view transform is a scan, not a picture. See
|
||||
//! [`crate::operation::Stage::View`].
|
||||
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use crate::descriptor::{
|
||||
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||
};
|
||||
use crate::operation::{Helper, Operation, Stage, Uniform};
|
||||
use crate::view::{Sigmoid, CONTRAST_RANGE, DEFAULT_CONTRAST, DEFAULT_WHITE, WHITE_RANGE};
|
||||
|
||||
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");
|
||||
|
||||
/// [`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 {
|
||||
// Tone: it is the tone response of the whole picture, and the panel's
|
||||
// Light group is where a photographer looks for the white point.
|
||||
attributes: vec![Attribute::Tone],
|
||||
id: ID,
|
||||
label: LocalizedKey("op.view_transform"),
|
||||
params: vec![
|
||||
ParamDescriptor::scalar(
|
||||
"contrast",
|
||||
"param.view_transform.contrast",
|
||||
CONTRAST_RANGE.0,
|
||||
CONTRAST_RANGE.1,
|
||||
DEFAULT_CONTRAST,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
2,
|
||||
),
|
||||
ParamDescriptor::scalar(
|
||||
"white",
|
||||
"param.view_transform.white",
|
||||
WHITE_RANGE.0,
|
||||
WHITE_RANGE.1,
|
||||
DEFAULT_WHITE,
|
||||
Unit::Stops,
|
||||
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),
|
||||
],
|
||||
})
|
||||
});
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ViewTransform {
|
||||
contrast: f32,
|
||||
white: f32,
|
||||
curve: f32,
|
||||
}
|
||||
|
||||
impl Default for ViewTransform {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
contrast: DEFAULT_CONTRAST,
|
||||
white: DEFAULT_WHITE,
|
||||
curve: CAMERA_RAW,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ViewTransform {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The curve these settings solve to.
|
||||
pub fn sigmoid(&self) -> Sigmoid {
|
||||
Sigmoid::new(self.contrast, self.white)
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for ViewTransform {
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
DESCRIPTOR.clone()
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
CONTRAST => self.contrast = value,
|
||||
WHITE => self.white = value,
|
||||
CURVE => self.curve = value.round(),
|
||||
_ => log::warn!("view_transform: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
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.curve != CAMERA_RAW
|
||||
}
|
||||
|
||||
fn stage(&self) -> Stage {
|
||||
Stage::View
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
"\
|
||||
// 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) {
|
||||
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()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
let s = self.sigmoid();
|
||||
vec![
|
||||
Uniform {
|
||||
name: "slope",
|
||||
value: s.n,
|
||||
},
|
||||
Uniform {
|
||||
name: "inv_k",
|
||||
value: s.inv_k,
|
||||
},
|
||||
Uniform {
|
||||
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.as_slice()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_starts_neutral_and_says_so() {
|
||||
// TRACES: FR-DEV-3j
|
||||
// Neutral in the sense every other node is: nothing moved, so nothing
|
||||
// is written. It is still composed — see the module documentation.
|
||||
let op = ViewTransform::new();
|
||||
assert!(!op.is_active());
|
||||
assert_eq!(op.sigmoid(), Sigmoid::default_curve());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_either_slider_makes_it_active() {
|
||||
let mut op = ViewTransform::new();
|
||||
op.set_param(WHITE, 6.0);
|
||||
assert!(op.is_active());
|
||||
let mut op = ViewTransform::new();
|
||||
op.set_param(CONTRAST, 2.0);
|
||||
assert!(op.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_uniforms_are_the_solved_curve() {
|
||||
let mut op = ViewTransform::new();
|
||||
op.set_param(CONTRAST, 2.0);
|
||||
op.set_param(WHITE, 6.0);
|
||||
let s = Sigmoid::new(2.0, 6.0);
|
||||
let u = op.uniforms();
|
||||
assert_eq!(
|
||||
u.iter().take(3).map(|u| u.value).collect::<Vec<_>>(),
|
||||
vec![s.n, s.inv_k, s.w]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_descriptor_defaults_are_the_curve_defaults() {
|
||||
// The sidecar treats a value equal to the descriptor's default as
|
||||
// unedited; the two disagreeing would make every photograph open
|
||||
// reporting a view transform edit it never had.
|
||||
let d = ViewTransform::new().descriptor();
|
||||
assert_eq!(
|
||||
d.param(CONTRAST).expect("contrast").default,
|
||||
DEFAULT_CONTRAST
|
||||
);
|
||||
assert_eq!(d.param(WHITE).expect("white").default, DEFAULT_WHITE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! TRACES: FR-DSP-2 | NFR-RES-2
|
||||
//! Cutting a render too large for one texture into tiles.
|
||||
//!
|
||||
//! The interactive path is not tiled, and on the evidence should not be
|
||||
//! (`docs/dev/frame-budget.md`, TD-4): one fused dispatch over a viewport is
|
||||
//! inside the frame budget, and a halo per tile nearly doubles the taps of a
|
||||
//! wide kernel. What does not fit is a *file*. A 22927×8966 panorama has no
|
||||
//! render target on a device whose textures stop at 16384, so its export, and
|
||||
//! nothing else, is drawn a tile at a time.
|
||||
//!
|
||||
//! A tile is two rectangles in pixels of the framed output: the one rendered,
|
||||
//! grown by the detail stage's reach ([`crate::ComposedDetail::reach`]) so
|
||||
//! every kernel near its edge reads the pixels it would read untiled, and the
|
||||
//! one kept, which is the tile proper. The kept rectangles cover the frame
|
||||
//! exactly once.
|
||||
//!
|
||||
//! The rendered rectangle's origin is aligned to [`TILE_ALIGN`]. The detail
|
||||
//! stage computes clarity's base on a reduced grid, and a tile starting half
|
||||
//! way through a reduced texel would reduce different pixels together than
|
||||
//! the untiled frame does, which shows as a faint seam.
|
||||
|
||||
/// A multiple of every reduced grid the detail stage uses, so a tile's
|
||||
/// grids line up with the untiled frame's.
|
||||
pub const TILE_ALIGN: u32 = 16;
|
||||
|
||||
/// One tile of a render: what to draw, and which part of it to keep.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Tile {
|
||||
/// `[x, y, width, height]` in output pixels: the tile grown by the halo,
|
||||
/// clamped to the frame. This is what is rendered.
|
||||
pub grown: [u32; 4],
|
||||
/// `[x, y, width, height]` in output pixels: the tile proper, which lies
|
||||
/// inside `grown`. This is what is kept.
|
||||
pub keep: [u32; 4],
|
||||
}
|
||||
|
||||
impl Tile {
|
||||
/// The rendered rectangle as a view on the frame, the rectangle
|
||||
/// [`crate::Framing::set_view`] takes.
|
||||
pub fn view(&self, frame: (u32, u32)) -> crate::framing::CropRect {
|
||||
let (fw, fh) = (frame.0.max(1) as f32, frame.1.max(1) as f32);
|
||||
crate::framing::CropRect {
|
||||
x: self.grown[0] as f32 / fw,
|
||||
y: self.grown[1] as f32 / fh,
|
||||
width: self.grown[2] as f32 / fw,
|
||||
height: self.grown[3] as f32 / fh,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the kept rectangle starts inside the rendered one.
|
||||
pub fn keep_offset(&self) -> (u32, u32) {
|
||||
(self.keep[0] - self.grown[0], self.keep[1] - self.grown[1])
|
||||
}
|
||||
}
|
||||
|
||||
/// Cut a `frame`-sized render into tiles no larger than `max_edge` once
|
||||
/// grown by `halo` on every side.
|
||||
///
|
||||
/// Row-major, top to bottom, so a caller writing the file as it goes gets
|
||||
/// its bands in order. A frame that fits whole is one tile with no halo.
|
||||
/// `None` when the halo leaves no room for a tile at all — a spot heal
|
||||
/// cloning from across a frame wider than the device can hold is the case,
|
||||
/// and it has to be refused rather than drawn with a seam.
|
||||
pub fn plan(frame: (u32, u32), max_edge: u32, halo: u32) -> Option<Vec<Tile>> {
|
||||
let (fw, fh) = (frame.0.max(1), frame.1.max(1));
|
||||
if fw <= max_edge && fh <= max_edge {
|
||||
return Some(vec![Tile {
|
||||
grown: [0, 0, fw, fh],
|
||||
keep: [0, 0, fw, fh],
|
||||
}]);
|
||||
}
|
||||
// The halo, rounded up so a grown origin lands on the grid; the tile
|
||||
// proper a multiple of it for the same reason.
|
||||
let halo = halo.div_ceil(TILE_ALIGN) * TILE_ALIGN;
|
||||
let room = max_edge.checked_sub(2 * halo)?;
|
||||
let step = room / TILE_ALIGN * TILE_ALIGN;
|
||||
if step == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut y = 0;
|
||||
while y < fh {
|
||||
let kh = step.min(fh - y);
|
||||
let mut x = 0;
|
||||
while x < fw {
|
||||
let kw = step.min(fw - x);
|
||||
let gx = x.saturating_sub(halo);
|
||||
let gy = y.saturating_sub(halo);
|
||||
let gx1 = (x + kw + halo).min(fw);
|
||||
let gy1 = (y + kh + halo).min(fh);
|
||||
out.push(Tile {
|
||||
grown: [gx, gy, gx1 - gx, gy1 - gy],
|
||||
keep: [x, y, kw, kh],
|
||||
});
|
||||
x += kw;
|
||||
}
|
||||
y += kh;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_frame_that_fits_is_one_tile_with_no_halo() {
|
||||
let tiles = plan((6000, 4000), 8192, 200).unwrap();
|
||||
assert_eq!(tiles.len(), 1);
|
||||
assert_eq!(tiles[0].grown, [0, 0, 6000, 4000]);
|
||||
assert_eq!(tiles[0].keep, tiles[0].grown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kept_rectangles_cover_the_frame_exactly_once() {
|
||||
// The panorama that started this, against a 16384 device with a
|
||||
// clarity-sized halo.
|
||||
let frame = (22927, 8966);
|
||||
let tiles = plan(frame, 16384, 230).unwrap();
|
||||
let mut covered = vec![0u8; (frame.0 * frame.1) as usize];
|
||||
for t in &tiles {
|
||||
let [x, y, w, h] = t.keep;
|
||||
for yy in y..y + h {
|
||||
for xx in x..x + w {
|
||||
covered[(yy * frame.0 + xx) as usize] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(covered.iter().all(|&c| c == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_tile_fits_the_device_and_holds_its_halo() {
|
||||
let frame = (22927, 8966);
|
||||
let (max, halo) = (8192, 300);
|
||||
for t in plan(frame, max, halo).unwrap() {
|
||||
let [gx, gy, gw, gh] = t.grown;
|
||||
let [kx, ky, kw, kh] = t.keep;
|
||||
assert!(gw <= max && gh <= max, "{t:?} does not fit");
|
||||
assert_eq!(gx % TILE_ALIGN, 0, "{t:?} starts off the grid");
|
||||
assert_eq!(gy % TILE_ALIGN, 0, "{t:?} starts off the grid");
|
||||
// The halo is there on every side, or the frame ends first — in
|
||||
// which case the untiled render stops at the same edge.
|
||||
assert!(gx == 0 || kx - gx >= halo);
|
||||
assert!(gy == 0 || ky - gy >= halo);
|
||||
assert!(gx + gw == frame.0 || gx + gw - (kx + kw) >= halo);
|
||||
assert!(gy + gh == frame.1 || gy + gh - (ky + kh) >= halo);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_halo_wider_than_the_device_is_refused() {
|
||||
assert_eq!(plan((40000, 100), 16384, 9000), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
//! TRACES: FR-DEV-3j | FR-DEV-2
|
||||
//! The view transform — the one stage that maps scene-linear colour to a
|
||||
//! display range (D19, ARCH §6.14).
|
||||
//!
|
||||
//! # What it is
|
||||
//!
|
||||
//! A log-logistic sigmoid, per channel:
|
||||
//!
|
||||
//! ```text
|
||||
//! f(x) = w · r / (1 + r), r = (x / k)^n
|
||||
//! ```
|
||||
//!
|
||||
//! `n` is the contrast — the slope in log-log terms, before the shoulder
|
||||
//! bends it. `k` and `w` are solved from two conditions rather than set:
|
||||
//! scene middle grey lands on display middle grey, and the scene white the
|
||||
//! photographer chose lands on display white. So the curve has a toe, a
|
||||
//! midtone slope and a shoulder that approaches `w` — a hair above 1.0 —
|
||||
//! without ever reaching it. Everything the shoulder has not reached by the
|
||||
//! white point is clipped by the output transform, which is the last moment
|
||||
//! and the only place a clip belongs.
|
||||
//!
|
||||
//! # Why per channel, and why the middle channel is put back
|
||||
//!
|
||||
//! Per channel is what makes a bright saturated colour desaturate as it
|
||||
//! approaches white — a blown sky rolls toward white rather than toward a
|
||||
//! saturated corner of the gamut, which is what film and every camera JPEG
|
||||
//! do. It also bends hue: the three channels sit at different places on the
|
||||
//! curve, so their ratios change, and an orange flame drifts toward yellow.
|
||||
//! So after the curve the middle channel is moved back to where it sat
|
||||
//! *between the other two* before it — the same fraction of the way from the
|
||||
//! smallest to the largest. The smallest and largest keep what the curve gave
|
||||
//! them, which keeps the desaturation; the hue, which is decided by that
|
||||
//! fraction, survives. It is the "preserve hue" step of darktable's sigmoid,
|
||||
//! at full strength.
|
||||
//!
|
||||
//! # Why these defaults
|
||||
//!
|
||||
//! [`SCENE_GREY`] is where the retired default base curve put middle grey
|
||||
//! (FR-DEV-3e): linear sensor data from a correctly exposed frame has it
|
||||
//! near 13% of saturation, and a camera JPEG shows it at 18%. The contrast
|
||||
//! and white defaults were chosen against that same retired curve: at 1.4 and
|
||||
//! 4 stops the midtones stay within a quarter of a stop of it between scene
|
||||
//! 0.03 and 1.0, while a highlight a stop past sensor saturation still rolls
|
||||
//! into white rather than stopping dead at it. The upper midtones come out a
|
||||
//! little darker than the curve had them, which is the price of that
|
||||
//! headroom and what the white slider is for.
|
||||
|
||||
/// Scene-linear middle grey: where the retired default curve placed it.
|
||||
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 `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;
|
||||
|
||||
/// The contrast range a photographer is offered.
|
||||
pub const CONTRAST_RANGE: (f32, f32) = (1.0, 3.0);
|
||||
|
||||
/// The white point range, in stops above middle grey.
|
||||
///
|
||||
/// The floor is not taste. The two conditions `k` and `w` are solved from
|
||||
/// have a solution only while `2^(white · n)` exceeds `1 / DISPLAY_GREY`,
|
||||
/// and at the lowest contrast that needs `white` above about 2.47 stops.
|
||||
pub const WHITE_RANGE: (f32, f32) = (2.5, 10.0);
|
||||
|
||||
/// The curve's three numbers, solved from the photographer's two.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Sigmoid {
|
||||
/// Contrast: the exponent.
|
||||
pub n: f32,
|
||||
/// `1 / k`, so the shader multiplies rather than divides.
|
||||
pub inv_k: f32,
|
||||
/// The asymptote the shoulder approaches, a little above 1.0.
|
||||
pub w: f32,
|
||||
}
|
||||
|
||||
impl Sigmoid {
|
||||
/// Solve the curve for a contrast and a white point in stops.
|
||||
///
|
||||
/// Out-of-range inputs are clamped to [`CONTRAST_RANGE`] and
|
||||
/// [`WHITE_RANGE`] rather than trusted: they arrive from a sidecar, which
|
||||
/// may have been written by a build with other limits, and outside them
|
||||
/// the solution below divides by something that is no longer positive.
|
||||
///
|
||||
/// With `r_g` the value of `r` at scene grey and `q = 2^(white · n)`, the
|
||||
/// two conditions `f(grey) = display grey` and `f(grey · 2^white) = 1`
|
||||
/// are `w·r_g/(1+r_g) = g` and `w·q·r_g/(1+q·r_g) = 1`. Dividing one by
|
||||
/// the other eliminates `w` and leaves `r_g = (g·q − 1) / (q·(1 − g))`.
|
||||
pub fn new(contrast: f32, white: f32) -> Self {
|
||||
let n = contrast.clamp(CONTRAST_RANGE.0, CONTRAST_RANGE.1) as f64;
|
||||
let white = white.clamp(WHITE_RANGE.0, WHITE_RANGE.1) as f64;
|
||||
let g = f64::from(DISPLAY_GREY);
|
||||
let q = (white * n).exp2();
|
||||
let r_grey = (g * q - 1.0) / (q * (1.0 - g));
|
||||
let w = g * (1.0 + r_grey) / r_grey;
|
||||
// r = (x / k)^n, and r at scene grey is r_grey, so
|
||||
// k = grey / r_grey^(1/n).
|
||||
let k = f64::from(SCENE_GREY) / r_grey.powf(1.0 / n);
|
||||
Self {
|
||||
n: n as f32,
|
||||
inv_k: (1.0 / k) as f32,
|
||||
w: w as f32,
|
||||
}
|
||||
}
|
||||
|
||||
/// The default curve.
|
||||
pub fn default_curve() -> Self {
|
||||
Self::new(DEFAULT_CONTRAST, DEFAULT_WHITE)
|
||||
}
|
||||
|
||||
/// One channel through the curve. The CPU reference the shader is
|
||||
/// tested against.
|
||||
pub fn channel(&self, x: f32) -> f32 {
|
||||
let r = (x.max(0.0) * self.inv_k).powf(self.n);
|
||||
self.w * r / (1.0 + r)
|
||||
}
|
||||
|
||||
/// A colour through the curve, with the middle channel put back between
|
||||
/// the other two. See the module documentation.
|
||||
pub fn apply(&self, c: [f32; 3]) -> [f32; 3] {
|
||||
let x = c.map(|v| v.max(0.0));
|
||||
let y = x.map(|v| self.channel(v));
|
||||
let lo = x[0].min(x[1]).min(x[2]);
|
||||
let hi = x[0].max(x[1]).max(x[2]);
|
||||
if hi - lo <= 1e-9 {
|
||||
return y;
|
||||
}
|
||||
let (y_lo, y_hi) = (self.channel(lo), self.channel(hi));
|
||||
x.map(|v| y_lo + (y_hi - y_lo) * (v - lo) / (hi - lo))
|
||||
}
|
||||
}
|
||||
|
||||
/// The WGSL twin of [`Sigmoid::apply`], as a helper function.
|
||||
pub const VIEW_SIGMOID_WGSL: &str = "\
|
||||
// The view transform (FR-DEV-3j): a log-logistic sigmoid per channel, then the
|
||||
// middle channel put back between the other two so that the hue survives the
|
||||
// shoulder. See `dr_pipeline::view` for the derivation and the defaults.
|
||||
fn view_sigmoid(c: vec3<f32>, n: f32, inv_k: f32, w: f32) -> vec3<f32> {
|
||||
// Negative components are colours outside the working primaries. They are
|
||||
// floored here, at the last stage, which is the one place a gamut clip
|
||||
// belongs.
|
||||
let x = max(c, vec3<f32>(0.0));
|
||||
let lo = min(x.r, min(x.g, x.b));
|
||||
let hi = max(x.r, max(x.g, x.b));
|
||||
let r_lo = pow(lo * inv_k, n);
|
||||
let r_hi = pow(hi * inv_k, n);
|
||||
let y_lo = w * r_lo / (1.0 + r_lo);
|
||||
let y_hi = w * r_hi / (1.0 + r_hi);
|
||||
// Each channel's place between the smallest and the largest. A neutral
|
||||
// has no spread, and every channel then takes the one value there is.
|
||||
let spread = hi - lo;
|
||||
let t = select((x - vec3<f32>(lo)) / max(spread, 1e-9), vec3<f32>(0.0), spread <= 1e-9);
|
||||
return vec3<f32>(y_lo) + (y_hi - y_lo) * t;
|
||||
}
|
||||
";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The retired default base curve, for the acceptance comparison: five
|
||||
/// points through the unit square, sampled here by straight lines between
|
||||
/// them in log-log terms — close enough to the monotone spline that drew
|
||||
/// it for a tolerance measured in quarters of a stop. Below its second
|
||||
/// point it was a straight line from the origin.
|
||||
fn retired_default(x: f32) -> f32 {
|
||||
const P: [(f32, f32); 4] = [(0.04, 0.043), (0.13, 0.175), (0.45, 0.690), (1.0, 1.0)];
|
||||
if x < 0.04 {
|
||||
return x * (0.043 / 0.04);
|
||||
}
|
||||
let x = x.min(1.0);
|
||||
let i = P.windows(2).position(|w| x <= w[1].0).unwrap_or(2);
|
||||
let ((x0, y0), (x1, y1)) = (P[i], P[i + 1]);
|
||||
let t = (x.ln() - x0.ln()) / (x1.ln() - x0.ln());
|
||||
(y0.ln() + t * (y1.ln() - y0.ln())).exp()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_grey_lands_on_display_grey() {
|
||||
// TRACES: FR-DEV-3j
|
||||
for (contrast, white) in [(1.0, 3.0), (1.4, 4.0), (2.5, 8.0), (3.0, 10.0)] {
|
||||
let s = Sigmoid::new(contrast, white);
|
||||
let got = s.channel(SCENE_GREY);
|
||||
assert!(
|
||||
(got - DISPLAY_GREY).abs() < 0.01,
|
||||
"contrast {contrast}, white {white}: grey went to {got}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_white_point_reaches_display_white() {
|
||||
// TRACES: FR-DEV-3j
|
||||
// The whole meaning of the slider: the scene value it names is where
|
||||
// the picture reaches white, and not before.
|
||||
for (contrast, white) in [(1.0, 3.0), (1.4, 4.0), (2.5, 8.0)] {
|
||||
let s = Sigmoid::new(contrast, white);
|
||||
let at = SCENE_GREY * white.exp2();
|
||||
assert!((s.channel(at) - 1.0).abs() < 1e-4, "{}", s.channel(at));
|
||||
assert!(s.channel(at * 0.9) < 1.0);
|
||||
assert!(s.w > 1.0, "the shoulder must approach a value above white");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_is_monotone_and_keeps_going_past_one() {
|
||||
// TRACES: FR-DEV-3j | FR-DEV-2
|
||||
// What the base curve got wrong: it was flat past 1.0, so every
|
||||
// recovered highlight left it as the same number.
|
||||
let s = Sigmoid::default_curve();
|
||||
let mut last = -1.0;
|
||||
for i in 0..=2000 {
|
||||
let x = i as f32 * 0.004;
|
||||
let y = s.channel(x);
|
||||
assert!(y > last || (x == 0.0 && y == 0.0), "not increasing at {x}");
|
||||
last = y;
|
||||
}
|
||||
assert!(s.channel(2.0) > s.channel(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reference_stays_close_to_the_retired_curve() {
|
||||
// TRACES: FR-DEV-3j | FR-DEV-3e
|
||||
// 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();
|
||||
assert!(ev.abs() < 0.3, "at scene {x} the default moved {ev:+.2} EV");
|
||||
x *= 1.1;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_neutral_stays_neutral() {
|
||||
// TRACES: FR-DEV-3j
|
||||
let s = Sigmoid::default_curve();
|
||||
for v in [0.0, 0.01, 0.13, 1.0, 7.0] {
|
||||
let [r, g, b] = s.apply([v, v, v]);
|
||||
assert_eq!(r, g);
|
||||
assert_eq!(g, b);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hue_survives_the_shoulder() {
|
||||
// TRACES: FR-DEV-3j
|
||||
// The middle channel's place between the other two is what decides
|
||||
// the hue. Without the correction an orange at the shoulder drifts
|
||||
// toward yellow as the red channel saturates first.
|
||||
let s = Sigmoid::default_curve();
|
||||
let orange = [2.0, 0.8, 0.1];
|
||||
let out = s.apply(orange);
|
||||
let before = (orange[1] - orange[2]) / (orange[0] - orange[2]);
|
||||
let after = (out[1] - out[2]) / (out[0] - out[2]);
|
||||
assert!((before - after).abs() < 1e-5, "{before} became {after}");
|
||||
// And the extremes keep what the curve gave them — the desaturation
|
||||
// toward white is the point of working per channel.
|
||||
assert!((out[0] - s.channel(2.0)).abs() < 1e-6);
|
||||
assert!((out[2] - s.channel(0.1)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_settings_are_clamped_not_trusted() {
|
||||
// A sidecar from another build may carry anything, and outside the
|
||||
// range the solution divides by a value that is no longer positive.
|
||||
let s = Sigmoid::new(0.0, 0.0);
|
||||
assert!(s.n.is_finite() && s.inv_k.is_finite() && s.w.is_finite());
|
||||
assert_eq!(s, Sigmoid::new(CONTRAST_RANGE.0, WHITE_RANGE.0));
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,8 @@
|
||||
//! # What is not covered, and why that is honest
|
||||
//!
|
||||
//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`,
|
||||
//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture`, `dehaze` —
|
||||
//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture`, `dehaze`,
|
||||
//! `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
|
||||
@@ -395,6 +396,7 @@ fn every_declared_node_is_checked() {
|
||||
assert_eq!(
|
||||
hand,
|
||||
[
|
||||
"camera_profile",
|
||||
"capture_sharpen",
|
||||
"clarity",
|
||||
"colour_mixer",
|
||||
@@ -403,6 +405,7 @@ fn every_declared_node_is_checked() {
|
||||
"noise_reduction",
|
||||
"texture",
|
||||
"tone_curve",
|
||||
"view_transform",
|
||||
"vignetting",
|
||||
],
|
||||
"the set of hand-written nodes changed; if that is deliberate, update \
|
||||
|
||||
@@ -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
|
||||
@@ -573,6 +796,32 @@ mod tests {
|
||||
assert_eq!(report.failed, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subfolder_becomes_the_category_of_what_it_holds() {
|
||||
// The folder picked is the root and names nothing; each folder under
|
||||
// it is a level of category, as Lightroom's groups were.
|
||||
let dir = tempdir("categories");
|
||||
std::fs::write(dir.join("Golden Hour.xmp"), ELEMENT_FORM).unwrap();
|
||||
let nested = dir.join("Film").join("Colour");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
std::fs::write(nested.join("Golden Hour.xmp"), ELEMENT_FORM).unwrap();
|
||||
|
||||
let names: Vec<_> = read_path(&dir).presets.into_iter().map(|p| p.0).collect();
|
||||
assert_eq!(names, ["Film/Colour/Golden Hour", "Golden Hour"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slash_in_a_displayed_name_is_not_a_category() {
|
||||
let dir = tempdir("slash");
|
||||
std::fs::write(
|
||||
dir.join("p.xmp"),
|
||||
ATTRIBUTE_FORM.replace("Warm Portrait", "Warm / Cool"),
|
||||
)
|
||||
.unwrap();
|
||||
let report = read_path(&dir);
|
||||
assert_eq!(report.presets[0].0, "Warm \u{2215} Cool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preset_without_a_name_is_called_after_its_file() {
|
||||
// Lightroom writes the name it displays, which is not always the file
|
||||
@@ -661,21 +910,37 @@ pub struct Report {
|
||||
/// A folder because that is the shape a photographer's presets are in — an
|
||||
/// exported Lightroom preset folder, nested one level per group — and asking
|
||||
/// them to import ninety files one at a time would be asking them not to
|
||||
/// bother. Nested folders are walked, which is what makes the group structure
|
||||
/// available to whatever wants it later.
|
||||
/// bother. Nested folders are walked, and each one below `path` becomes a
|
||||
/// category: a preset in `Portraits/` is named `Portraits/Warm skin`, which is
|
||||
/// how the preset menu files it (see `PresetLibrary`'s note on categories).
|
||||
///
|
||||
/// The *name* comes from `crs:Name` where the file carries one and from the
|
||||
/// file stem where it does not. Lightroom writes the name it displays, which
|
||||
/// is not always the file name, and the displayed name is the one the
|
||||
/// photographer will look for.
|
||||
/// photographer will look for. A `/` inside that name would read as a
|
||||
/// category it never had, so it becomes `∕`, which looks the same and
|
||||
/// separates nothing.
|
||||
pub fn read_path(path: &std::path::Path) -> Report {
|
||||
let mut report = Report::default();
|
||||
read_into(path, &mut report);
|
||||
read_into(path, "", &mut report);
|
||||
report.presets.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
report
|
||||
}
|
||||
|
||||
fn read_into(path: &std::path::Path, report: &mut Report) {
|
||||
/// The category a folder below the import root files its presets under.
|
||||
fn category_of(parent: &str, folder: &std::path::Path) -> String {
|
||||
let Some(name) = folder.file_name() else {
|
||||
return parent.to_string();
|
||||
};
|
||||
let name = name.to_string_lossy().replace('/', "\u{2215}");
|
||||
if parent.is_empty() {
|
||||
name
|
||||
} else {
|
||||
format!("{parent}/{name}")
|
||||
}
|
||||
}
|
||||
|
||||
fn read_into(path: &std::path::Path, category: &str, report: &mut Report) {
|
||||
if path.is_dir() {
|
||||
let Ok(entries) = std::fs::read_dir(path) else {
|
||||
log::warn!("preset import: cannot read {}", path.display());
|
||||
@@ -686,7 +951,12 @@ fn read_into(path: &std::path::Path, report: &mut Report) {
|
||||
let mut paths: Vec<std::path::PathBuf> = entries.flatten().map(|e| e.path()).collect();
|
||||
paths.sort();
|
||||
for path in paths {
|
||||
read_into(&path, report);
|
||||
let category = if path.is_dir() {
|
||||
category_of(category, &path)
|
||||
} else {
|
||||
category.to_string()
|
||||
};
|
||||
read_into(&path, &category, report);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -710,6 +980,12 @@ fn read_into(path: &std::path::Path, report: &mut Report) {
|
||||
.unwrap_or_default()
|
||||
});
|
||||
report.unsupported.extend(import.skipped);
|
||||
let name = name.replace('/', "\u{2215}");
|
||||
let name = if category.is_empty() {
|
||||
name
|
||||
} else {
|
||||
format!("{category}/{name}")
|
||||
};
|
||||
report.presets.push((name, import.preset));
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -72,17 +72,54 @@ pub enum ThumbSize {
|
||||
/// Zoomed cells, the loupe, and the filmstrip. ~45 KB each, fetched only
|
||||
/// where something actually asks for that detail.
|
||||
Large = 1,
|
||||
/// TRACES: FR-MRG-6
|
||||
/// A panorama's cell two columns wide, at the height of one: long edge
|
||||
/// sized for the width rather than for a square, since the grid class
|
||||
/// of a 4:1 panorama is 256×64 — a smear across the cells. The wide
|
||||
/// classes are made only for photographs that wide, so they cost a
|
||||
/// library nothing else.
|
||||
Wide2 = 2,
|
||||
/// Three columns.
|
||||
Wide3 = 3,
|
||||
/// Four columns: the widest class.
|
||||
Wide4 = 4,
|
||||
}
|
||||
|
||||
/// The most columns a wide class spans.
|
||||
pub const WIDEST_SPAN: usize = 4;
|
||||
|
||||
impl ThumbSize {
|
||||
/// Long edge in pixels.
|
||||
/// Long edge in pixels. A wide class is 512 per column it spans, which
|
||||
/// keeps its short edge near the large class's for the aspect that
|
||||
/// class is chosen for — sharp at the largest cells on a 2x display.
|
||||
pub fn edge(self) -> u32 {
|
||||
match self {
|
||||
ThumbSize::Grid => 256,
|
||||
ThumbSize::Large => 1024,
|
||||
ThumbSize::Wide2 => 1024,
|
||||
ThumbSize::Wide3 => 1536,
|
||||
ThumbSize::Wide4 => 2048,
|
||||
}
|
||||
}
|
||||
|
||||
/// The wide class for a cell `span` columns wide: `None` for one
|
||||
/// column, and the widest class for anything past it.
|
||||
pub fn wide(span: usize) -> Option<Self> {
|
||||
match span {
|
||||
0 | 1 => None,
|
||||
2 => Some(ThumbSize::Wide2),
|
||||
3 => Some(ThumbSize::Wide3),
|
||||
_ => Some(ThumbSize::Wide4),
|
||||
}
|
||||
}
|
||||
|
||||
/// The class for a cell `span` columns wide whose columns are drawn at
|
||||
/// `pixels`: a wide class for any cell wider than one, whatever the
|
||||
/// zoom, since its height is a column's and its width is not.
|
||||
pub fn for_span(span: usize, pixels: u32) -> Self {
|
||||
Self::wide(span).unwrap_or_else(|| Self::for_cell(pixels))
|
||||
}
|
||||
|
||||
/// The smallest class that can fill a cell of this size without visibly
|
||||
/// softening.
|
||||
///
|
||||
@@ -96,12 +133,22 @@ impl ThumbSize {
|
||||
}
|
||||
}
|
||||
|
||||
fn from_i64(v: i64) -> Self {
|
||||
/// The class a stored discriminant names, or `None` for one this build
|
||||
/// does not know.
|
||||
pub fn from_stored(v: i64) -> Option<Self> {
|
||||
match v {
|
||||
1 => ThumbSize::Large,
|
||||
_ => ThumbSize::Grid,
|
||||
0 => Some(ThumbSize::Grid),
|
||||
1 => Some(ThumbSize::Large),
|
||||
2 => Some(ThumbSize::Wide2),
|
||||
3 => Some(ThumbSize::Wide3),
|
||||
4 => Some(ThumbSize::Wide4),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_i64(v: i64) -> Self {
|
||||
Self::from_stored(v).unwrap_or(ThumbSize::Grid)
|
||||
}
|
||||
}
|
||||
|
||||
/// Long edge of a grid thumbnail.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user