Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
880915e061 | ||
|
|
b53d4dc391 | ||
|
|
90d0985db8 | ||
|
|
f813014e80 | ||
|
|
fc3b1ca440 | ||
|
|
36e8360db9 | ||
|
|
8aa10cd249 | ||
|
|
c2cfacd7d3 | ||
|
|
a9271c4850 | ||
|
|
4b71ef0947 | ||
|
|
1e1aa1442b | ||
|
|
3761281dd1 | ||
|
|
9cba420fd5 | ||
|
|
56f4180347 | ||
|
|
417cba8b4d | ||
|
|
7966bf2dd8 | ||
|
|
4822991bec | ||
|
|
4b33754482 | ||
|
|
b3999bbc0c | ||
|
|
43402bfe5b | ||
|
|
cb97ebe7ac | ||
|
|
2ced6f114f | ||
|
|
85dee4375b | ||
|
|
87c405eb46 | ||
|
|
555ec0efb3 | ||
|
|
0291b80672 | ||
|
|
ef71bb3289 | ||
|
|
08b7d23e86 | ||
|
|
a116325991 | ||
|
|
f20e481358 | ||
|
|
16a5957aa7 | ||
|
|
5736a21a3a | ||
|
|
b6ca7be185 | ||
|
|
06422a07db | ||
|
|
14f08a565f | ||
|
|
0c9d564586 | ||
|
|
75e12441fd | ||
|
|
3f8f909e41 | ||
|
|
5ffd54ba43 | ||
|
|
5a8c3e4c40 | ||
|
|
0e6ac09fd5 | ||
|
|
948f6c3ed2 | ||
|
|
8f9e59b9fa | ||
|
|
83f0461ce7 | ||
|
|
26e50ae723 | ||
|
|
25dc0d0179 | ||
|
|
a36ec98b36 | ||
|
|
c343ac79d3 | ||
|
|
2e7f14dafe | ||
|
|
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 |
@@ -487,10 +487,11 @@ jobs:
|
||||
wine "$SETUP" /S 2>/dev/null
|
||||
INST=$(echo "$HOME"/.wine/drive_c/users/*/AppData/Local/Programs/DarkRoom)
|
||||
ls "$INST"
|
||||
# As many files as package.sh stages: everything but the READMEs in
|
||||
# the directories it copies. A literal here went stale the first
|
||||
# time a model was added.
|
||||
WANT=$(find models/face models/scene models/inpaint -maxdepth 1 -type f ! -name README.md | wc -l)
|
||||
# As many files as package.sh stages: everything but the READMEs and
|
||||
# the Hexagon's quantised siblings in the directories it copies. A
|
||||
# literal here went stale the first time a model was added.
|
||||
WANT=$(find models/face models/scene models/inpaint models/denoise -maxdepth 1 -type f ! -name README.md \
|
||||
! -name '*.int8.onnx' ! -name '*.a16w8.onnx' ! -name '*.a16w16.onnx' | wc -l)
|
||||
GOT=$(ls "$INST/models" | wc -l)
|
||||
[ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT model files, installed $GOT"; exit 1; }
|
||||
# The manual, and every picture it shows, counted the same way.
|
||||
@@ -498,6 +499,12 @@ jobs:
|
||||
WANT=$(ls docs/manual/media | wc -l)
|
||||
GOT=$(ls "$INST/manual/media" | wc -l)
|
||||
[ "$GOT" = "$WANT" ] || { echo "FAIL: expected $WANT manual pictures, installed $GOT"; exit 1; }
|
||||
# Both bundled runtimes, each with its provider beside it
|
||||
# (tools/fetch-bundled-runtimes.sh).
|
||||
for f in openvino/onnxruntime.dll openvino/onnxruntime_providers_openvino.dll \
|
||||
openvino/openvino.dll webgpu/onnxruntime.dll webgpu/dxcompiler.dll; do
|
||||
[ -f "$INST/runtimes/$f" ] || { echo "FAIL: runtimes/$f not installed"; exit 1; }
|
||||
done
|
||||
wine reg query 'HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\DarkRoom' 2>/dev/null \
|
||||
| grep -q DisplayVersion || { echo "FAIL: no uninstall registry key"; exit 1; }
|
||||
wine "$INST/darkroom.exe" --version 2>/dev/null | grep -q '^darkroom-desktop ' \
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
name: Manual pages
|
||||
|
||||
# Publishes the manual to Gitea Pages, the way KPN publishes its docs: the
|
||||
# site is whatever the `gitea-pages` branch holds, served at
|
||||
# https://pages.tourolle.paris/dtourolle/darkroom/.
|
||||
#
|
||||
# No site generator. docs/manual/index.html is already the rendered page —
|
||||
# `cargo run -p traceability -- manual` writes it from README.md, and the
|
||||
# Traceability workflow fails a push whose page disagrees with the README —
|
||||
# so this job copies the page and its pictures onto the branch, with a PDF
|
||||
# and an EPUB made from them.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'docs/manual/**'
|
||||
- '.gitea/workflows/manual-pages.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: linux/amd64
|
||||
name: Publish the manual
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# The pictures are in LFS, so a plain checkout would publish pointers.
|
||||
# Fetched as build-and-test.yml fetches the models, and for the reason
|
||||
# written there: checkout's Authorization header and the per-object JWT
|
||||
# Gitea hands git-lfs collide into a 400, so the header goes and the
|
||||
# token travels as a credential instead.
|
||||
- name: Fetch the manual's pictures
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
|
||||
run: |
|
||||
set -e
|
||||
git lfs install --local
|
||||
git config --local --get-regexp '^http\..*extraheader$' \
|
||||
| cut -d' ' -f1 | sort -u \
|
||||
| while read -r key; do git config --local --unset-all "$key"; done || true
|
||||
git config --local lfs.url \
|
||||
"https://x-access-token:${TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
|
||||
git lfs pull --include="docs/manual/media/**"
|
||||
|
||||
# A pointer is ~130 bytes of text; a picture is not. Publishing a page of
|
||||
# broken images is worse than publishing nothing, so check before pushing.
|
||||
- name: Refuse pointers
|
||||
run: |
|
||||
set -e
|
||||
bad=$(find docs/manual/media -type f -size -1k -exec grep -l '^version https://git-lfs' {} + || true)
|
||||
if [ -n "$bad" ]; then
|
||||
echo "LFS pointers where pictures should be:"; echo "$bad"; exit 1
|
||||
fi
|
||||
|
||||
# The downloads the page links to (traceability's PAGES_SITE, PDF_NAME,
|
||||
# EPUB_NAME). The PDF is the page itself through its print stylesheet —
|
||||
# A4, a cover and a contents page with page numbers, a chapter a page —
|
||||
# so it says what the page says; tools/manual/pdf.py says why it goes
|
||||
# through JPEG copies of the pictures first.
|
||||
- name: Make the PDF and the EPUB
|
||||
run: |
|
||||
set -e
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
|
||||
pandoc python3-pip python3-venv libpango-1.0-0 libpangoft2-1.0-0 \
|
||||
fonts-noto-core fonts-dejavu-core > /dev/null
|
||||
python3 -m venv /tmp/wp
|
||||
/tmp/wp/bin/pip install -q weasyprint
|
||||
mkdir -p site
|
||||
/tmp/wp/bin/python tools/manual/pdf.py docs/manual \
|
||||
site/darkroom-manual.pdf site/darkroom-manual.epub
|
||||
ls -l site
|
||||
|
||||
# One commit, force-pushed: the branch is a build output, and its history
|
||||
# is in master's.
|
||||
- name: Push to gitea-pages
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
|
||||
run: |
|
||||
set -e
|
||||
site=$(mktemp -d)
|
||||
cp site/darkroom-manual.pdf site/darkroom-manual.epub "$site/"
|
||||
cp docs/manual/index.html "$site/"
|
||||
cp -r docs/manual/media "$site/"
|
||||
cd "$site"
|
||||
git init -q -b gitea-pages
|
||||
git config user.name "Gitea Actions"
|
||||
git config user.email "actions@gitea.tourolle.paris"
|
||||
git add -A
|
||||
git commit -q -m "manual: deploy from ${GITHUB_SHA}"
|
||||
git push -f \
|
||||
"https://x-access-token:${TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git" \
|
||||
gitea-pages
|
||||
Generated
+44
-27
@@ -1265,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-android"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"dr-plat",
|
||||
@@ -1278,7 +1278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-desktop"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-plat",
|
||||
@@ -1454,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dr-bench"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-catalog",
|
||||
@@ -1471,7 +1471,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-catalog"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"dr-face",
|
||||
"dr-plat",
|
||||
@@ -1486,7 +1486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-decode"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"env_logger",
|
||||
@@ -1498,9 +1498,26 @@ dependencies = [
|
||||
"zune-jpeg 0.4.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dr-denoise"
|
||||
version = "0.24.1"
|
||||
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.24.1"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-gpu",
|
||||
@@ -1519,7 +1536,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-face"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
@@ -1532,7 +1549,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-film"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1541,7 +1558,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-gpu"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dr-decode",
|
||||
@@ -1559,7 +1576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-inference-engine"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"libloading",
|
||||
@@ -1574,7 +1591,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ingest"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"dr-plat",
|
||||
"dr-types",
|
||||
@@ -1586,7 +1603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-lens"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"lensfun",
|
||||
"log",
|
||||
@@ -1594,7 +1611,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pano"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-inference-engine",
|
||||
@@ -1608,7 +1625,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pipeline"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -1617,7 +1634,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-plat"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"android-native-keyring-store",
|
||||
"dr-types",
|
||||
@@ -1633,7 +1650,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-preset-xmp"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"dr-pipeline",
|
||||
"log",
|
||||
@@ -1643,7 +1660,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-segment"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
@@ -1656,7 +1673,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-plat",
|
||||
@@ -1670,7 +1687,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-folder"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-sync",
|
||||
@@ -1682,7 +1699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-nextcloud"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-decode",
|
||||
@@ -1704,7 +1721,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-thumbs"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"jpeg-encoder",
|
||||
@@ -1716,7 +1733,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-types"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1725,12 +1742,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ui"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"dr-catalog",
|
||||
"dr-decode",
|
||||
"dr-denoise",
|
||||
"dr-export",
|
||||
"dr-face",
|
||||
"dr-film",
|
||||
@@ -1750,6 +1768,7 @@ dependencies = [
|
||||
"dr-types",
|
||||
"dr-xmp",
|
||||
"env_logger",
|
||||
"half",
|
||||
"i-slint-backend-testing",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
@@ -1773,7 +1792,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-xmp"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -5513,8 +5532,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 +7126,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "traceability"
|
||||
version = "0.18.2"
|
||||
version = "0.24.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"proc-macro2",
|
||||
|
||||
+30
-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.24.1"
|
||||
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,33 @@ 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.
|
||||
# Except dr-ui. Slint expands the `.slint` files into ~27 MB of Rust
|
||||
# (`out/app.rs`), and at one codegen unit LLVM optimises all of it on a single
|
||||
# thread: 13.5 minutes of a release build with the other cores idle. The code
|
||||
# it holds is UI glue — property bindings and callbacks — not the image work,
|
||||
# which lives in the crates above that keep the single unit.
|
||||
[profile.release.package.dr-ui]
|
||||
codegen-units = 16
|
||||
|
||||
# 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.24.1**, forty 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,8 +70,17 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:hasCode="true"
|
||||
android:allowBackup="false"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<!-- The DSP's RPC library, which QNN's Hexagon stub loads. From API 31
|
||||
an app's linker namespace refuses a vendor library the manifest
|
||||
does not name, and QNN then fails to create its device
|
||||
(QNN_DEVICE_ERROR_INVALID_CONFIG) before it reaches the DSP:
|
||||
every model ran on the CPU on 0.22.0. Not required, so a device
|
||||
without one still installs and stays on the CPU. -->
|
||||
<uses-native-library android:name="libcdsprpc.so" android:required="false" />
|
||||
|
||||
<!-- NativeActivity rather than a Kotlin Activity: android-activity's
|
||||
glue loads libdarkroom.so and calls android_main. `android.app.lib_name`
|
||||
is how it learns which library to load, and must match [lib].name.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,27 +332,38 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
|
||||
// eyes-open filter has something to read, and a tablet has no other way
|
||||
// to get them either.
|
||||
//
|
||||
// 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] = [
|
||||
// The quantised siblings — `.a16w8.onnx`, `.a16w16.onnx` — are what the
|
||||
// Hexagon runs (docs/dev/inference.md §1.5), each in the narrowest form
|
||||
// that held that model's accuracy on the tablet; the engine loads the
|
||||
// sibling when the probe chose that rung and ignores it otherwise. The
|
||||
// segmenter's and XFeat's forms are compiled into the binary instead,
|
||||
// beside their f32 graphs.
|
||||
const BUNDLED: [(&std::ffi::CStr, &str); 21] = [
|
||||
(c"models/scrfd_500m_640.onnx", "scrfd_500m_640.onnx"),
|
||||
(
|
||||
c"models/scrfd_500m_640.int8.onnx",
|
||||
"scrfd_500m_640.int8.onnx",
|
||||
c"models/scrfd_500m_640.a16w8.onnx",
|
||||
"scrfd_500m_640.a16w8.onnx",
|
||||
),
|
||||
(c"models/scrfd_2.5g_640.onnx", "scrfd_2.5g_640.onnx"),
|
||||
(
|
||||
c"models/scrfd_2.5g_640.int8.onnx",
|
||||
"scrfd_2.5g_640.int8.onnx",
|
||||
c"models/scrfd_2.5g_640.a16w8.onnx",
|
||||
"scrfd_2.5g_640.a16w8.onnx",
|
||||
),
|
||||
(c"models/scrfd_10g_640.onnx", "scrfd_10g_640.onnx"),
|
||||
(c"models/scrfd_10g_640.int8.onnx", "scrfd_10g_640.int8.onnx"),
|
||||
(
|
||||
c"models/scrfd_10g_640.a16w8.onnx",
|
||||
"scrfd_10g_640.a16w8.onnx",
|
||||
),
|
||||
(c"models/arcface_mbf_b1.onnx", "arcface_mbf_b1.onnx"),
|
||||
(c"models/2d106det_b1.onnx", "2d106det_b1.onnx"),
|
||||
(c"models/2d106det_b1.a16w8.onnx", "2d106det_b1.a16w8.onnx"),
|
||||
(c"models/ocec_s_b1.onnx", "ocec_s_b1.onnx"),
|
||||
(c"models/sgc_l_48_b1.onnx", "sgc_l_48_b1.onnx"),
|
||||
(c"models/yolo26s-sem-ade20k.onnx", "yolo26s-sem-ade20k.onnx"),
|
||||
(
|
||||
c"models/yolo26s-sem-ade20k.a16w16.onnx",
|
||||
"yolo26s-sem-ade20k.a16w16.onnx",
|
||||
),
|
||||
(
|
||||
c"models/yolo26s-sem-ade20k.classes.json",
|
||||
"yolo26s-sem-ade20k.classes.json",
|
||||
@@ -360,6 +371,19 @@ 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/migan-512.a16w16.onnx", "migan-512.a16w16.onnx"),
|
||||
// The learned demosaic and denoise, one network per method
|
||||
// (FR-DEV-3g), each with the 16-bit form the Hexagon runs.
|
||||
(c"models/mosaic-fast-1408.onnx", "mosaic-fast-1408.onnx"),
|
||||
(
|
||||
c"models/mosaic-fast-1408.a16w16.onnx",
|
||||
"mosaic-fast-1408.a16w16.onnx",
|
||||
),
|
||||
(c"models/mosaic-hq-1408.onnx", "mosaic-hq-1408.onnx"),
|
||||
(
|
||||
c"models/mosaic-hq-1408.a16w16.onnx",
|
||||
"mosaic-hq-1408.a16w16.onnx",
|
||||
),
|
||||
];
|
||||
|
||||
let dir = dr_ui::shared_face_models_dir();
|
||||
@@ -422,8 +446,13 @@ fn unpack_bundled_models(app: &slint::android::AndroidApp) {
|
||||
// on a first launch they were not on disk until this line. The runtime
|
||||
// is in the APK's native library directory beside `libdarkroom.so`,
|
||||
// which is also where Qualcomm's DSP loader has to be pointed for the
|
||||
// Hexagon skel (docs/dev/inference.md §3, §8).
|
||||
dr_ui::inference::init(native_library_dir().into_iter().collect());
|
||||
// Hexagon skel (docs/dev/inference.md §3, §8). Two of them: the QNN
|
||||
// build, and the generic WebGPU build by its file name, which the
|
||||
// engine opens only when the first does not fit the SoC (§3.2).
|
||||
let runtimes = native_library_dir()
|
||||
.map(|dir| vec![dir.clone(), dir.join("libonnxruntime_generic.so")])
|
||||
.unwrap_or_default();
|
||||
dr_ui::inference::init(runtimes);
|
||||
}
|
||||
|
||||
/// The directory the system unpacked this APK's native libraries into.
|
||||
@@ -509,6 +538,19 @@ mod tests {
|
||||
Some(value.to_string())
|
||||
}
|
||||
|
||||
/// From API 31 the linker refuses a vendor library the manifest does not
|
||||
/// name, and QNN cannot create its Hexagon device without the DSP's RPC
|
||||
/// library: 0.22.0 ran every model on the CPU for want of this line.
|
||||
#[test]
|
||||
fn the_npu_can_reach_the_dsp() {
|
||||
assert!(
|
||||
manifest().contains(
|
||||
r#"<uses-native-library android:name="libcdsprpc.so" android:required="false" />"#
|
||||
),
|
||||
"libcdsprpc.so must be declared, and not required"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gallery_can_open_a_photograph_in_this_app() {
|
||||
let manifest = manifest();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -91,23 +106,52 @@ fn main() -> anyhow::Result<()> {
|
||||
/// providers, or against the wrong cuDNN — and a system copy whose providers
|
||||
/// do not load is not a problem, only a slower app: the probe builds a real
|
||||
/// session before believing a provider.
|
||||
///
|
||||
/// The order breaks ties only. The engine opens every runtime on this list
|
||||
/// and loads the one whose providers fit the GPU (inference.md §3.2), so a
|
||||
/// package's bundled builds — `runtimes/openvino` and `runtimes/webgpu`
|
||||
/// beside each place a package installs to, from
|
||||
/// `tools/fetch-bundled-runtimes.sh` — sit beside a CUDA or ROCm runtime
|
||||
/// without hiding it.
|
||||
fn runtime_dirs() -> Vec<PathBuf> {
|
||||
// A place a package installs to, and the bundled runtimes under it.
|
||||
fn packaged(dirs: &mut Vec<PathBuf>, base: PathBuf) {
|
||||
dirs.push(base.join("runtimes/openvino"));
|
||||
dirs.push(base.join("runtimes/webgpu"));
|
||||
dirs.push(base);
|
||||
}
|
||||
let mut dirs = Vec::new();
|
||||
if let Some(dir) = std::env::var_os("DARKROOM_ORT_DIR") {
|
||||
dirs.push(PathBuf::from(dir));
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(bin) = exe.parent() {
|
||||
dirs.push(bin.to_path_buf());
|
||||
dirs.push(bin.join("../lib/darkroom"));
|
||||
packaged(&mut dirs, bin.to_path_buf());
|
||||
packaged(&mut dirs, bin.join("../lib/darkroom"));
|
||||
}
|
||||
}
|
||||
dirs.push(dr_ui::inference::user_runtime_dir());
|
||||
#[cfg(target_os = "linux")]
|
||||
dirs.extend([
|
||||
PathBuf::from("/app/lib/darkroom"),
|
||||
PathBuf::from("/usr/lib/darkroom"),
|
||||
PathBuf::from("/usr/lib"),
|
||||
]);
|
||||
{
|
||||
packaged(&mut dirs, PathBuf::from("/app/lib/darkroom"));
|
||||
packaged(&mut dirs, PathBuf::from("/usr/lib/darkroom"));
|
||||
dirs.push(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,159 @@
|
||||
//! 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 [fast|best]
|
||||
//! ```
|
||||
//!
|
||||
//! Decode, the app's hot-pixel pass, the frame's noise from its best source,
|
||||
//! then one of the shipped networks (`best` unless named) under the inference engine on whatever rung this
|
||||
//! machine probes to. Writes `out.npy` — the active area, `h×w×3` f32 linear
|
||||
//! camera RGB — for comparison with the training repo's own path
|
||||
//! (`tools/compare_rust.py` in darkroom-denoise). `DARKROOM_ORT_DIR` points
|
||||
//! at an ONNX Runtime build; the engine's cache goes to `DR_ENGINE_CACHE` or
|
||||
//! a temporary directory. The whole-frame network (`mosaic-hq.onnx` beside
|
||||
//! the fixed file) runs where the rung takes any size; `DR_PLAN=tiles` keeps
|
||||
//! the 1408² tiles anyway, to compare the two.
|
||||
|
||||
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 [fast|best]");
|
||||
std::process::exit(2);
|
||||
};
|
||||
let shipped = match args.next().as_deref() {
|
||||
None | Some("best") => dr_denoise::BEST,
|
||||
Some("fast") => dr_denoise::FAST,
|
||||
Some(other) => {
|
||||
eprintln!("no network called {other}: fast or best");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let model = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../models/denoise")
|
||||
.join(shipped.file);
|
||||
let whole = model.with_file_name(shipped.whole);
|
||||
let tiles_only = std::env::var("DR_PLAN").is_ok_and(|p| p == "tiles");
|
||||
let mut models = vec![(Role::Denoiser, model.clone())];
|
||||
if whole.is_file() && !tiles_only {
|
||||
models.push((Role::WholeDenoiser, whole));
|
||||
}
|
||||
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,
|
||||
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 = if tiles_only {
|
||||
OnnxNet::open_tiled(&model, shipped)
|
||||
} else {
|
||||
OnnxNet::open(&model, shipped)
|
||||
}
|
||||
.expect("model");
|
||||
println!(
|
||||
"rung {} · {}",
|
||||
net.rung().map(|r| r.label()).unwrap_or("?"),
|
||||
if net.whole_frame() {
|
||||
"whole frame"
|
||||
} else {
|
||||
"1408² tiles"
|
||||
}
|
||||
);
|
||||
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,119 @@
|
||||
//! 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 repair;
|
||||
pub mod tile;
|
||||
|
||||
use dr_decode::RawImage;
|
||||
|
||||
pub use noise::{NoiseModel, Source};
|
||||
pub use tile::{Sizes, TileNet, HALO};
|
||||
|
||||
/// TRACES: FR-DEV-3g
|
||||
/// A network the app ships in `models/denoise/`: its fixed-tile file, the
|
||||
/// same network with any height and width for a whole frame (§14), and the
|
||||
/// context it needs past a tile's kept centre (§13).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Shipped {
|
||||
pub file: &'static str,
|
||||
pub whole: &'static str,
|
||||
pub halo: usize,
|
||||
}
|
||||
|
||||
/// The smallest student: 0.9 M parameters, 11 GMAC a megapixel.
|
||||
pub const FAST: Shipped = Shipped {
|
||||
file: "mosaic-fast-1408.onnx",
|
||||
whole: "mosaic-fast.onnx",
|
||||
halo: HALO,
|
||||
};
|
||||
/// One network of the first release's shape, 3.2 M parameters and 48 GMAC a
|
||||
/// megapixel, taught by the mixture of experts that was Best until 0.24:
|
||||
/// its edges at a third of its work (denoise.md §15). A new file name, not
|
||||
/// the old Medium's or Best's: the result cache keys a model by its name
|
||||
/// and size, and this one is byte for byte the old Medium's size.
|
||||
pub const BEST: Shipped = Shipped {
|
||||
file: "mosaic-hq-1408.onnx",
|
||||
whole: "mosaic-hq.onnx",
|
||||
halo: 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);
|
||||
// The active area laid out once, then the noise-aware repair the model
|
||||
// was trained behind (see `repair`).
|
||||
let mut mosaic: Vec<f32> = (0..h * w).map(|i| active.at(i / w, i % w)).collect();
|
||||
let pattern = raw.cfa_pattern;
|
||||
let repaired = repair::repair(&mut mosaic, h, w, repair::REPAIR_K, &|y, x, v| {
|
||||
noise.sigma(pattern.colour_at(x as u32, y as u32) as usize, v)
|
||||
});
|
||||
log::info!(
|
||||
"learned denoise: {repaired} photosites beyond {}σ of every neighbour repaired",
|
||||
repair::REPAIR_K
|
||||
);
|
||||
tile::run_tiled(
|
||||
net,
|
||||
h,
|
||||
w,
|
||||
raw.cfa_pattern,
|
||||
&|y, x| mosaic[y * w + 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,142 @@
|
||||
//! 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; on the Hexagon the `.a16w16.onnx` sibling, 16-bit
|
||||
//! activations and weights, 0.00 dB from f32 on the tablet itself where int8
|
||||
//! lost 5–9 dB (docs/dev/inference.md §1.5). That sibling is the same network
|
||||
//! with the Bayer packing spelled `SpaceToDepth`, which QNN can hold and the
|
||||
//! 6-D reshape it replaces it cannot.
|
||||
//!
|
||||
//! Each network also ships with any height and width (`mosaic-hq.onnx`
|
||||
//! beside `mosaic-hq-1408.onnx`, darkroom-denoise `tools/export_whole.py`,
|
||||
//! identical to the fixed file at 1408²). Where the rung takes any size, the
|
||||
//! frame runs whole instead of in tiles whose borders are thrown away — a
|
||||
//! 1408² tile keeps 1024², 1.89 photosites computed for each one kept
|
||||
//! (denoise.md §14).
|
||||
|
||||
use crate::tile::{Sizes, TileNet};
|
||||
use crate::{DenoiseError, Shipped};
|
||||
use dr_inference_engine::{Form, Model, Role};
|
||||
|
||||
/// The edge of the tile the shipped fixed-shape export takes.
|
||||
pub const TILE: usize = 1408;
|
||||
|
||||
/// What a whole-frame input's sides must be multiples of: the networks pack
|
||||
/// 2×2 and halve three times, so a side is a whole number of positions at
|
||||
/// their coarsest level only in steps of 16.
|
||||
pub const ALIGN: usize = 16;
|
||||
|
||||
pub struct OnnxNet {
|
||||
model: Model,
|
||||
sizes: Sizes,
|
||||
halo: usize,
|
||||
}
|
||||
|
||||
impl OnnxNet {
|
||||
/// The network `shipped`, whose fixed-tile file is at `path`.
|
||||
///
|
||||
/// On a rung that runs any input size (TensorRT, the CUDA provider —
|
||||
/// [`dr_inference_engine::whole_frame_limit`]) and with the any-size
|
||||
/// export installed beside it, the whole-frame network: the frame in one
|
||||
/// call, or the fewest large tiles that fit (§14). Its output is the
|
||||
/// fixed tiles' to rounding. Everywhere else, and if the whole-frame
|
||||
/// model will not open, the 1408² tiles.
|
||||
pub fn open(path: &std::path::Path, shipped: Shipped) -> Result<Self, DenoiseError> {
|
||||
if let Some(max) = dr_inference_engine::whole_frame_limit() {
|
||||
let whole = path.with_file_name(shipped.whole);
|
||||
if whole.is_file() {
|
||||
let opened = std::fs::read(&whole)
|
||||
.map_err(DenoiseError::from)
|
||||
.and_then(|bytes| {
|
||||
Ok(dr_inference_engine::open(
|
||||
Role::WholeDenoiser,
|
||||
Form::F32,
|
||||
&bytes,
|
||||
)?)
|
||||
});
|
||||
match opened {
|
||||
Ok(model) => {
|
||||
return Ok(OnnxNet {
|
||||
model,
|
||||
sizes: Sizes::Any { align: ALIGN, max },
|
||||
halo: shipped.halo,
|
||||
})
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"learned denoise: {} will not open ({e}); running 1408² tiles",
|
||||
whole.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::open_tiled(path, shipped)
|
||||
}
|
||||
|
||||
/// The fixed-tile network at `path`, whatever the rung: 1408² tiles.
|
||||
pub fn open_tiled(path: &std::path::Path, shipped: Shipped) -> 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)?,
|
||||
sizes: Sizes::Square(TILE),
|
||||
halo: shipped.halo,
|
||||
})
|
||||
}
|
||||
|
||||
/// Where it runs, for a status line.
|
||||
pub fn rung(&self) -> Result<dr_inference_engine::Rung, DenoiseError> {
|
||||
Ok(self.model.acquire()?.rung())
|
||||
}
|
||||
|
||||
/// Whether this is the whole-frame network.
|
||||
pub fn whole_frame(&self) -> bool {
|
||||
matches!(self.sizes, Sizes::Any { .. })
|
||||
}
|
||||
}
|
||||
|
||||
impl TileNet for OnnxNet {
|
||||
fn sizes(&self) -> Sizes {
|
||||
self.sizes
|
||||
}
|
||||
|
||||
fn halo(&self) -> usize {
|
||||
self.halo
|
||||
}
|
||||
|
||||
fn run(
|
||||
&mut self,
|
||||
rows: usize,
|
||||
cols: usize,
|
||||
mosaic: Vec<f32>,
|
||||
sigma: Vec<f32>,
|
||||
write: &mut dyn FnMut(&[f32]),
|
||||
) -> Result<(), DenoiseError> {
|
||||
let shape = ndarray::IxDyn(&[1, 1, rows, cols]);
|
||||
// The vectors become the tensors: no copy on the way in.
|
||||
let m = ort::value::Tensor::from_array(
|
||||
ndarray::Array::from_shape_vec(shape.clone(), mosaic)
|
||||
.map_err(|e| DenoiseError::Model(e.to_string()))?,
|
||||
)?;
|
||||
let s = ort::value::Tensor::from_array(
|
||||
ndarray::Array::from_shape_vec(shape, sigma)
|
||||
.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, rows as i64, cols as i64] {
|
||||
return Err(DenoiseError::Model(format!(
|
||||
"output is {dims:?}, expected [1, 3, {rows}, {cols}]"
|
||||
)));
|
||||
}
|
||||
// And none on the way out: the frame is written from the runtime's buffer.
|
||||
write(data);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! TRACES: FR-DEV-3g
|
||||
//! Hot and dead photosites, judged against the noise, before the network.
|
||||
//!
|
||||
//! The app's own pass (`dr_gpu::Demosaicer::repair_hot_pixels`) runs first and
|
||||
//! takes the gross defects. At high ISO it leaves thousands of photosites per
|
||||
//! 6D frame more than 8σ beyond every neighbour, which the network turns into
|
||||
//! specks. This second pass uses that pass's two tests with the threshold in
|
||||
//! units of the photosite's own σ from the noise model:
|
||||
//!
|
||||
//! - beyond every same-colour neighbour (two photosites away, the 3×3 of its
|
||||
//! plane) by more than `k·σ`, and
|
||||
//! - beyond every adjacent photosite, whatever its colour, by more than
|
||||
//! `k·σ` **and** by a factor of two — what keeps a real point of light,
|
||||
//! which lights its neighbours through the lens and the anti-aliasing
|
||||
//! filter. A margin in σ alone is not enough: on a bright star 8σ is a
|
||||
//! sliver of the signal, and the star would be flattened.
|
||||
//!
|
||||
//! A hot one becomes its brightest same-colour neighbour, a dead one its
|
||||
//! darkest. The shipped model was trained on input repaired exactly so
|
||||
//! (darkroom-denoise `denoise/repair.py`, `--repair-k 8`): the threshold
|
||||
//! belongs to the model, and changes with it. Neighbours off the frame are
|
||||
//! the nearest photosite on it, as the training code reads them.
|
||||
|
||||
/// The threshold the shipped model was trained with, in σ.
|
||||
pub const REPAIR_K: f32 = 8.0;
|
||||
|
||||
/// Repair `mosaic` (`h×w`, row-major, normalised) in place; `sigma(y, x, v)`
|
||||
/// is the photosite's σ. Returns how many photosites changed.
|
||||
pub fn repair(
|
||||
mosaic: &mut [f32],
|
||||
h: usize,
|
||||
w: usize,
|
||||
k: f32,
|
||||
sigma: &(dyn Fn(usize, usize, f32) -> f32 + Sync),
|
||||
) -> usize {
|
||||
let copy = mosaic.to_vec();
|
||||
let original = ©
|
||||
let at = |y: isize, x: isize| {
|
||||
let y = y.clamp(0, h as isize - 1) as usize;
|
||||
let x = x.clamp(0, w as isize - 1) as usize;
|
||||
original[y * w + x]
|
||||
};
|
||||
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||
let rows_per = h.div_ceil(threads).max(1);
|
||||
let mut counts = vec![0usize; h.div_ceil(rows_per)];
|
||||
std::thread::scope(|scope| {
|
||||
for ((chunk, rows), count) in mosaic
|
||||
.chunks_mut(rows_per * w)
|
||||
.enumerate()
|
||||
.zip(counts.iter_mut())
|
||||
{
|
||||
let at = &at;
|
||||
scope.spawn(move || {
|
||||
for (i, row) in rows.chunks_mut(w).enumerate() {
|
||||
let y = chunk * rows_per + i;
|
||||
for (x, out) in row.iter_mut().enumerate() {
|
||||
let v = original[y * w + x];
|
||||
let (yi, xi) = (y as isize, x as isize);
|
||||
let (mut s_hi, mut s_lo) = (f32::MIN, f32::MAX);
|
||||
let (mut a_hi, mut a_lo) = (f32::MIN, f32::MAX);
|
||||
for dy in -1isize..=1 {
|
||||
for dx in -1isize..=1 {
|
||||
if dy == 0 && dx == 0 {
|
||||
continue;
|
||||
}
|
||||
let s = at(yi + 2 * dy, xi + 2 * dx);
|
||||
s_hi = s_hi.max(s);
|
||||
s_lo = s_lo.min(s);
|
||||
let a = at(yi + dy, xi + dx);
|
||||
a_hi = a_hi.max(a);
|
||||
a_lo = a_lo.min(a);
|
||||
}
|
||||
}
|
||||
let t = k * sigma(y, x, v);
|
||||
if v - s_hi > t && v - a_hi > t && a_hi < 0.5 * v {
|
||||
*out = s_hi;
|
||||
*count += 1;
|
||||
} else if s_lo - v > t && a_lo - v > t && v < 0.5 * a_lo {
|
||||
*out = s_lo;
|
||||
*count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
counts.iter().sum()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const N: usize = 16;
|
||||
|
||||
fn flat(level: f32) -> Vec<f32> {
|
||||
vec![level; N * N]
|
||||
}
|
||||
|
||||
fn run(m: &mut [f32]) -> usize {
|
||||
repair(m, N, N, REPAIR_K, &|_, _, _| 0.01)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hot_photosite_becomes_its_brightest_same_colour_neighbour() {
|
||||
let mut m = flat(0.1);
|
||||
m[8 * N + 8] = 0.5; // 40σ above everything around it
|
||||
m[8 * N + 10] = 0.12; // a same-colour neighbour, a little brighter
|
||||
assert_eq!(run(&mut m), 1);
|
||||
assert_eq!(m[8 * N + 8], 0.12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dead_photosite_in_a_lit_area_is_repaired() {
|
||||
let mut m = flat(0.5);
|
||||
m[5 * N + 5] = 0.0;
|
||||
assert_eq!(run(&mut m), 1);
|
||||
assert_eq!(m[5 * N + 5], 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_of_real_light_is_kept() {
|
||||
// Light through a lens lands on a patch: its adjacent photosites are
|
||||
// lit too, so the second test refuses it.
|
||||
let mut m = flat(0.1);
|
||||
for dy in 0..3 {
|
||||
for dx in 0..3 {
|
||||
m[(7 + dy) * N + 7 + dx] = if (dy, dx) == (1, 1) { 0.9 } else { 0.6 };
|
||||
}
|
||||
}
|
||||
let before = m.clone();
|
||||
assert_eq!(run(&mut m), 0);
|
||||
assert_eq!(m, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_within_the_threshold_is_left_alone() {
|
||||
let mut m: Vec<f32> = (0..N * N)
|
||||
.map(|i| 0.1 + 0.005 * ((i * 7919 % 13) as f32 - 6.0) / 6.0)
|
||||
.collect();
|
||||
let before = m.clone();
|
||||
assert_eq!(run(&mut m), 0);
|
||||
assert_eq!(m, before);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,729 @@
|
||||
//! TRACES: FR-DEV-3g
|
||||
//! A whole frame through a network, in tiles, exactly (denoise.md §3.4, §14).
|
||||
//!
|
||||
//! A tile's output is exact in its centre: past a halo wider than the
|
||||
//! network's receptive field (185 photosites for a single network, more for
|
||||
//! the mixture), 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.
|
||||
//!
|
||||
//! **Tile sizes.** A fixed-shape network takes one square ([`Sizes::Square`],
|
||||
//! 1408², of which Best keeps 896²). A network exported with any height and
|
||||
//! width ([`Sizes::Any`]) takes the frame whole when it is small enough, and
|
||||
//! otherwise the fewest equal tiles that are: [`plan`] picks the grid that
|
||||
//! computes the fewest photosites. If the first tile of a plan fails — a
|
||||
//! GPU out of memory — the limit is halved and the frame planned again.
|
||||
//!
|
||||
//! **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, for a
|
||||
/// single network; a mixture reaches further and says so through
|
||||
/// [`TileNet::halo`].
|
||||
pub const HALO: usize = 192;
|
||||
|
||||
/// The tiles a network takes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Sizes {
|
||||
/// One square, `n` photosites a side.
|
||||
Square(usize),
|
||||
/// Any rectangle whose sides are multiples of `align`, at most `max`
|
||||
/// (rows, columns).
|
||||
Any { align: usize, max: (usize, usize) },
|
||||
}
|
||||
|
||||
/// A network: `mosaic` and `sigma`, `rows×cols` RGGB, in; `3×rows×cols`
|
||||
/// planar linear camera RGB out.
|
||||
///
|
||||
/// The inputs are handed over, and the output is lent to `write` rather than
|
||||
/// returned: a 1408² tile is 24 MB of output and a whole frame 300 MB, and
|
||||
/// copying it out of the runtime's buffer and back into the frame was a
|
||||
/// measurable share of a frame's time.
|
||||
pub trait TileNet {
|
||||
/// The tile sizes it takes.
|
||||
fn sizes(&self) -> Sizes;
|
||||
/// Photosites of context it needs past a tile's kept centre: at least
|
||||
/// its receptive field. [`HALO`] unless the network says otherwise.
|
||||
fn halo(&self) -> usize {
|
||||
HALO
|
||||
}
|
||||
fn run(
|
||||
&mut self,
|
||||
rows: usize,
|
||||
cols: usize,
|
||||
mosaic: Vec<f32>,
|
||||
sigma: Vec<f32>,
|
||||
write: &mut dyn FnMut(&[f32]),
|
||||
) -> Result<(), crate::DenoiseError>;
|
||||
}
|
||||
|
||||
/// How a frame is cut: every tile `rows × cols` in, keeping its centre
|
||||
/// `core.0 × core.1` past the halo, on a `grid.0 × grid.1` grid.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Plan {
|
||||
pub rows: usize,
|
||||
pub cols: usize,
|
||||
pub core: (usize, usize),
|
||||
pub grid: (usize, usize),
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
/// Photosites the network computes for the frame.
|
||||
pub fn work(&self) -> usize {
|
||||
self.grid.0 * self.grid.1 * self.rows * self.cols
|
||||
}
|
||||
}
|
||||
|
||||
/// The tiles for an `uh × uw` frame (in the network's phase) at `sizes`, or
|
||||
/// `None` when no tile fits.
|
||||
///
|
||||
/// Square tiles are today's grid. Any-size tiles are equal on each axis, so
|
||||
/// one call shape serves the frame — TensorRT's profile tunes for one, and
|
||||
/// the CUDA provider searches its algorithms once per shape — and the grid
|
||||
/// is the one with the least work: one tile whenever the frame and its
|
||||
/// halo fit under `max`.
|
||||
pub fn plan(uh: usize, uw: usize, halo: usize, sizes: Sizes) -> Option<Plan> {
|
||||
match sizes {
|
||||
Sizes::Square(n) => {
|
||||
if n <= 2 * halo || !(n - 2 * halo).is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
let core = n - 2 * halo;
|
||||
Some(Plan {
|
||||
rows: n,
|
||||
cols: n,
|
||||
core: (core, core),
|
||||
grid: (uh.div_ceil(core), uw.div_ceil(core)),
|
||||
})
|
||||
}
|
||||
Sizes::Any { align, max } => {
|
||||
// An even align keeps every tile origin on an even photosite,
|
||||
// so every tile starts on red.
|
||||
let align = align.max(2).next_multiple_of(2);
|
||||
let axis = |extent: usize, tiles: usize, limit: usize| {
|
||||
let size = (extent.div_ceil(tiles) + 2 * halo).next_multiple_of(align);
|
||||
let core = size.checked_sub(2 * halo)?;
|
||||
(size <= limit && core > 0 && core.is_multiple_of(2)).then_some((size, core))
|
||||
};
|
||||
let mut best: Option<Plan> = None;
|
||||
for gy in 1..=16 {
|
||||
let Some((rows, cy)) = axis(uh, gy, max.0) else {
|
||||
continue;
|
||||
};
|
||||
for gx in 1..=16 {
|
||||
let Some((cols, cx)) = axis(uw, gx, max.1) else {
|
||||
continue;
|
||||
};
|
||||
let p = Plan {
|
||||
rows,
|
||||
cols,
|
||||
core: (cy, cx),
|
||||
grid: (uh.div_ceil(cy), uw.div_ceil(cx)),
|
||||
};
|
||||
if best.is_none_or(|b| p.work() < b.work()) {
|
||||
best = Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)`. An any-size
|
||||
/// network whose first tile fails is planned again with tiles half that
|
||||
/// size, until a tile would keep no centre; then the failure is returned.
|
||||
#[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 + Sync),
|
||||
sigma: &(dyn Fn(usize, f32) -> f32 + Sync),
|
||||
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 halo = net.halo();
|
||||
let (uh, uw) = (h + dy, w + dx);
|
||||
let mut sizes = net.sizes();
|
||||
loop {
|
||||
let plan = plan(uh, uw, halo, sizes).ok_or_else(|| {
|
||||
crate::DenoiseError::Model(format!(
|
||||
"no tile of {sizes:?} keeps a centre past a {halo} halo"
|
||||
))
|
||||
})?;
|
||||
match run_plan(net, plan, h, w, (dy, dx), halo, at, sigma, progress) {
|
||||
Err(Failed { error, first: true }) => {
|
||||
// The first call of a size is where a GPU runs out of
|
||||
// memory. Halve the larger kept centre of the tile that
|
||||
// failed — not the limit, which may be far above it, and not
|
||||
// the tile, half of which may be all halo — and plan again,
|
||||
// until no smaller tile keeps a centre.
|
||||
let Sizes::Any { align, .. } = sizes else {
|
||||
return Err(error);
|
||||
};
|
||||
let (cr, cc) = plan.core;
|
||||
let smaller = if cr >= cc {
|
||||
(cr / 2 + 2 * halo, plan.cols)
|
||||
} else {
|
||||
(plan.rows, cc / 2 + 2 * halo)
|
||||
};
|
||||
let next = Sizes::Any {
|
||||
align,
|
||||
max: smaller,
|
||||
};
|
||||
if self::plan(uh, uw, halo, next).is_none() {
|
||||
return Err(error);
|
||||
}
|
||||
log::warn!(
|
||||
"learned denoise: a {}×{} tile failed ({error}); trying tiles up to {}×{}",
|
||||
plan.rows,
|
||||
plan.cols,
|
||||
smaller.0,
|
||||
smaller.1
|
||||
);
|
||||
sizes = next;
|
||||
}
|
||||
Err(Failed { error, .. }) => return Err(error),
|
||||
Ok(done) => return Ok(done),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A run that stopped on an error, and whether it was the plan's first call.
|
||||
struct Failed {
|
||||
error: crate::DenoiseError,
|
||||
first: bool,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_plan(
|
||||
net: &mut dyn TileNet,
|
||||
plan: Plan,
|
||||
h: usize,
|
||||
w: usize,
|
||||
(dy, dx): (usize, usize),
|
||||
halo: usize,
|
||||
at: &(dyn Fn(usize, usize) -> f32 + Sync),
|
||||
sigma: &(dyn Fn(usize, f32) -> f32 + Sync),
|
||||
progress: &mut dyn FnMut(usize, usize) -> bool,
|
||||
) -> Result<Option<Vec<f32>>, Failed> {
|
||||
let Plan {
|
||||
rows: nr,
|
||||
cols: nc,
|
||||
core: (cr, cc),
|
||||
grid: (ty, tx),
|
||||
} = plan;
|
||||
// In unified coordinates the frame spans u ∈ [dy, dy + h), v ∈ [dx, dx + w).
|
||||
let (uh, uw) = (h + dy, w + dx);
|
||||
let total = ty * tx;
|
||||
let origins: Vec<(usize, usize)> = (0..ty)
|
||||
.flat_map(|i| (0..tx).map(move |j| (i * cr, j * cc)))
|
||||
.collect();
|
||||
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||
|
||||
// One tile's mosaic and σ, gathered on every core: rows are independent.
|
||||
let gather = |u0: usize, v0: usize| {
|
||||
let mut mos = vec![0.0f32; nr * nc];
|
||||
let mut sig = vec![0.0f32; nr * nc];
|
||||
let rows_per = nr.div_ceil(threads).max(1);
|
||||
std::thread::scope(|scope| {
|
||||
for (chunk, (m, s)) in mos
|
||||
.chunks_mut(rows_per * nc)
|
||||
.zip(sig.chunks_mut(rows_per * nc))
|
||||
.enumerate()
|
||||
{
|
||||
scope.spawn(move || {
|
||||
for (i, (mrow, srow)) in m.chunks_mut(nc).zip(s.chunks_mut(nc)).enumerate() {
|
||||
let r = chunk * rows_per + i;
|
||||
// Unified row u = u0 + r − halo; frame row y = u − dy, reflected.
|
||||
let u = u0 as isize + r as isize - halo as isize;
|
||||
let y = reflect(u - dy as isize, h);
|
||||
for c in 0..nc {
|
||||
let v = v0 as isize + c as isize - halo as isize;
|
||||
let x = reflect(v - dx as isize, w);
|
||||
let val = at(y, x);
|
||||
mrow[c] = val;
|
||||
// RGGB colour of the tile position (r, c).
|
||||
srow[c] = sigma([[0, 1], [1, 2]][r & 1][c & 1], val);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
(mos, sig)
|
||||
};
|
||||
|
||||
// Pipelined: the next tile is gathered while the network runs this one,
|
||||
// so the device does not wait on the CPU. A channel of one keeps at
|
||||
// most two tiles' inputs alive.
|
||||
let mut out = vec![0.0f32; h * w * 3];
|
||||
let stop = std::sync::atomic::AtomicBool::new(false);
|
||||
let fail = |error, k: usize, stop: &std::sync::atomic::AtomicBool| {
|
||||
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
Failed {
|
||||
error,
|
||||
first: k == 0,
|
||||
}
|
||||
};
|
||||
std::thread::scope(|scope| -> Result<Option<()>, Failed> {
|
||||
let (tx_tiles, rx_tiles) = std::sync::mpsc::sync_channel(1);
|
||||
let (origins, stop, gather) = (&origins, &stop, &gather);
|
||||
scope.spawn(move || {
|
||||
for &(u0, v0) in origins {
|
||||
if stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
if tx_tiles.send((u0, v0, gather(u0, v0))).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
for k in 0..total {
|
||||
let Ok((u0, v0, (mos, sig))) = rx_tiles.recv() else {
|
||||
break;
|
||||
};
|
||||
let mut wrong = None;
|
||||
let ran = net.run(nr, nc, mos, sig, &mut |rgb: &[f32]| {
|
||||
if rgb.len() != 3 * nr * nc {
|
||||
wrong = Some(rgb.len());
|
||||
return;
|
||||
}
|
||||
// The tile's centre back into the frame: the frame rows it covers,
|
||||
// split across cores (each row is written by one thread only).
|
||||
let (y_lo, y_hi) = (u0.max(dy) - dy, (u0 + cr).min(uh) - dy);
|
||||
let (x_lo, x_hi) = (v0.max(dx) - dx, (v0 + cc).min(uw) - dx);
|
||||
if y_hi > y_lo && x_hi > x_lo {
|
||||
let rows = &mut out[y_lo * w * 3..y_hi * w * 3];
|
||||
let per = (y_hi - y_lo).div_ceil(threads).max(1);
|
||||
std::thread::scope(|scope| {
|
||||
for (chunk, block) in rows.chunks_mut(per * w * 3).enumerate() {
|
||||
scope.spawn(move || {
|
||||
for (i, row) in block.chunks_mut(w * 3).enumerate() {
|
||||
let y = y_lo + chunk * per + i;
|
||||
// Tile row of frame row y: u = y + dy = u0 + r − halo.
|
||||
let r = y + dy + halo - u0;
|
||||
for x in x_lo..x_hi {
|
||||
let c = x + dx + halo - v0;
|
||||
for ch in 0..3 {
|
||||
row[x * 3 + ch] = rgb[ch * nr * nc + r * nc + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if let Err(e) = ran {
|
||||
while rx_tiles.try_recv().is_ok() {}
|
||||
return Err(fail(e, k, stop));
|
||||
}
|
||||
if let Some(len) = wrong {
|
||||
while rx_tiles.try_recv().is_ok() {}
|
||||
return Err(fail(
|
||||
crate::DenoiseError::Model(format!(
|
||||
"network returned {len} values for a {nr}×{nc} tile"
|
||||
)),
|
||||
k,
|
||||
stop,
|
||||
));
|
||||
}
|
||||
if !progress(k + 1, total) {
|
||||
stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
// Drain so the producer is not left blocked on a full channel.
|
||||
while rx_tiles.try_recv().is_ok() {}
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Ok(Some(()))
|
||||
})
|
||||
.map(|done| done.map(|()| 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 {
|
||||
sizes: Sizes,
|
||||
reach: usize,
|
||||
/// Fails any call with more photosites than this, as a GPU out of
|
||||
/// memory does.
|
||||
fails_above: usize,
|
||||
calls: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
fn square(n: usize, reach: usize) -> BoxNet {
|
||||
BoxNet {
|
||||
sizes: Sizes::Square(n),
|
||||
reach,
|
||||
fails_above: usize::MAX,
|
||||
calls: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn any(max: (usize, usize), reach: usize) -> BoxNet {
|
||||
BoxNet {
|
||||
sizes: Sizes::Any { align: 16, max },
|
||||
reach,
|
||||
fails_above: usize::MAX,
|
||||
calls: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
impl TileNet for BoxNet {
|
||||
fn sizes(&self) -> Sizes {
|
||||
self.sizes
|
||||
}
|
||||
fn run(
|
||||
&mut self,
|
||||
rows: usize,
|
||||
cols: usize,
|
||||
m: Vec<f32>,
|
||||
_s: Vec<f32>,
|
||||
write: &mut dyn FnMut(&[f32]),
|
||||
) -> Result<(), crate::DenoiseError> {
|
||||
self.calls.push((rows, cols));
|
||||
if rows * cols > self.fails_above {
|
||||
return Err(crate::DenoiseError::Model("out of memory".into()));
|
||||
}
|
||||
let (qr, qc) = (rows / 2, cols / 2);
|
||||
let quad = |qy: usize, qx: usize| {
|
||||
let (y, x) = (2 * qy, 2 * qx);
|
||||
[
|
||||
m[y * cols + x],
|
||||
0.5 * (m[y * cols + x + 1] + m[(y + 1) * cols + x]),
|
||||
m[(y + 1) * cols + x + 1],
|
||||
]
|
||||
};
|
||||
let plane = rows * cols;
|
||||
let mut out = vec![0.0; 3 * plane];
|
||||
for qy in 0..qr {
|
||||
for qx in 0..qc {
|
||||
let mut acc = [0.0f32; 3];
|
||||
let mut cnt = 0.0;
|
||||
for a in qy.saturating_sub(self.reach)..(qy + self.reach + 1).min(qr) {
|
||||
for b in qx.saturating_sub(self.reach)..(qx + self.reach + 1).min(qc) {
|
||||
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 * plane + (2 * qy + dy) * cols + 2 * qx + dx] = acc[c] / cnt;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
write(&out);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = square(2 * HALO + 64, 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 = square(2 * HALO + 32, 20);
|
||||
let mut big = square(2 * HALO + 256, 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The same frame through square tiles, one whole-frame call, a grid of
|
||||
/// any-size tiles, and a network that runs out of memory on the whole
|
||||
/// frame and is planned again: one answer.
|
||||
#[test]
|
||||
fn any_size_tiles_give_the_square_tiles_answer() {
|
||||
let (h, w) = (230, 170);
|
||||
for p in [
|
||||
CfaPattern::Rggb,
|
||||
CfaPattern::Grbg,
|
||||
CfaPattern::Gbrg,
|
||||
CfaPattern::Bggr,
|
||||
] {
|
||||
let at = |y: usize, x: usize| ((y * 7919 + x * 104729) % 1000) as f32 / 1000.0;
|
||||
let run = |net: &mut BoxNet| {
|
||||
run_tiled(net, h, w, p, &at, &|_, _| 0.0, &mut |_, _| true)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
};
|
||||
// A reach of 6 quads is well inside the halo, and keeps a
|
||||
// debug-build test of four phases short.
|
||||
let want = run(&mut square(2 * HALO + 32, 6));
|
||||
|
||||
let mut whole = any((4096, 4096), 6);
|
||||
let got = run(&mut whole);
|
||||
assert_eq!(whole.calls.len(), 1, "the frame fits: one call");
|
||||
assert_eq!(got, want, "{p:?}: whole frame");
|
||||
|
||||
let mut grid = any((2 * HALO + 96, 2 * HALO + 64), 6);
|
||||
let got = run(&mut grid);
|
||||
assert!(grid.calls.len() > 1);
|
||||
assert!(
|
||||
grid.calls.windows(2).all(|c| c[0] == c[1]),
|
||||
"one call shape"
|
||||
);
|
||||
assert_eq!(got, want, "{p:?}: a grid of any-size tiles");
|
||||
|
||||
let mut tight = any((4096, 4096), 6);
|
||||
tight.fails_above = (2 * HALO + 200) * (2 * HALO + 200);
|
||||
let got = run(&mut tight);
|
||||
assert_eq!(got, want, "{p:?}: planned again after a failure");
|
||||
assert!(tight.calls.len() > 2, "the whole frame failed, then tiles");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_plan_is_one_tile_when_the_frame_fits_and_the_least_work_when_not() {
|
||||
// A 6D frame with Best's halo, under the whole-frame limit: one call.
|
||||
let one = plan(
|
||||
3648,
|
||||
5472,
|
||||
256,
|
||||
Sizes::Any {
|
||||
align: 16,
|
||||
max: (4608, 6656),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(one.grid, (1, 1));
|
||||
assert_eq!((one.rows, one.cols), (4160, 5984));
|
||||
assert!(one.core.0 >= 3648 && one.core.1 >= 5472);
|
||||
// Too wide for one: the cheapest grid, every tile within the limit.
|
||||
let two = plan(
|
||||
3648,
|
||||
8192,
|
||||
256,
|
||||
Sizes::Any {
|
||||
align: 16,
|
||||
max: (4608, 6656),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(two.cols <= 6656 && two.rows <= 4608);
|
||||
assert_eq!(two.grid, (1, 2));
|
||||
// And always less work than today's 1408 squares.
|
||||
let squares = plan(3648, 5472, 256, Sizes::Square(1408)).unwrap();
|
||||
assert_eq!(squares.grid, (5, 7));
|
||||
assert!(one.work() * 2 < squares.work());
|
||||
// The whole-frame engine's limit on a 6 GB card: two tiles, each
|
||||
// within it, and still under half the work of the 1408 squares.
|
||||
let halves = plan(
|
||||
3648,
|
||||
5472,
|
||||
256,
|
||||
Sizes::Any {
|
||||
align: 16,
|
||||
max: (4608, 3328),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(halves.grid, (1, 2));
|
||||
assert_eq!((halves.rows, halves.cols), (4160, 3248));
|
||||
assert!(halves.work() * 2 < squares.work());
|
||||
// A limit no tile fits under.
|
||||
assert!(plan(
|
||||
3648,
|
||||
5472,
|
||||
256,
|
||||
Sizes::Any {
|
||||
align: 16,
|
||||
max: (400, 400)
|
||||
}
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_run_returns_nothing() {
|
||||
let mut net = square(2 * HALO + 32, 0);
|
||||
let r = run_tiled(
|
||||
&mut net,
|
||||
100,
|
||||
100,
|
||||
CfaPattern::Rggb,
|
||||
&|_, _| 0.5,
|
||||
&|_, _| 0.0,
|
||||
&mut |done, _| done < 2,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(r.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod timing {
|
||||
use super::*;
|
||||
|
||||
/// A network that answers instantly with an output of the right size,
|
||||
/// so what is timed is the tiler alone: gathering each tile's mosaic and
|
||||
/// σ, and writing its centre back.
|
||||
struct Null(usize, Vec<f32>);
|
||||
|
||||
impl TileNet for Null {
|
||||
fn sizes(&self) -> Sizes {
|
||||
Sizes::Square(self.0)
|
||||
}
|
||||
fn run(
|
||||
&mut self,
|
||||
_rows: usize,
|
||||
_cols: usize,
|
||||
m: Vec<f32>,
|
||||
_s: Vec<f32>,
|
||||
write: &mut dyn FnMut(&[f32]),
|
||||
) -> Result<(), crate::DenoiseError> {
|
||||
// Stands for the runtime's own output buffer: allocated once.
|
||||
if self.1.len() != 3 * m.len() {
|
||||
self.1 = vec![m[0]; 3 * m.len()];
|
||||
}
|
||||
write(&self.1);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `cargo test --release -p dr-denoise tiler_overhead -- --ignored --nocapture`
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn tiler_overhead_on_a_6d_frame() {
|
||||
let (h, w) = (3648, 5472);
|
||||
let frame: Vec<f32> = (0..h * w).map(|i| (i % 977) as f32 / 977.0).collect();
|
||||
let at = |y: usize, x: usize| frame[y * w + x];
|
||||
let sigma = |_c: usize, v: f32| (0.001 * v + 1e-5).sqrt();
|
||||
for n in [1408usize, 2048] {
|
||||
let mut net = Null(n, Vec::new());
|
||||
let t = std::time::Instant::now();
|
||||
let mut tiles = 0;
|
||||
run_tiled(
|
||||
&mut net,
|
||||
h,
|
||||
w,
|
||||
CfaPattern::Rggb,
|
||||
&at,
|
||||
&sigma,
|
||||
&mut |_, total| {
|
||||
tiles = total;
|
||||
true
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let s = t.elapsed().as_secs_f64();
|
||||
println!(
|
||||
"tile {n}: {tiles} tiles, tiler alone {s:.2} s ({:.0} ms a tile)",
|
||||
s / tiles as f64 * 1e3
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
@@ -137,8 +137,8 @@ impl Detection {
|
||||
/// A loaded SCRFD graph.
|
||||
pub struct Detector {
|
||||
session: Model,
|
||||
/// f32 or int8 — the int8 form finds a different set of faces and is a
|
||||
/// different detector in `model_id` (docs/dev/inference.md §7).
|
||||
/// f32 or a quantised form — which finds a different set of faces and is
|
||||
/// a different detector in `model_id` (docs/dev/inference.md §7).
|
||||
form: Form,
|
||||
/// Feature-map count: 3 for strides {8,16,32}, 4 for {8,16,32,64}.
|
||||
///
|
||||
@@ -155,7 +155,7 @@ impl Detector {
|
||||
}
|
||||
|
||||
/// Load the canonical f32 file at `path`, or the form the device's
|
||||
/// backend wants instead — the `.int8.onnx` beside it on a Hexagon —
|
||||
/// backend wants instead — the `.a16w8.onnx` beside it on a Hexagon —
|
||||
/// which [`Detector::form`] then reports.
|
||||
pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self, FaceError> {
|
||||
let (path, form) = dr_inference_engine::resolve_model(Role::Detector, path.as_ref());
|
||||
|
||||
@@ -123,13 +123,22 @@ pub struct Landmarker {
|
||||
}
|
||||
|
||||
impl Landmarker {
|
||||
/// The graph at `path`, or the `.a16w8.onnx` sibling beside it when the
|
||||
/// device's backend runs that (the Hexagon, inference.md §1.5: 0.25 px
|
||||
/// from f32 in the 192 crop, where int8 moved the points by 1.5).
|
||||
pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self, FaceError> {
|
||||
let (path, form) = dr_inference_engine::resolve_model(Role::Landmarks, path.as_ref());
|
||||
let bytes = std::fs::read(path).map_err(FaceError::ModelRead)?;
|
||||
Self::from_bytes(&bytes)
|
||||
Self::from_bytes_in(&bytes, form)
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, FaceError> {
|
||||
let model = dr_inference_engine::open(Role::Landmarks, Form::F32, bytes)?;
|
||||
Self::from_bytes_in(bytes, Form::F32)
|
||||
}
|
||||
|
||||
/// `bytes` in a stated numeric form; the output keeps its meaning.
|
||||
pub fn from_bytes_in(bytes: &[u8], form: Form) -> Result<Self, FaceError> {
|
||||
let model = dr_inference_engine::open(Role::Landmarks, form, bytes)?;
|
||||
let acquired = model.acquire()?;
|
||||
let session = acquired.lock();
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! List each RAW file's hot and dead photosite candidates (docs/dev/sensor-health.md).
|
||||
//!
|
||||
//! Reads paths on stdin and prints one JSON line per file: its capture
|
||||
//! conditions and every photosite [`Demosaicer::find_hot_pixels`] flags, as
|
||||
//! `[x, y, value, hot]` in sensor coordinates. Which candidates are defects
|
||||
//! is a question across frames, so this answers nothing on its own.
|
||||
//!
|
||||
//! ```sh
|
||||
//! find ~/Pictures -name '*.CR2' | cargo run --release -p dr-gpu --example sensor_scan
|
||||
//! ```
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
use dr_gpu::{Demosaicer, GpuContext};
|
||||
|
||||
fn main() {
|
||||
if let Some(list) = std::env::args().skip_while(|a| a != "--probe").nth(1) {
|
||||
return probe(&list);
|
||||
}
|
||||
let ctx = pollster::block_on(GpuContext::new_headless()).expect("a GPU for the hot-pixel pass");
|
||||
let demosaicer = Demosaicer::new(&ctx).expect("demosaicer");
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
let path = line.expect("stdin");
|
||||
match scan(&path, &demosaicer) {
|
||||
Ok(json) => println!("{json}"),
|
||||
Err(e) => eprintln!("fail\t{path}\t{e}"),
|
||||
}
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
}
|
||||
|
||||
fn scan(path: &str, demosaicer: &Demosaicer) -> Result<String, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
let raw = dr_decode::decode(&bytes).map_err(|e| e.to_string())?;
|
||||
let meta = dr_decode::metadata(&bytes).map_err(|e| e.to_string())?;
|
||||
let sites = demosaicer
|
||||
.find_hot_pixels(&raw)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let opt = |v: Option<f64>| v.map_or("null".to_string(), |v| v.to_string());
|
||||
let list: Vec<String> = sites
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let v = raw.data[(s.y * raw.width + s.x) as usize];
|
||||
format!("[{},{},{},{}]", s.x, s.y, v, u8::from(s.hot))
|
||||
})
|
||||
.collect();
|
||||
Ok(format!(
|
||||
"{{\"path\":{:?},\"model\":{:?},\"captured\":{},\"iso\":{},\"shutter\":{},\"white\":{},\"black\":{:?},\"crop\":[{},{},{},{}],\"sites\":[{}]}}",
|
||||
path,
|
||||
format!("{} {}", raw.make, raw.model),
|
||||
meta.captured_at.map_or("null".to_string(), |t| t.to_string()),
|
||||
opt(meta.iso.map(f64::from)),
|
||||
opt(meta.shutter.map(f64::from)),
|
||||
raw.white_level,
|
||||
raw.black_level,
|
||||
raw.crop.x,
|
||||
raw.crop.y,
|
||||
raw.crop.width,
|
||||
raw.crop.height,
|
||||
list.join(","),
|
||||
))
|
||||
}
|
||||
|
||||
/// `--probe COORDS`: for each path on stdin, each `x y` line of COORDS as
|
||||
/// `[value, same-colour neighbour max, median]` over black, on the CPU. A
|
||||
/// probe asks whether a photosite stood out in a frame where it would have
|
||||
/// been visible, which the scan's verdict cannot say: a frame that does not
|
||||
/// flag a defect may only have been too bright around it.
|
||||
fn probe(list: &str) {
|
||||
let coords: Vec<(u32, u32)> = std::fs::read_to_string(list)
|
||||
.expect("coords")
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let mut it = l.split_whitespace().map(|v| v.parse().ok());
|
||||
Some((it.next()??, it.next()??))
|
||||
})
|
||||
.collect();
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
let path = line.expect("stdin");
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
let (Ok(raw), Ok(meta)) = (dr_decode::decode(&bytes), dr_decode::metadata(&bytes)) else {
|
||||
continue;
|
||||
};
|
||||
let w = raw.width as i64;
|
||||
let at = |x: i64, y: i64| {
|
||||
let cell = (((y - raw.crop.y as i64) & 1) * 2 + ((x - raw.crop.x as i64) & 1)) as usize;
|
||||
raw.data[(y * w + x) as usize].saturating_sub(raw.black_level[cell])
|
||||
};
|
||||
let rows: Vec<String> = coords
|
||||
.iter()
|
||||
.map(|&(x, y)| {
|
||||
let (x, y) = (x as i64, y as i64);
|
||||
let mut n: Vec<u16> = Vec::new();
|
||||
for dy in [-2i64, 0, 2] {
|
||||
for dx in [-2i64, 0, 2] {
|
||||
if (dx, dy) != (0, 0) {
|
||||
n.push(at(x + dx, y + dy));
|
||||
}
|
||||
}
|
||||
}
|
||||
n.sort_unstable();
|
||||
format!("[{},{},{}]", at(x, y), n[n.len() - 1], n[n.len() / 2])
|
||||
})
|
||||
.collect();
|
||||
println!(
|
||||
"{{\"path\":{:?},\"captured\":{},\"iso\":{},\"shutter\":{},\"range\":{},\"p\":[{}]}}",
|
||||
path,
|
||||
meta.captured_at.unwrap_or(0),
|
||||
meta.iso.unwrap_or(0),
|
||||
meta.shutter.unwrap_or(0.0),
|
||||
raw.white_level - raw.black_level[0],
|
||||
rows.join(","),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+500
-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,238 @@ 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 words = self.hot_pixel_words(raw)?;
|
||||
let mut changed = 0;
|
||||
for (i, v) in raw.data.iter_mut().enumerate() {
|
||||
let new = unpack_sample(&words, i);
|
||||
changed += usize::from(new != *v);
|
||||
*v = new;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// The photosites [`Self::repair_hot_pixels`] would replace, in sensor
|
||||
/// coordinates, without replacing them.
|
||||
///
|
||||
/// For the sensor health record (docs/dev/sensor-health.md): one frame's
|
||||
/// verdict is a candidate list, not a defect map — a single photosite of a
|
||||
/// star that passes both tests reads the same as a hot one. Which of them
|
||||
/// is the sensor is decided across frames, by who keeps coming back.
|
||||
pub fn find_hot_pixels(&self, raw: &RawImage) -> Result<Vec<Photosite>, GpuError> {
|
||||
if raw.samples_per_pixel != 1 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let words = self.hot_pixel_words(raw)?;
|
||||
let stride = raw.width.max(1);
|
||||
Ok(raw
|
||||
.data
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, &v)| {
|
||||
let new = unpack_sample(&words, i);
|
||||
(new != v).then(|| Photosite {
|
||||
x: i as u32 % stride,
|
||||
y: i as u32 / stride,
|
||||
hot: new < v,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The hot-pixel pass over `raw`, read back as packed words.
|
||||
fn hot_pixel_words(&self, raw: &RawImage) -> Result<Vec<u32>, GpuError> {
|
||||
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();
|
||||
Ok(words)
|
||||
}
|
||||
}
|
||||
|
||||
/// One photosite the hot-pixel pass judged defective.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Photosite {
|
||||
/// Sensor coordinates: the full readout, masked border included.
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
/// Read far above its neighbourhood; otherwise far below (dead).
|
||||
pub hot: bool,
|
||||
}
|
||||
|
||||
/// Sample `i` of a readout packed by [`pack_samples`].
|
||||
fn unpack_sample(words: &[u32], i: usize) -> u16 {
|
||||
let w = words[i / 2];
|
||||
(if i.is_multiple_of(2) {
|
||||
w & 0xFFFF
|
||||
} else {
|
||||
w >> 16
|
||||
}) as u16
|
||||
}
|
||||
|
||||
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 +1676,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 +1793,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 +2091,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 +2177,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;
|
||||
@@ -37,10 +38,11 @@ pub use adjust::AdjustPass;
|
||||
// rather than an implementation detail: a detail pass is guaranteed linear,
|
||||
// unclipped, full internal precision (FR-DEV-2), and anyone reasoning about
|
||||
// VRAM at 24 MP needs to know what an intermediate costs.
|
||||
pub use demosaic::{DemosaicedImage, Demosaicer};
|
||||
pub use demosaic::{DemosaicedImage, Demosaicer, Photosite};
|
||||
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,303 @@
|
||||
//! 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, PROFILE_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()
|
||||
}
|
||||
|
||||
/// The profile at the strength it states — the look table on, as the
|
||||
/// reference applies it at 1.0. Not the default, which leaves it off (D21).
|
||||
fn as_stated() -> CameraProfile {
|
||||
let mut op = CameraProfile::new();
|
||||
op.set_param(LOOK, PROFILE_LOOK);
|
||||
op
|
||||
}
|
||||
|
||||
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())), as_stated());
|
||||
assert_agrees(
|
||||
&got,
|
||||
|c| apply_reference(&tables, c, 1.0),
|
||||
"as the profile states it",
|
||||
);
|
||||
|
||||
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())), as_stated());
|
||||
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,8 +6,8 @@
|
||||
//! 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_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||||
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext, Photosite};
|
||||
use dr_pipeline::EditGraph;
|
||||
|
||||
const SIZE: u32 = 36;
|
||||
@@ -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,73 @@ 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]));
|
||||
}
|
||||
|
||||
/// Finding without repairing (docs/dev/sensor-health.md): the same verdict as
|
||||
/// the repair, as sensor coordinates, with the frame left as it was. The
|
||||
/// sensor health record builds on this, so it must name exactly the
|
||||
/// photosites the repair would change — the hot one and the dead one, and
|
||||
/// not the star.
|
||||
#[test]
|
||||
fn finding_names_what_the_repair_would_change_and_changes_nothing() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let d = Demosaicer::new(&ctx).expect("demosaicer");
|
||||
let mut set = vec![(MIDDLE, MIDDLE, WHITE), (9, 25, 0)];
|
||||
for dy in 0..3 {
|
||||
for dx in 0..3 {
|
||||
set.push((4 + dx, 4 + dy, WHITE));
|
||||
}
|
||||
}
|
||||
let raw = frame(CfaPattern::Rggb, 1600, &set);
|
||||
let mut found = d.find_hot_pixels(&raw).expect("find");
|
||||
found.sort_by_key(|p| (p.y, p.x));
|
||||
assert_eq!(
|
||||
found,
|
||||
vec![
|
||||
Photosite {
|
||||
x: MIDDLE,
|
||||
y: MIDDLE,
|
||||
hot: true
|
||||
},
|
||||
Photosite {
|
||||
x: 9,
|
||||
y: 25,
|
||||
hot: false
|
||||
},
|
||||
]
|
||||
);
|
||||
let mut repaired = raw.clone();
|
||||
assert_eq!(d.repair_hot_pixels(&mut repaired).expect("repair"), 2);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -24,6 +24,14 @@ enum Ep {
|
||||
Cpu,
|
||||
MiGraphX,
|
||||
MiGraphXFp16,
|
||||
OpenVinoCpu,
|
||||
OpenVinoGpu,
|
||||
OpenVinoGpuFp16,
|
||||
OpenVinoNpu,
|
||||
/// Dawn's low-power adapter: the integrated GPU on a hybrid machine.
|
||||
WebGpuLow,
|
||||
/// Dawn's high-performance adapter: the discrete one, if there is one.
|
||||
WebGpuHigh,
|
||||
}
|
||||
|
||||
impl Ep {
|
||||
@@ -32,20 +40,113 @@ impl Ep {
|
||||
Ep::Cpu => "CPU",
|
||||
Ep::MiGraphX => "MIGraphX f32",
|
||||
Ep::MiGraphXFp16 => "MIGraphX fp16",
|
||||
Ep::OpenVinoCpu => "OpenVINO CPU",
|
||||
Ep::OpenVinoGpu => "OpenVINO GPU",
|
||||
Ep::OpenVinoGpuFp16 => "OpenVINO GPU16",
|
||||
Ep::OpenVinoNpu => "OpenVINO NPU",
|
||||
Ep::WebGpuLow => "WebGPU low",
|
||||
Ep::WebGpuHigh => "WebGPU high",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a second build reads what the first one compiled.
|
||||
fn caches(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Ep::MiGraphX
|
||||
| Ep::MiGraphXFp16
|
||||
| Ep::OpenVinoGpu
|
||||
| Ep::OpenVinoGpuFp16
|
||||
| Ep::OpenVinoNpu
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn build(ep: Ep, bytes: &[u8], threads: usize, cache: &Path) -> ort::Result<ort::session::Session> {
|
||||
let mut b = ort::session::Session::builder()?.with_intra_threads(threads)?;
|
||||
let dir = |sub: &str| {
|
||||
let d = cache.join(sub);
|
||||
let _ = std::fs::create_dir_all(&d);
|
||||
d.to_string_lossy().into_owned()
|
||||
};
|
||||
// `GPU` is OpenVINO's first OpenCL GPU, which on a hybrid laptop can be
|
||||
// the discrete NVIDIA one; DARKROOM_OV_GPU=GPU.1 names another.
|
||||
let gpu = std::env::var("DARKROOM_OV_GPU").unwrap_or_else(|_| "GPU".into());
|
||||
match ep {
|
||||
Ep::Cpu => {}
|
||||
Ep::MiGraphX => migraphx(&mut b, false, &cache.join("f32"))?,
|
||||
Ep::MiGraphXFp16 => migraphx(&mut b, true, &cache.join("fp16"))?,
|
||||
// Option names as `openvino_provider_factory.cc` reads them at 1.24.
|
||||
Ep::OpenVinoCpu => append(&mut b, c"OpenVINO", &[("device_type", "CPU".into())])?,
|
||||
Ep::OpenVinoGpu => append(
|
||||
&mut b,
|
||||
c"OpenVINO",
|
||||
&[
|
||||
("device_type", gpu.clone()),
|
||||
("precision", "FP32".into()),
|
||||
("cache_dir", dir("ov-gpu-f32")),
|
||||
],
|
||||
)?,
|
||||
Ep::OpenVinoGpuFp16 => append(
|
||||
&mut b,
|
||||
c"OpenVINO",
|
||||
&[
|
||||
("device_type", gpu.clone()),
|
||||
("precision", "FP16".into()),
|
||||
("cache_dir", dir("ov-gpu-fp16")),
|
||||
],
|
||||
)?,
|
||||
Ep::OpenVinoNpu => append(
|
||||
&mut b,
|
||||
c"OpenVINO",
|
||||
&[("device_type", "NPU".into()), ("cache_dir", dir("ov-npu"))],
|
||||
)?,
|
||||
// `webgpu_provider_options.h` at 1.27; the runtime prefixes the key.
|
||||
Ep::WebGpuLow => append(
|
||||
&mut b,
|
||||
c"WebGPU",
|
||||
&[("powerPreference", "low-power".into())],
|
||||
)?,
|
||||
Ep::WebGpuHigh => append(
|
||||
&mut b,
|
||||
c"WebGPU",
|
||||
&[("powerPreference", "high-performance".into())],
|
||||
)?,
|
||||
}
|
||||
b.commit_from_memory(bytes)
|
||||
}
|
||||
|
||||
/// Any provider through the generic key/value entry point.
|
||||
fn append(
|
||||
b: &mut ort::session::builder::SessionBuilder,
|
||||
name: &std::ffi::CStr,
|
||||
options: &[(&str, String)],
|
||||
) -> ort::Result<()> {
|
||||
use ort::AsPointer;
|
||||
use std::ffi::CString;
|
||||
let keys: Vec<CString> = options
|
||||
.iter()
|
||||
.map(|(k, _)| CString::new(*k).unwrap())
|
||||
.collect();
|
||||
let values: Vec<CString> = options
|
||||
.iter()
|
||||
.map(|(_, v)| CString::new(v.as_bytes()).unwrap())
|
||||
.collect();
|
||||
let key_ptrs: Vec<_> = keys.iter().map(|k| k.as_ptr()).collect();
|
||||
let value_ptrs: Vec<_> = values.iter().map(|v| v.as_ptr()).collect();
|
||||
// SAFETY: as `migraphx` below.
|
||||
unsafe {
|
||||
let status = (ort::api().SessionOptionsAppendExecutionProvider)(
|
||||
b.ptr_mut(),
|
||||
name.as_ptr(),
|
||||
key_ptrs.as_ptr(),
|
||||
value_ptrs.as_ptr(),
|
||||
keys.len(),
|
||||
);
|
||||
ort::Error::result_from_status(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Register MIGraphX through the generic key/value API. `ort`'s own
|
||||
/// builder fills the legacy `OrtMIGraphXProviderOptions`, which 1.29 reads
|
||||
/// for its precision flags and nothing else: the model cache directory —
|
||||
@@ -83,19 +184,29 @@ fn migraphx(
|
||||
|
||||
/// Median of `runs` timed runs over zeros, in milliseconds, after warm-ups.
|
||||
fn time(session: &mut ort::session::Session, warmups: usize, runs: usize) -> Result<f64, String> {
|
||||
let shape: Vec<usize> = session.inputs()[0]
|
||||
.dtype()
|
||||
.tensor_shape()
|
||||
.ok_or("input is not a tensor")?
|
||||
.iter()
|
||||
.map(|&d| if d > 0 { d as usize } else { 1 })
|
||||
.collect();
|
||||
let zeros = vec![0f32; shape.iter().product()];
|
||||
// Zeros for every input, not just the first: the denoiser takes
|
||||
// `mosaic` and `sigma`. A dynamic dimension is read as 1.
|
||||
let mut inputs = Vec::new();
|
||||
for input in session.inputs() {
|
||||
let shape: Vec<usize> = input
|
||||
.dtype()
|
||||
.tensor_shape()
|
||||
.ok_or("input is not a tensor")?
|
||||
.iter()
|
||||
.map(|&d| if d > 0 { d as usize } else { 1 })
|
||||
.collect();
|
||||
let zeros = vec![0f32; shape.iter().product()];
|
||||
inputs.push((input.name().to_string(), shape, zeros));
|
||||
}
|
||||
let once = |s: &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 values = Vec::with_capacity(inputs.len());
|
||||
for (name, shape, zeros) in &inputs {
|
||||
let value = ort::value::Tensor::from_array((shape.clone(), zeros.clone()))
|
||||
.map_err(|e| e.to_string())?;
|
||||
values.push((name.clone(), ort::session::SessionInputValue::from(value)));
|
||||
}
|
||||
let t = Instant::now();
|
||||
let out = s.run(ort::inputs![input]).map_err(|e| e.to_string())?;
|
||||
let out = s.run(values).map_err(|e| e.to_string())?;
|
||||
let _ = out[0]
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -155,13 +266,35 @@ fn main() {
|
||||
// A compiling provider is built twice: the second build reads the
|
||||
// program the first wrote, and its time is what a launch after the
|
||||
// first costs.
|
||||
let plan = [
|
||||
(Ep::Cpu, false),
|
||||
(Ep::MiGraphX, false),
|
||||
(Ep::MiGraphX, true),
|
||||
(Ep::MiGraphXFp16, false),
|
||||
(Ep::MiGraphXFp16, true),
|
||||
];
|
||||
// DARKROOM_EPS narrows the list (`cpu,openvino,webgpu,migraphx`);
|
||||
// a runtime without a provider fails its build in a millisecond
|
||||
// anyway, so the default is all of them.
|
||||
let wanted = std::env::var("DARKROOM_EPS").unwrap_or_default();
|
||||
let on = |family: &str| wanted.is_empty() || wanted.split(',').any(|w| w == family);
|
||||
let mut plan = Vec::new();
|
||||
for (family, eps) in [
|
||||
("cpu", &[Ep::Cpu][..]),
|
||||
("migraphx", &[Ep::MiGraphX, Ep::MiGraphXFp16][..]),
|
||||
(
|
||||
"openvino",
|
||||
&[
|
||||
Ep::OpenVinoCpu,
|
||||
Ep::OpenVinoGpu,
|
||||
Ep::OpenVinoGpuFp16,
|
||||
Ep::OpenVinoNpu,
|
||||
][..],
|
||||
),
|
||||
("webgpu", &[Ep::WebGpuLow, Ep::WebGpuHigh][..]),
|
||||
] {
|
||||
if on(family) {
|
||||
for &ep in eps {
|
||||
plan.push((ep, false));
|
||||
if ep.caches() {
|
||||
plan.push((ep, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (ep, cached) in plan {
|
||||
let started = Instant::now();
|
||||
match build(ep, &bytes, threads, &cache) {
|
||||
|
||||
@@ -4,12 +4,17 @@
|
||||
//!
|
||||
//! DARKROOM_ORT_DIR=/usr/lib \
|
||||
//! cargo run --release -p dr-inference-engine --features native,tract \
|
||||
//! --example ladder -- CACHE_DIR models/face/scrfd_500m_640.onnx [MODEL.onnx ...]
|
||||
//! --example ladder -- CACHE_DIR models/face/scrfd_500m_640.onnx [ROLE=MODEL.onnx ...]
|
||||
//!
|
||||
//! Every model named is a `Detector` for the config's purposes, which is
|
||||
//! enough to see the rung taken, the engines compiled and a session land
|
||||
//! on it. Delete `CACHE_DIR` to see the first run again; keep it to see the
|
||||
//! second.
|
||||
//! `DARKROOM_ORT_DIRS=a:b:c` offers several runtimes, as the app's search
|
||||
//! list does, and shows which the engine chose for this device's GPU.
|
||||
//!
|
||||
//! A bare path is a `Detector`; `denoiser=…`, `scene=…`, `inpainter=…`,
|
||||
//! `landmarks=…` (any `Role`, lower case) says otherwise, so a device can
|
||||
//! show each role taking its own form (inference.md §1.5). Each is opened
|
||||
//! through `resolve_model`, as the app opens it, and the line says which
|
||||
//! form and which rung it landed on. Delete `CACHE_DIR` to see the first
|
||||
//! run again; keep it to see the second.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -17,7 +22,10 @@ use std::time::{Duration, Instant};
|
||||
fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
let mut args = std::env::args_os().skip(1).map(PathBuf::from);
|
||||
let (Some(cache_dir), models) = (args.next(), args.collect::<Vec<_>>()) else {
|
||||
let (Some(cache_dir), models) = (
|
||||
args.next(),
|
||||
args.map(|a| role_and_path(&a)).collect::<Vec<_>>(),
|
||||
) else {
|
||||
eprintln!("usage: ladder CACHE_DIR MODEL.onnx [MODEL.onnx ...]");
|
||||
std::process::exit(2);
|
||||
};
|
||||
@@ -26,18 +34,22 @@ fn main() {
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
// DARKROOM_ORT_DIRS lists several, colon-separated, as the app's search
|
||||
// does: the engine loads the one that fits the GPU (§3.2).
|
||||
let runtime_dirs: Vec<PathBuf> = std::env::var_os("DARKROOM_ORT_DIR")
|
||||
.map(PathBuf::from)
|
||||
.into_iter()
|
||||
.chain(
|
||||
std::env::var_os("DARKROOM_ORT_DIRS")
|
||||
.map(|v| std::env::split_paths(&v).collect::<Vec<_>>())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.collect();
|
||||
let started = Instant::now();
|
||||
dr_inference_engine::init(dr_inference_engine::Config {
|
||||
runtime_dirs,
|
||||
cache_dir: cache_dir.clone(),
|
||||
models: models
|
||||
.iter()
|
||||
.map(|p| (dr_inference_engine::Role::Detector, p.clone()))
|
||||
.collect(),
|
||||
models: models.clone(),
|
||||
embedded: Vec::new(),
|
||||
ceiling: None,
|
||||
threads: 0,
|
||||
@@ -80,21 +92,39 @@ fn main() {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
|
||||
for path in &models {
|
||||
let bytes = std::fs::read(path).expect("read model");
|
||||
for (role, path) in &models {
|
||||
let (path, form) = dr_inference_engine::resolve_model(*role, path);
|
||||
let bytes = std::fs::read(&path).expect("read model");
|
||||
let t = Instant::now();
|
||||
let model = dr_inference_engine::open(
|
||||
dr_inference_engine::Role::Detector,
|
||||
dr_inference_engine::Form::F32,
|
||||
&bytes,
|
||||
)
|
||||
.expect("open model");
|
||||
let model = dr_inference_engine::open(*role, form, &bytes).expect("open model");
|
||||
let acquired = model.acquire().expect("acquire session");
|
||||
println!(
|
||||
"{} on {} in {:.2} s",
|
||||
"{role:?}: {} ({form:?}) on {} in {:.2} s",
|
||||
path.file_name().unwrap().to_string_lossy(),
|
||||
acquired.rung().label(),
|
||||
t.elapsed().as_secs_f64()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `denoiser=path` → (Denoiser, path); a bare path is a detector.
|
||||
fn role_and_path(arg: &std::path::Path) -> (dr_inference_engine::Role, PathBuf) {
|
||||
use dr_inference_engine::Role::*;
|
||||
let s = arg.to_string_lossy();
|
||||
let Some((name, path)) = s.split_once('=') else {
|
||||
return (Detector, arg.to_path_buf());
|
||||
};
|
||||
let role = match name {
|
||||
"detector" => Detector,
|
||||
"embedder" => Embedder,
|
||||
"segmenter" => Segmenter,
|
||||
"scene" => Scene,
|
||||
"landmarks" => Landmarks,
|
||||
"eyes" => EyeClassifier,
|
||||
"keypoints" => Keypoints,
|
||||
"inpainter" => Inpainter,
|
||||
"denoiser" => Denoiser,
|
||||
other => panic!("no role {other:?}"),
|
||||
};
|
||||
(role, PathBuf::from(path))
|
||||
}
|
||||
|
||||
@@ -51,17 +51,25 @@ pub fn ensure_installed() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Look for `libonnxruntime` in `dirs`, in order, and hand `ort` the first
|
||||
/// table that loads; otherwise tract. Once per process.
|
||||
/// Find every `libonnxruntime` in `dirs`, hand `ort` the table of the one
|
||||
/// that best fits this device's GPUs, and fall to tract if none loads.
|
||||
/// Once per process.
|
||||
///
|
||||
/// Best fit, not first found (§3.2): a device can hold several runtimes —
|
||||
/// the package's OpenVINO build, a CUDA build the user fetched, the
|
||||
/// distribution's ROCm build — and each carries one vendor's providers.
|
||||
/// Between equals, the earlier directory wins, as it always has, and a
|
||||
/// runtime that fits perfectly ends the search: the APK's QNN build on a
|
||||
/// Qualcomm tablet is found first, and the generic build beside it is
|
||||
/// never opened there.
|
||||
/// `DARKROOM_ORT_DIR`, when it loads, wins outright: it is how a person
|
||||
/// says which runtime they mean.
|
||||
pub fn install(dirs: &[PathBuf]) -> Runtime {
|
||||
RUNTIME
|
||||
.get_or_init(|| {
|
||||
#[cfg(feature = "native")]
|
||||
for dir in dirs {
|
||||
match load_native(dir) {
|
||||
Ok(rt) => return rt,
|
||||
Err(e) => log::info!("inference: no runtime in {}: {e}", dir.display()),
|
||||
}
|
||||
if let Some(rt) = install_best(dirs) {
|
||||
return rt;
|
||||
}
|
||||
#[cfg(not(feature = "native"))]
|
||||
let _ = dirs;
|
||||
@@ -70,6 +78,103 @@ pub fn install(dirs: &[PathBuf]) -> Runtime {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// A runtime opened to read its providers, not yet handed to `ort`.
|
||||
#[cfg(feature = "native")]
|
||||
struct Found {
|
||||
lib: libloading::Library,
|
||||
api: *const ort_sys::OrtApi,
|
||||
path: PathBuf,
|
||||
version: String,
|
||||
providers: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
fn install_best(dirs: &[PathBuf]) -> Option<Runtime> {
|
||||
let named = std::env::var_os("DARKROOM_ORT_DIR").map(PathBuf::from);
|
||||
let gpus = crate::hardware::detect();
|
||||
let mut found: Vec<Found> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for dir in dirs {
|
||||
match open_native(dir) {
|
||||
Ok(f) => {
|
||||
// `bin/../lib/darkroom` and `/usr/lib/darkroom` are one file.
|
||||
if !seen.insert(std::fs::canonicalize(&f.path).unwrap_or(f.path.clone())) {
|
||||
std::mem::forget(f.lib);
|
||||
continue;
|
||||
}
|
||||
log::info!(
|
||||
"inference: ONNX Runtime {} at {} offers {}",
|
||||
f.version,
|
||||
f.path.display(),
|
||||
f.providers.join(", ")
|
||||
);
|
||||
if named.as_deref() == Some(dir.as_path()) {
|
||||
found.clear();
|
||||
found.push(f);
|
||||
break;
|
||||
}
|
||||
let perfect = gpus.score(&f.providers) >= crate::hardware::PERFECT;
|
||||
found.push(f);
|
||||
if perfect {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => log::info!("inference: no runtime in {}: {e}", dir.display()),
|
||||
}
|
||||
}
|
||||
let best = (0..found.len())
|
||||
.max_by_key(|&i| (gpus.score(&found[i].providers), std::cmp::Reverse(i)))?;
|
||||
let chosen = found.swap_remove(best);
|
||||
// The others stay mapped. Unloading a C++ runtime after its static
|
||||
// constructors ran is a crash at exit waiting to happen, and an
|
||||
// unused mapping costs address space, not memory.
|
||||
for other in found {
|
||||
std::mem::forget(other.lib);
|
||||
}
|
||||
log::info!("inference: chose {} for {gpus:?}", chosen.path.display());
|
||||
|
||||
// SAFETY: the table came from this library's `OrtGetApiBase`, and the
|
||||
// library is leaked below, so every pointer in the copy stays valid for
|
||||
// the life of the process.
|
||||
if !ort::set_api(unsafe { (*chosen.api).clone() }) {
|
||||
log::warn!("inference: an API table was already installed");
|
||||
std::mem::forget(chosen.lib);
|
||||
return None;
|
||||
}
|
||||
std::mem::forget(chosen.lib);
|
||||
|
||||
// Qualcomm's DSP loader finds the Hexagon skel through this variable,
|
||||
// and only through it; the runtime's own directory is where the APK
|
||||
// put it. Harmless anywhere else.
|
||||
#[cfg(target_os = "android")]
|
||||
if let Some(dir) = chosen.path.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::env::set_var("ADSP_LIBRARY_PATH", dir);
|
||||
}
|
||||
|
||||
// Windows looks for a provider's own dependencies — OpenVINO's DLLs,
|
||||
// which Intel's build leaves beside it — on the DLL search path, not in
|
||||
// the provider's directory. Intel's Python shim prepends to `PATH` for
|
||||
// the same reason; so does this, before any provider loads.
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(dir) = chosen.path.parent() {
|
||||
let old = std::env::var_os("PATH").unwrap_or_default();
|
||||
let dirs = std::iter::once(dir.to_path_buf()).chain(std::env::split_paths(&old));
|
||||
if let Ok(path) = std::env::join_paths(dirs) {
|
||||
std::env::set_var("PATH", path);
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"inference: ONNX Runtime {} from {}",
|
||||
chosen.version,
|
||||
chosen.path.display()
|
||||
);
|
||||
Some(Runtime::OnnxRuntime {
|
||||
path: chosen.path,
|
||||
version: chosen.version,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "tract")]
|
||||
fn install_tract() -> Runtime {
|
||||
let _ = ort::set_api(ort_tract::api());
|
||||
@@ -85,8 +190,12 @@ fn install_tract() -> Runtime {
|
||||
Runtime::Tract
|
||||
}
|
||||
|
||||
/// Open the runtime in `dir` and read what it offers. `dir` may also name
|
||||
/// the library itself — Android has two runtimes and one directory, so the
|
||||
/// second goes by its file name — and an empty path is the bare name
|
||||
/// through the system loader, which on Android is the APK's own copy.
|
||||
#[cfg(feature = "native")]
|
||||
fn load_native(dir: &std::path::Path) -> Result<Runtime, String> {
|
||||
fn open_native(dir: &std::path::Path) -> Result<Found, String> {
|
||||
let name = if cfg!(target_os = "windows") {
|
||||
"onnxruntime.dll"
|
||||
} else if cfg!(any(target_os = "macos", target_os = "ios")) {
|
||||
@@ -94,18 +203,21 @@ fn load_native(dir: &std::path::Path) -> Result<Runtime, String> {
|
||||
} else {
|
||||
"libonnxruntime.so"
|
||||
};
|
||||
// An empty dir means the bare name: the system loader's search, which on
|
||||
// Android includes the APK's own native libraries.
|
||||
let is_library = dir.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
|
||||
n.contains("onnxruntime")
|
||||
&& (n.ends_with(".so") || n.ends_with(".dll") || n.ends_with(".dylib"))
|
||||
});
|
||||
let path = if dir.as_os_str().is_empty() {
|
||||
PathBuf::from(name)
|
||||
} else if is_library {
|
||||
dir.to_path_buf()
|
||||
} else {
|
||||
find_library(dir, name).ok_or("not present")?
|
||||
};
|
||||
|
||||
// SAFETY: the library's initialisers are ONNX Runtime's own; the symbol
|
||||
// is the documented entry point with the documented signature; the table
|
||||
// is copied out and the library handle is leaked, so every pointer in
|
||||
// the copy stays valid for the life of the process.
|
||||
// is the documented entry point with the documented signature. The
|
||||
// table pointer is valid while `lib` is, which the caller keeps.
|
||||
unsafe {
|
||||
let lib = libloading::Library::new(&path).map_err(|e| e.to_string())?;
|
||||
let get_base: libloading::Symbol<
|
||||
@@ -125,24 +237,45 @@ fn load_native(dir: &std::path::Path) -> Result<Runtime, String> {
|
||||
ort_sys::ORT_API_VERSION
|
||||
));
|
||||
}
|
||||
if !ort::set_api((*api).clone()) {
|
||||
return Err("an API table was already installed".into());
|
||||
}
|
||||
std::mem::forget(lib);
|
||||
|
||||
// Qualcomm's DSP loader finds the Hexagon skel through this variable,
|
||||
// and only through it; the runtime's own directory is where the APK
|
||||
// put it. Harmless anywhere else.
|
||||
#[cfg(target_os = "android")]
|
||||
if !dir.as_os_str().is_empty() {
|
||||
std::env::set_var("ADSP_LIBRARY_PATH", dir);
|
||||
}
|
||||
|
||||
log::info!("inference: ONNX Runtime {version} from {}", path.display());
|
||||
Ok(Runtime::OnnxRuntime { path, version })
|
||||
let providers = available_providers(api);
|
||||
Ok(Found {
|
||||
lib,
|
||||
api,
|
||||
path,
|
||||
version,
|
||||
providers,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The providers compiled into the runtime behind `api` — not the ones this
|
||||
/// device can run, which is the probe's question.
|
||||
///
|
||||
/// # Safety
|
||||
/// `api` must be a live table from `GetApi`.
|
||||
#[cfg(feature = "native")]
|
||||
unsafe fn available_providers(api: *const ort_sys::OrtApi) -> Vec<String> {
|
||||
let mut list: *mut *mut std::ffi::c_char = std::ptr::null_mut();
|
||||
let mut n: std::ffi::c_int = 0;
|
||||
let status = ((*api).GetAvailableProviders)(&mut list, &mut n);
|
||||
if !status.0.is_null() {
|
||||
((*api).ReleaseStatus)(status.0);
|
||||
return Vec::new();
|
||||
}
|
||||
let names = (0..n.max(0) as usize)
|
||||
.map(|i| {
|
||||
std::ffi::CStr::from_ptr(*list.add(i))
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
})
|
||||
.collect();
|
||||
let status = ((*api).ReleaseAvailableProviders)(list, n);
|
||||
if !status.0.is_null() {
|
||||
((*api).ReleaseStatus)(status.0);
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// `libonnxruntime.so` in `dir`, or a versioned spelling of it —
|
||||
/// `libonnxruntime.so.1.30.0` is what the Python wheel ships, and a package
|
||||
/// that installs only the versioned file is not wrong.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::{state, Config, Form, Rung};
|
||||
use crate::{state, Config, Rung};
|
||||
|
||||
enum Source {
|
||||
File(PathBuf),
|
||||
@@ -44,6 +44,55 @@ 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 {
|
||||
model_dir(cfg, "coreml", bytes)
|
||||
}
|
||||
|
||||
/// Where OpenVINO compiles `bytes` to, at one precision. OpenVINO hashes
|
||||
/// the model it is given, weights included, but a key that leaves out
|
||||
/// what is being varied has cost a day before (CLAUDE.md, "Providers"),
|
||||
/// and a directory per model and precision costs nothing: the precision
|
||||
/// is a compile option, and the two forms are different programs.
|
||||
pub fn openvino_dir(cfg: &Config, bytes: &[u8], fp16: bool) -> PathBuf {
|
||||
model_dir(
|
||||
cfg,
|
||||
if fp16 {
|
||||
"openvino/fp16"
|
||||
} else {
|
||||
"openvino/f32"
|
||||
},
|
||||
bytes,
|
||||
)
|
||||
}
|
||||
|
||||
/// Where TensorRT keeps the engine for a whole-frame model. Its own
|
||||
/// directory per model: ONNX Runtime's engine cache key leaves the input
|
||||
/// shape out, and served one export's engine to another of the same graph
|
||||
/// with a different shape when the denoiser was first cut into pieces
|
||||
/// (2026-10-04) — the fixed 1408² denoiser and its any-size sibling are
|
||||
/// exactly that pair. The profile's largest shape is in the name for the
|
||||
/// same reason: an engine built for one range is not the next one's.
|
||||
pub fn tensorrt_whole_dir(cfg: &Config, bytes: &[u8]) -> PathBuf {
|
||||
let (h, w) = crate::WHOLE_FRAME_MAX;
|
||||
model_dir(cfg, &format!("tensorrt-whole-{h}x{w}"), bytes)
|
||||
}
|
||||
|
||||
/// `<cache>/<provider>/<runtime version>/<hash of the bytes>`: one per
|
||||
/// model, and one per runtime version, which wrote it.
|
||||
fn model_dir(cfg: &Config, provider: &str, bytes: &[u8]) -> PathBuf {
|
||||
let runtime = match crate::api::runtime() {
|
||||
crate::Runtime::OnnxRuntime { version, .. } => version,
|
||||
crate::Runtime::Tract => "tract".into(),
|
||||
};
|
||||
cfg.cache_dir
|
||||
.join(provider)
|
||||
.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() {
|
||||
@@ -68,10 +117,10 @@ pub fn run() {
|
||||
(*role, Source::File(path), size)
|
||||
})
|
||||
})
|
||||
.chain(cfg.embedded.iter().filter_map(|(role, bytes)| {
|
||||
// An embedded model has no int8 sibling to offer a rung that
|
||||
// wants one; it runs on that rung's fallback.
|
||||
(rung.serves(*role) && rung.form(*role) == Form::F32).then_some((
|
||||
.chain(cfg.embedded.iter().filter_map(|(role, form, bytes)| {
|
||||
// The embedded form the rung wants, if the build carries it;
|
||||
// a build without it runs that model on the rung's fallback.
|
||||
(rung.serves(*role) && rung.form(*role) == *form).then_some((
|
||||
*role,
|
||||
Source::Bytes(bytes),
|
||||
bytes.len() as u64,
|
||||
@@ -90,12 +139,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();
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Which GPUs this device has, as far as choosing a runtime needs to know
|
||||
//! (docs/dev/inference.md §3.2).
|
||||
//!
|
||||
//! A runtime carries one vendor's providers — Intel's build has OpenVINO,
|
||||
//! the `onnxruntime-gpu` wheel CUDA and TensorRT, a ROCm build MIGraphX,
|
||||
//! Microsoft's WebGPU build the generic rung — and only one runtime loads
|
||||
//! per process. These checks are what lets `api` load the one that fits
|
||||
//! when a device has several installed. They read files, never a driver:
|
||||
//! a wrong answer costs a slower rung, which the probe still measures, and
|
||||
//! a driver call at start-up could cost the launch.
|
||||
|
||||
/// What a runtime's providers are scored against.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Gpus {
|
||||
pub nvidia: bool,
|
||||
/// An AMD GPU with the ROCm kernel interface, which MIGraphX needs.
|
||||
pub amd_rocm: bool,
|
||||
pub intel: bool,
|
||||
pub qualcomm: bool,
|
||||
}
|
||||
|
||||
/// The score of a runtime whose vendor rung matches the device's GPU.
|
||||
/// Nothing beats it, so the search stops there.
|
||||
pub const PERFECT: u32 = 3;
|
||||
|
||||
impl Gpus {
|
||||
/// How well a runtime offering `providers` fits this device. The vendor
|
||||
/// rungs score above OpenVINO because a machine with an Intel iGPU and
|
||||
/// an NVIDIA or AMD card wants the card; the generic rung scores above
|
||||
/// a CPU-only build because it carries the same CPU provider and might
|
||||
/// beat it.
|
||||
pub fn score(&self, providers: &[String]) -> u32 {
|
||||
providers
|
||||
.iter()
|
||||
.map(|p| match p.as_str() {
|
||||
"TensorrtExecutionProvider" | "CUDAExecutionProvider" if self.nvidia => PERFECT,
|
||||
"MIGraphXExecutionProvider" if self.amd_rocm => PERFECT,
|
||||
"QNNExecutionProvider" if self.qualcomm => PERFECT,
|
||||
"CoreMLExecutionProvider" => PERFECT,
|
||||
"OpenVINOExecutionProvider" if self.intel => 2,
|
||||
"WebGpuExecutionProvider" => 1,
|
||||
_ => 0,
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn detect() -> Gpus {
|
||||
use std::path::Path;
|
||||
// Every DRM card's PCI vendor: an Intel iGPU is `0x8086` whether or
|
||||
// not its compute driver is installed, which the probe finds out.
|
||||
let vendors: Vec<String> = std::fs::read_dir("/sys/class/drm")
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
name.starts_with("card") && !name.contains('-')
|
||||
})
|
||||
.filter_map(|e| std::fs::read_to_string(e.path().join("device/vendor")).ok())
|
||||
.map(|v| v.trim().to_string())
|
||||
.collect();
|
||||
Gpus {
|
||||
nvidia: Path::new("/proc/driver/nvidia/version").exists(),
|
||||
amd_rocm: Path::new("/dev/kfd").exists(),
|
||||
intel: vendors.iter().any(|v| v == "0x8086"),
|
||||
qualcomm: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn detect() -> Gpus {
|
||||
use std::path::PathBuf;
|
||||
let root = std::env::var_os("SystemRoot")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
|
||||
let system32 = root.join("System32");
|
||||
// Intel's DCH graphics driver, integrated and Arc alike, installs
|
||||
// from `iigd_dch.inf`; its package directory is the evidence.
|
||||
let intel = std::fs::read_dir(system32.join(r"DriverStore\FileRepository"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| e.ok())
|
||||
.any(|e| e.file_name().to_string_lossy().starts_with("iigd_dch"));
|
||||
Gpus {
|
||||
nvidia: system32.join("nvcuda.dll").exists(),
|
||||
amd_rocm: false,
|
||||
intel,
|
||||
qualcomm: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn detect() -> Gpus {
|
||||
// Fail-safe: only a device that names another vendor is not Qualcomm.
|
||||
// `ro.soc.manufacturer` exists from Android 12, and a property or file
|
||||
// the app cannot read reads as nothing; nothing keeps the QNN build
|
||||
// first, as 0.22 had it, where a Qualcomm device mistaken for another
|
||||
// would trade its NPU for the generic rung. Qualcomm's FastRPC library,
|
||||
// which the Hexagon path loads anyway, overrules a name.
|
||||
let soc = crate::probe::system_property("ro.soc.manufacturer");
|
||||
let fastrpc = [
|
||||
"/vendor/lib64/libcdsprpc.so",
|
||||
"/system/vendor/lib64/libcdsprpc.so",
|
||||
]
|
||||
.iter()
|
||||
.any(|p| std::path::Path::new(p).exists());
|
||||
Gpus {
|
||||
qualcomm: qualcomm_soc(&soc) || fastrpc,
|
||||
..Gpus::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `ro.soc.manufacturer` leaves the device Qualcomm's: it says so,
|
||||
/// or it says nothing.
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
fn qualcomm_soc(manufacturer: &str) -> bool {
|
||||
let m = manufacturer.trim();
|
||||
m.is_empty() || m.eq_ignore_ascii_case("QTI") || m.eq_ignore_ascii_case("Qualcomm")
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "android")))]
|
||||
pub fn detect() -> Gpus {
|
||||
Gpus::default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn offers(p: &[&str]) -> Vec<String> {
|
||||
p.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_named_other_vendor_is_not_qualcomm() {
|
||||
assert!(qualcomm_soc("QTI"));
|
||||
assert!(qualcomm_soc("Qualcomm"));
|
||||
// Unreadable, or older than Android 12: the QNN build stays first.
|
||||
assert!(qualcomm_soc(""));
|
||||
assert!(!qualcomm_soc("Mediatek"));
|
||||
assert!(!qualcomm_soc("Google"));
|
||||
assert!(!qualcomm_soc("Samsung"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_card_beats_the_integrated_gpu_and_both_beat_the_generic_rung() {
|
||||
let cpu = offers(&["CPUExecutionProvider"]);
|
||||
let nvidia = offers(&[
|
||||
"TensorrtExecutionProvider",
|
||||
"CUDAExecutionProvider",
|
||||
"CPUExecutionProvider",
|
||||
]);
|
||||
let intel = offers(&["OpenVINOExecutionProvider", "CPUExecutionProvider"]);
|
||||
let webgpu = offers(&["WebGpuExecutionProvider", "CPUExecutionProvider"]);
|
||||
let laptop = Gpus {
|
||||
nvidia: true,
|
||||
intel: true,
|
||||
..Gpus::default()
|
||||
};
|
||||
assert!(laptop.score(&nvidia) > laptop.score(&intel));
|
||||
assert!(laptop.score(&intel) > laptop.score(&webgpu));
|
||||
assert!(laptop.score(&webgpu) > laptop.score(&cpu));
|
||||
// No Intel GPU: Intel's build is worth no more than a CPU build to
|
||||
// this device, and the generic rung is worth more.
|
||||
let amd_on_windows = Gpus::default();
|
||||
assert_eq!(amd_on_windows.score(&intel), amd_on_windows.score(&cpu));
|
||||
assert!(amd_on_windows.score(&webgpu) > amd_on_windows.score(&intel));
|
||||
// A ROCm build on a machine without ROCm is a CPU build.
|
||||
let rocm = offers(&["MIGraphXExecutionProvider", "CPUExecutionProvider"]);
|
||||
assert_eq!(amd_on_windows.score(&rocm), 0);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
mod api;
|
||||
mod engines;
|
||||
// Read only when a runtime is loaded from disk (`api::install_best`).
|
||||
#[cfg_attr(not(feature = "native"), allow(dead_code))]
|
||||
mod hardware;
|
||||
mod probe;
|
||||
mod session;
|
||||
|
||||
@@ -42,20 +45,76 @@ pub enum Role {
|
||||
/// XFeat, the panorama keypoint detector (docs/dev/panorama.md).
|
||||
Keypoints,
|
||||
/// MI-GAN, the panorama border filler (docs/dev/panorama.md §12). Plain
|
||||
/// convolutions, so any rung serves it; fp16 on TensorRT and int8 on
|
||||
/// the Hexagon are the point of it.
|
||||
/// convolutions, so any rung serves it; fp16 on TensorRT and 16-bit
|
||||
/// activations on the Hexagon (int8 changes the fill, §1.5).
|
||||
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 takes it with 16-bit activations and weights (§1.5).
|
||||
Denoiser,
|
||||
/// The same denoise networks exported with any height and width, run
|
||||
/// over a whole frame — or the fewest large tiles that fit — instead of
|
||||
/// 1408² tiles whose borders are thrown away (docs/dev/denoise.md §14).
|
||||
/// Served only where a size the graph was not compiled for costs
|
||||
/// nothing: TensorRT, through an optimisation profile up to
|
||||
/// [`WHOLE_FRAME_MAX`], and the CUDA provider. Everywhere else the
|
||||
/// fixed-tile [`Role::Denoiser`] runs; see [`whole_frame_limit`].
|
||||
WholeDenoiser,
|
||||
}
|
||||
|
||||
/// The largest input, rows × columns, a [`Role::WholeDenoiser`] session
|
||||
/// takes: TensorRT's optimisation profile is built up to it, and the tiler
|
||||
/// cuts a larger frame into the fewest tiles no bigger.
|
||||
///
|
||||
/// Sized for a 6 GB card. TensorRT plans its memory for the profile's
|
||||
/// largest shape, and at 4608 × 6656 (a whole 6D frame with Best's border
|
||||
/// and room to spare) it asked for 4.9–5.9 GB and could not build on the
|
||||
/// RTX 3050. At 15 MP a 6D frame is two tiles of 4160 × 3248: 27 MP of
|
||||
/// work for 20 MP kept, against 49 MP in 1408² tiles.
|
||||
pub const WHOLE_FRAME_MAX: (usize, usize) = (4608, 3328);
|
||||
|
||||
/// The input size TensorRT tunes a whole-frame engine for: half a 6D frame
|
||||
/// with Best's border, the tile the reference measurements run.
|
||||
pub const WHOLE_FRAME_OPT: (usize, usize) = (4160, 3248);
|
||||
|
||||
/// Whether the selected rung runs [`Role::WholeDenoiser`], and if so the
|
||||
/// largest input it takes. `None` means run the fixed tiles.
|
||||
pub fn whole_frame_limit() -> Option<(usize, usize)> {
|
||||
let rung = current_rung(&state().lock().unwrap());
|
||||
rung.serves(Role::WholeDenoiser).then_some(WHOLE_FRAME_MAX)
|
||||
}
|
||||
|
||||
/// Which numeric form of a model a session was built from.
|
||||
///
|
||||
/// `Int8` is a different network from `F32` for a detector — it finds a
|
||||
/// different set of faces — which is why [`form_suffix`] exists and why a
|
||||
/// The quantised forms are QDQ graphs, per-channel weights, as QNN's HTP
|
||||
/// takes them (docs/dev/inference.md §1.5): `Int8` is 8-bit activations and
|
||||
/// weights, `A16W8` 16-bit activations with 8-bit weights, `A16W16` 16-bit
|
||||
/// both. The Hexagon accepts no float tensor at all, so these are the
|
||||
/// whole menu; which one a role gets is [`Rung::form`], measured per model.
|
||||
///
|
||||
/// A quantised detector is a different network from the f32 one — it finds
|
||||
/// a different set of faces — which is why [`form_suffix`] exists and why a
|
||||
/// caller appends it to `model_id`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Form {
|
||||
F32,
|
||||
Int8,
|
||||
A16W8,
|
||||
A16W16,
|
||||
}
|
||||
|
||||
impl Form {
|
||||
/// The infix of the sibling file that holds this form:
|
||||
/// `scrfd_500m_640.a16w8.onnx` beside `scrfd_500m_640.onnx`.
|
||||
pub fn file_tag(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Form::F32 => None,
|
||||
Form::Int8 => Some("int8"),
|
||||
Form::A16W8 => Some("a16w8"),
|
||||
Form::A16W16 => Some("a16w16"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A rung of the ladder (§2). Ordered: a user override names the highest rung
|
||||
@@ -76,8 +135,24 @@ pub enum Rung {
|
||||
/// removed in ONNX Runtime 1.23, so there is no non-compiling AMD rung
|
||||
/// to fall back to: this one falls back to the CPU.
|
||||
MiGraphX,
|
||||
/// Qualcomm's Hexagon NPU through QNN, int8 models only. Android only.
|
||||
/// Qualcomm's Hexagon NPU through QNN, quantised 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,
|
||||
/// Intel, through OpenVINO on the integrated or Arc GPU. Desktop only.
|
||||
/// Compiles a program per model, as MIGraphX does, so the CPU is its
|
||||
/// fallback; fp16 on the same terms as TensorRT (§7).
|
||||
OpenVino,
|
||||
/// Any other GPU, through ONNX Runtime's WebGPU provider: Dawn on
|
||||
/// Vulkan, D3D12 or Metal. The generic rung, for a GPU no vendor rung
|
||||
/// covers. Measured slower than the CPU on every GPU it has been timed
|
||||
/// on (§1), so it is on the ladder for the GPUs it has not, and the
|
||||
/// probe's clock is what keeps it off the rest.
|
||||
WebGpu,
|
||||
}
|
||||
|
||||
impl Rung {
|
||||
@@ -88,6 +163,9 @@ impl Rung {
|
||||
Rung::TensorRt => "TensorRT",
|
||||
Rung::MiGraphX => "MIGraphX",
|
||||
Rung::Hexagon => "Hexagon NPU",
|
||||
Rung::CoreMl => "CoreML",
|
||||
Rung::OpenVino => "OpenVINO",
|
||||
Rung::WebGpu => "WebGPU",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,29 +174,66 @@ 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::OpenVino
|
||||
| Rung::WebGpu
|
||||
| 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 | Rung::OpenVino
|
||||
)
|
||||
}
|
||||
|
||||
/// The model form this rung wants for a role.
|
||||
fn form(self, _role: Role) -> Form {
|
||||
///
|
||||
/// On the Hexagon, the narrowest form that held each model's accuracy
|
||||
/// on the tablet itself (§1.5): int8 lost 5% of the detector's faces at
|
||||
/// 40–80 px, moved the landmarks by 1.5 px and the segmenter's scores
|
||||
/// to nothing, and the denoiser by 6–9 dB, so those take 16-bit
|
||||
/// activations; the segmenter, scene model, filler and denoiser also
|
||||
/// needed 16-bit weights. Only XFeat keeps int8: its panorama alignment
|
||||
/// moved by no more than f32's own refits do.
|
||||
pub fn form(self, role: Role) -> Form {
|
||||
match self {
|
||||
Rung::Hexagon => Form::Int8,
|
||||
Rung::Hexagon => match role {
|
||||
Role::Keypoints => Form::Int8,
|
||||
Role::Detector | Role::Landmarks => Form::A16W8,
|
||||
Role::Segmenter | Role::Scene | Role::Inpainter | Role::Denoiser => Form::A16W16,
|
||||
// Not served there at all: the Hexagon takes fixed shapes.
|
||||
Role::Embedder | Role::EyeClassifier | Role::WholeDenoiser => Form::F32,
|
||||
},
|
||||
_ => Form::F32,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Whether this rung runs `role` at all. The Hexagon takes quantised
|
||||
/// graphs only, and the embedder is never quantised (§7) — it runs on
|
||||
/// the CPU beside a detector on the NPU, so its vectors compare across
|
||||
/// devices; at A16W16 it still missed the 0.999 cosine gate. The eye
|
||||
/// classifiers stay on the CPU too: a millisecond there, and the two
|
||||
/// share one role while only one of them held its readings quantised.
|
||||
/// 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 {
|
||||
// Any input size only where a new size costs nothing. MIGraphX,
|
||||
// OpenVINO and CoreML compile per shape, the Hexagon takes fixed
|
||||
// shapes only, and the CPU could but would hold gigabytes of f32
|
||||
// activations for a whole frame of Best.
|
||||
if role == Role::WholeDenoiser {
|
||||
return matches!(self, Rung::TensorRt | Rung::Cuda);
|
||||
}
|
||||
match self {
|
||||
Rung::Hexagon => role != Role::Embedder,
|
||||
Rung::Hexagon => !matches!(role, Role::Embedder | Role::EyeClassifier),
|
||||
Rung::CoreMl => role != Role::Embedder,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
@@ -141,8 +256,10 @@ pub struct Config {
|
||||
/// The canonical model files on this device, so engines can be compiled
|
||||
/// ahead of the first request for them.
|
||||
pub models: Vec<(Role, PathBuf)>,
|
||||
/// Models compiled into the binary, for the same reason.
|
||||
pub embedded: Vec<(Role, &'static [u8])>,
|
||||
/// Models compiled into the binary, for the same reason, each with the
|
||||
/// form it is. A build that embeds a quantised sibling lists it here
|
||||
/// beside the f32 graph, and the compile step takes the one the rung wants.
|
||||
pub embedded: Vec<(Role, Form, &'static [u8])>,
|
||||
/// The highest rung the user allows; `None` is "the best that works".
|
||||
pub ceiling: Option<Rung>,
|
||||
/// ONNX Runtime's intra-op pool; 0 picks from the core count.
|
||||
@@ -168,11 +285,11 @@ pub struct Status {
|
||||
}
|
||||
|
||||
impl Status {
|
||||
/// "Hexagon NPU · int8 · ONNX Runtime 1.29" — the settings row's text.
|
||||
/// "Hexagon NPU · quantised · ONNX Runtime 1.29" — the settings row's text.
|
||||
pub fn line(&self) -> String {
|
||||
let form = match self.rung {
|
||||
Rung::Hexagon => " · int8",
|
||||
Rung::TensorRt | Rung::MiGraphX => " · fp16",
|
||||
Rung::Hexagon => " · quantised",
|
||||
Rung::TensorRt | Rung::MiGraphX | Rung::OpenVino => " · fp16",
|
||||
_ => "",
|
||||
};
|
||||
format!("{}{} · {}", self.rung.label(), form, self.runtime.label())
|
||||
@@ -348,6 +465,18 @@ 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>,
|
||||
/// Probes run under this fingerprint (`probe::run`): a fall-back to the
|
||||
/// CPU is re-probed until there have been `RETRIES`. Defaulted, so a
|
||||
/// cache from 0.22.0 or before — which may hold exactly such a verdict —
|
||||
/// probes again.
|
||||
#[serde(default)]
|
||||
attempts: u32,
|
||||
}
|
||||
|
||||
struct State {
|
||||
@@ -438,26 +567,44 @@ fn current_rung(s: &State) -> Rung {
|
||||
|
||||
/// The file to load for `role` under the current selection, and its form.
|
||||
///
|
||||
/// A rung that wants int8 gets the `.int8.onnx` sibling of the canonical file
|
||||
/// if it exists; otherwise the canonical file, on the rung's fallback. A
|
||||
/// caller adds [`form_suffix`] to the `model_id` it records.
|
||||
/// A rung that wants a quantised form gets that sibling of the canonical
|
||||
/// file (`<stem>.a16w8.onnx` and so on, [`Form::file_tag`]) if it exists;
|
||||
/// otherwise the canonical file, on the rung's fallback. A caller adds
|
||||
/// [`form_suffix`] to the `model_id` it records.
|
||||
pub fn resolve_model(role: Role, canonical: &Path) -> (PathBuf, Form) {
|
||||
let rung = current_rung(&state().lock().unwrap());
|
||||
if rung.serves(role) && rung.form(role) == Form::Int8 {
|
||||
let sibling = int8_sibling(canonical);
|
||||
let want = rung.form(role);
|
||||
if rung.serves(role) && want != Form::F32 {
|
||||
let sibling = form_sibling(canonical, want);
|
||||
if sibling.is_file() {
|
||||
return (sibling, Form::Int8);
|
||||
return (sibling, want);
|
||||
}
|
||||
}
|
||||
(canonical.to_path_buf(), Form::F32)
|
||||
}
|
||||
|
||||
fn int8_sibling(canonical: &Path) -> PathBuf {
|
||||
/// The same choice for a model compiled into the binary: of the forms
|
||||
/// `offered`, the one the current rung wants for `role`, else the f32 one.
|
||||
/// `offered` must hold an `F32` entry.
|
||||
pub fn choose_embedded(role: Role, offered: &[(Form, &'static [u8])]) -> (&'static [u8], Form) {
|
||||
let rung = current_rung(&state().lock().unwrap());
|
||||
let want = rung.form(role);
|
||||
let pick = |form| offered.iter().find(|(f, _)| *f == form);
|
||||
let (form, bytes) = (rung.serves(role).then(|| pick(want)).flatten())
|
||||
.or_else(|| pick(Form::F32))
|
||||
.expect("an embedded model offers its f32 form");
|
||||
(bytes, *form)
|
||||
}
|
||||
|
||||
fn form_sibling(canonical: &Path, form: Form) -> PathBuf {
|
||||
let stem = canonical
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
canonical.with_file_name(format!("{stem}.int8.onnx"))
|
||||
match form.file_tag() {
|
||||
Some(tag) => canonical.with_file_name(format!("{stem}.{tag}.onnx")),
|
||||
None => canonical.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a form appends to a detector's `model_id` (§7).
|
||||
@@ -465,6 +612,8 @@ pub fn form_suffix(form: Form) -> &'static str {
|
||||
match form {
|
||||
Form::F32 => "",
|
||||
Form::Int8 => "_i8",
|
||||
Form::A16W8 => "_a16",
|
||||
Form::A16W16 => "_a16w16",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,8 +643,8 @@ pub fn open(role: Role, form: Form, bytes: &[u8]) -> Result<Model, Error> {
|
||||
fn effective_rung(s: &State, selected: Rung, role: Role, form: Form, hash: u64) -> Rung {
|
||||
let mut rung = selected;
|
||||
if !rung.serves(role) || rung.form(role) != form {
|
||||
// The embedder on a Hexagon device, or an f32 detector where the int8
|
||||
// sibling was missing: neither can go to the NPU.
|
||||
// The embedder on a Hexagon device, or an f32 detector where the
|
||||
// quantised sibling was missing: neither can go to the NPU.
|
||||
rung = rung.fallback();
|
||||
}
|
||||
if rung.compiles() && !s.cache.compiled.contains(&engines::key_of(rung, hash)) {
|
||||
@@ -586,8 +735,10 @@ mod tests {
|
||||
#[test]
|
||||
fn the_hexagon_never_takes_the_embedder() {
|
||||
assert!(!Rung::Hexagon.serves(Role::Embedder));
|
||||
assert!(!Rung::Hexagon.serves(Role::EyeClassifier));
|
||||
assert!(Rung::Hexagon.serves(Role::Detector));
|
||||
assert_eq!(Rung::Hexagon.form(Role::Detector), Form::Int8);
|
||||
assert!(Rung::Hexagon.serves(Role::Denoiser));
|
||||
assert_eq!(Rung::Hexagon.form(Role::Detector), Form::A16W8);
|
||||
// A detector offered in f32 on a Hexagon device lands on the CPU.
|
||||
let s = State {
|
||||
config: Config::default(),
|
||||
@@ -598,39 +749,106 @@ mod tests {
|
||||
probing: false,
|
||||
wanted: 0,
|
||||
};
|
||||
let on = |role, form| effective_rung(&s, Rung::Hexagon, role, form, engines::hash(b""));
|
||||
assert_eq!(on(Role::Embedder, Form::F32), Rung::Cpu);
|
||||
assert_eq!(on(Role::Detector, Form::F32), Rung::Cpu);
|
||||
// A form other than the one the role wants is not the NPU's either:
|
||||
// an int8 detector left over from an older install stays off it.
|
||||
assert_eq!(on(Role::Detector, Form::Int8), Rung::Cpu);
|
||||
// The wanted form whose context is not compiled yet: also the CPU.
|
||||
assert_eq!(on(Role::Detector, Form::A16W8), Rung::Cpu);
|
||||
}
|
||||
|
||||
/// The form each role gets on the Hexagon is the one measured to hold
|
||||
/// its accuracy there (§1.5); a change to this table is a change to
|
||||
/// what the tablet computes, and must come with a measurement.
|
||||
#[test]
|
||||
fn each_role_has_its_measured_form_on_the_hexagon() {
|
||||
use Form::*;
|
||||
for (role, form) in [
|
||||
(Role::Detector, A16W8),
|
||||
(Role::Landmarks, A16W8),
|
||||
(Role::Segmenter, A16W16),
|
||||
(Role::Scene, A16W16),
|
||||
(Role::Inpainter, A16W16),
|
||||
(Role::Denoiser, A16W16),
|
||||
(Role::Keypoints, Int8),
|
||||
(Role::Embedder, F32),
|
||||
(Role::EyeClassifier, F32),
|
||||
] {
|
||||
assert_eq!(Rung::Hexagon.form(role), form, "{role:?}");
|
||||
}
|
||||
for rung in [
|
||||
Rung::Cpu,
|
||||
Rung::Cuda,
|
||||
Rung::TensorRt,
|
||||
Rung::MiGraphX,
|
||||
Rung::CoreMl,
|
||||
Rung::OpenVino,
|
||||
Rung::WebGpu,
|
||||
] {
|
||||
assert_eq!(rung.form(Role::Detector), F32);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_form_lives_in_its_tagged_sibling() {
|
||||
let canonical = Path::new("/m/scrfd_500m_640.onnx");
|
||||
assert_eq!(form_sibling(canonical, Form::F32), canonical);
|
||||
assert_eq!(
|
||||
effective_rung(
|
||||
&s,
|
||||
Rung::Hexagon,
|
||||
Role::Embedder,
|
||||
Form::F32,
|
||||
engines::hash(b"")
|
||||
),
|
||||
Rung::Cpu
|
||||
form_sibling(canonical, Form::A16W8),
|
||||
Path::new("/m/scrfd_500m_640.a16w8.onnx")
|
||||
);
|
||||
assert_eq!(
|
||||
effective_rung(
|
||||
&s,
|
||||
Rung::Hexagon,
|
||||
Role::Detector,
|
||||
Form::F32,
|
||||
engines::hash(b"")
|
||||
),
|
||||
Rung::Cpu
|
||||
);
|
||||
// An int8 detector whose context is not compiled yet: also the CPU.
|
||||
assert_eq!(
|
||||
effective_rung(
|
||||
&s,
|
||||
Rung::Hexagon,
|
||||
Role::Detector,
|
||||
Form::Int8,
|
||||
engines::hash(b"")
|
||||
),
|
||||
Rung::Cpu
|
||||
form_sibling(canonical, Form::Int8),
|
||||
Path::new("/m/scrfd_500m_640.int8.onnx")
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
/// OpenVINO compiles a program per model, so a request waits on the CPU
|
||||
/// until the engine thread has built it; WebGPU builds in the session
|
||||
/// and serves at once. Both take every role in f32 graphs.
|
||||
#[test]
|
||||
fn openvino_waits_for_its_program_and_webgpu_does_not() {
|
||||
let hash = engines::hash(b"detector");
|
||||
let mut s = State {
|
||||
config: Config::default(),
|
||||
cache: Cache::default(),
|
||||
probing: false,
|
||||
wanted: 0,
|
||||
};
|
||||
let on = |s: &State, rung| effective_rung(s, rung, Role::Detector, Form::F32, hash);
|
||||
assert_eq!(on(&s, Rung::OpenVino), Rung::Cpu);
|
||||
s.cache
|
||||
.compiled
|
||||
.insert(engines::key_of(Rung::OpenVino, hash));
|
||||
assert_eq!(on(&s, Rung::OpenVino), Rung::OpenVino);
|
||||
assert_eq!(on(&s, Rung::WebGpu), Rung::WebGpu);
|
||||
let embedder = effective_rung(&s, Rung::WebGpu, Role::Embedder, Form::F32, hash);
|
||||
assert_eq!(embedder, Rung::WebGpu);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_status_reports_only_the_rungs_above_the_selection() {
|
||||
let _serial = serial();
|
||||
|
||||
@@ -14,18 +14,64 @@ use crate::{api::Runtime, state, Cache, Config, Form, Role, Rung};
|
||||
|
||||
/// The rungs to try on this platform, best first, under the user's ceiling.
|
||||
fn ladder(ceiling: Option<Rung>) -> Vec<Rung> {
|
||||
// WebGPU is the generic rung (§2): it is reached only on a runtime that
|
||||
// carries it, which `api` loads where no vendor's runtime fits the
|
||||
// device, and kept only where it beats the CPU.
|
||||
#[cfg(target_os = "android")]
|
||||
let all = [Rung::Hexagon];
|
||||
// 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"))]
|
||||
let all = [Rung::TensorRt, Rung::Cuda, Rung::MiGraphX];
|
||||
let all = [Rung::Hexagon, Rung::WebGpu];
|
||||
// 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 runtime carries one vendor's providers, chosen for this device's
|
||||
// GPU (`api`); the others are "not enabled in this build", and that
|
||||
// answer arrives in milliseconds.
|
||||
#[cfg(not(any(target_os = "android", target_os = "macos")))]
|
||||
let all = [
|
||||
Rung::TensorRt,
|
||||
Rung::Cuda,
|
||||
Rung::MiGraphX,
|
||||
Rung::OpenVino,
|
||||
Rung::WebGpu,
|
||||
];
|
||||
all.into_iter()
|
||||
.filter(|r| ceiling.is_none_or(|c| *r <= c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Probes under one fingerprint that may end on the CPU after an
|
||||
/// accelerator failed or lost, before that answer is kept.
|
||||
const RETRIES: u32 = 3;
|
||||
|
||||
/// What a cached probe result is good for.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Reuse {
|
||||
/// Use it as it is.
|
||||
Keep,
|
||||
/// Probe again: it fell back to the CPU after this many probes.
|
||||
Again(u32),
|
||||
/// Another device, runtime or model set: probe from the start.
|
||||
Fresh,
|
||||
}
|
||||
|
||||
/// The CPU because an accelerator failed or lost is asked again on the next
|
||||
/// launches, a few times: a failure can be a moment's (QNN could not create
|
||||
/// its device on 0.22.0's first launch after the update), and keeping it for
|
||||
/// good left the tablet's every model on the CPU. Bounded, so a wedged
|
||||
/// driver costs a few launches, not all.
|
||||
fn reuse(cached: &Cache, fingerprint: &str) -> Reuse {
|
||||
if cached.fingerprint != fingerprint || cached.rung.is_none() {
|
||||
return Reuse::Fresh;
|
||||
}
|
||||
let fell_back = cached.rung == Some(Rung::Cpu) && !cached.failed.is_empty();
|
||||
if fell_back && cached.attempts < RETRIES {
|
||||
Reuse::Again(cached.attempts)
|
||||
} else {
|
||||
Reuse::Keep
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe body. Sets the cache and clears `probing` when done; never
|
||||
/// panics out, because a failed probe is a result (the floor) and not an
|
||||
/// error.
|
||||
@@ -33,20 +79,33 @@ pub fn run(runtime: Runtime) {
|
||||
let cfg = state().lock().unwrap().config.clone();
|
||||
let fingerprint = fingerprint(&runtime, &cfg);
|
||||
|
||||
let mut attempts = 0;
|
||||
if let Some(cached) = read_cache(&cfg) {
|
||||
if cached.fingerprint == fingerprint && cached.rung.is_some() {
|
||||
log::info!(
|
||||
"inference: cached selection {} ({})",
|
||||
cached.rung.unwrap().label(),
|
||||
cached.reason
|
||||
);
|
||||
finish(cached);
|
||||
return;
|
||||
match reuse(&cached, &fingerprint) {
|
||||
Reuse::Keep => {
|
||||
log::info!(
|
||||
"inference: cached selection {} ({})",
|
||||
cached.rung.map_or("?", |r| r.label()),
|
||||
cached.reason
|
||||
);
|
||||
finish(cached);
|
||||
return;
|
||||
}
|
||||
Reuse::Again(n) => {
|
||||
attempts = n;
|
||||
log::info!(
|
||||
"inference: probing again after falling back to the CPU ({}), attempt {} of {RETRIES}",
|
||||
cached.reason,
|
||||
n + 1
|
||||
);
|
||||
}
|
||||
Reuse::Fresh => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut cache = Cache {
|
||||
fingerprint,
|
||||
attempts: attempts + 1,
|
||||
..Cache::default()
|
||||
};
|
||||
|
||||
@@ -81,7 +140,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");
|
||||
@@ -103,7 +166,16 @@ pub fn run(runtime: Runtime) {
|
||||
}
|
||||
if cache.rung.is_none() {
|
||||
cache.rung = Some(Rung::Cpu);
|
||||
cache.reason = match cache.failed.first() {
|
||||
// The rung that tried and lost, not the first one the runtime was
|
||||
// never built with: "WebGPU 150 ms, slower than the CPU" says why
|
||||
// this device is on the CPU, "TensorRT not enabled" does not.
|
||||
let tried = cache
|
||||
.failed
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(_, why)| !why.contains("in this build"))
|
||||
.or(cache.failed.first());
|
||||
cache.reason = match tried {
|
||||
Some((r, why)) => format!("{} {}", r.label(), first_line(why)),
|
||||
None => "the only rung on this platform".into(),
|
||||
};
|
||||
@@ -118,11 +190,56 @@ 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
|
||||
/// smaller still, and a Hexagon probed with one would fail for want of a
|
||||
/// form nobody ships.
|
||||
/// none. A ~2 MB detector is the cheapest real test of a provider, and
|
||||
/// every rung serves it — the eye classifiers are smaller still, but the
|
||||
/// Hexagon does not take them, and a probe with one would fail it for that.
|
||||
fn probe_model(cfg: &Config) -> Option<(Role, PathBuf)> {
|
||||
let smallest = |want: Option<Role>| {
|
||||
cfg.models
|
||||
@@ -138,9 +255,14 @@ fn probe_model(cfg: &Config) -> Option<(Role, PathBuf)> {
|
||||
smallest(Some(Role::Detector)).or_else(|| smallest(None))
|
||||
}
|
||||
|
||||
/// Build, run once for the engine, then time three runs; the median in
|
||||
/// milliseconds and, for a compiling rung, the cache key of the engine this
|
||||
/// just built.
|
||||
/// Build, warm up, then time seven runs; the median in milliseconds and,
|
||||
/// for a compiling rung, the cache key of the engine this just built.
|
||||
///
|
||||
/// Three warm-ups, not one: an idle integrated GPU takes a few runs to
|
||||
/// raise its clock. With one, the Iris Xe's OpenVINO lost to the CPU on
|
||||
/// the smallest detector in two probes of three, where warm it is 5.8 ms
|
||||
/// against 9.5 (§1.6). The smallest detector is a GPU's worst case; the
|
||||
/// clock must not also be.
|
||||
fn time_rung(
|
||||
rung: Rung,
|
||||
role: Role,
|
||||
@@ -148,51 +270,65 @@ fn time_rung(
|
||||
cfg: &Config,
|
||||
) -> Result<(f64, Option<String>), String> {
|
||||
let want = rung.form(role);
|
||||
let path = match want {
|
||||
Form::Int8 => {
|
||||
let p = crate::int8_sibling(canonical);
|
||||
if !p.is_file() {
|
||||
return Err(format!("no int8 form of {}", canonical.display()));
|
||||
}
|
||||
p
|
||||
}
|
||||
Form::F32 => canonical.to_path_buf(),
|
||||
};
|
||||
let path = crate::form_sibling(canonical, want);
|
||||
if want != Form::F32 && !path.is_file() {
|
||||
return Err(format!(
|
||||
"no {} form of {}",
|
||||
want.file_tag().unwrap_or("f32"),
|
||||
canonical.display()
|
||||
));
|
||||
}
|
||||
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
|
||||
let started = Instant::now();
|
||||
let mut session =
|
||||
crate::session::build(rung, role, &bytes, cfg).map_err(|e| first_line(&e.to_string()))?;
|
||||
let mut session = crate::session::build_probe(rung, role, &bytes, cfg)
|
||||
.map_err(|e| first_line(&e.to_string()))?;
|
||||
log::info!(
|
||||
"inference: {} session built in {:.1} s",
|
||||
rung.label(),
|
||||
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())?;
|
||||
Ok(t.elapsed().as_secs_f64() * 1e3)
|
||||
};
|
||||
run(&mut session)?;
|
||||
let mut times = [run(&mut session)?, run(&mut session)?, run(&mut session)?];
|
||||
for _ in 0..3 {
|
||||
run(&mut session)?;
|
||||
}
|
||||
let mut times = (0..7)
|
||||
.map(|_| run(&mut session))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
times.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let key = rung.compiles().then(|| crate::engines::key(rung, &bytes));
|
||||
Ok((times[1], key))
|
||||
Ok((times[times.len() / 2], key))
|
||||
}
|
||||
|
||||
/// The part of a provider's error a person can act on. ONNX Runtime's
|
||||
@@ -228,9 +364,9 @@ fn fingerprint(runtime: &Runtime, cfg: &Config) -> String {
|
||||
},
|
||||
device_identity(),
|
||||
];
|
||||
for (role, bytes) in &cfg.embedded {
|
||||
for (role, form, bytes) in &cfg.embedded {
|
||||
parts.push(format!(
|
||||
"{role:?} embedded {:016x}",
|
||||
"{role:?} embedded {form:?} {:016x}",
|
||||
crate::engines::hash(bytes)
|
||||
));
|
||||
}
|
||||
@@ -239,9 +375,13 @@ fn fingerprint(runtime: &Runtime, cfg: &Config) -> String {
|
||||
.map(|b| crate::engines::hash(&b))
|
||||
.unwrap_or(0);
|
||||
parts.push(format!("{role:?} {hash:016x}"));
|
||||
let int8 = crate::int8_sibling(path);
|
||||
if let Ok(b) = std::fs::read(&int8) {
|
||||
parts.push(format!("{role:?} int8 {:016x}", crate::engines::hash(&b)));
|
||||
for form in [Form::Int8, Form::A16W8, Form::A16W16] {
|
||||
if let Ok(b) = std::fs::read(crate::form_sibling(path, form)) {
|
||||
parts.push(format!(
|
||||
"{role:?} {form:?} {:016x}",
|
||||
crate::engines::hash(&b)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
parts.join("\n")
|
||||
@@ -269,19 +409,35 @@ fn providers_beside(runtime: &Path) -> String {
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn device_identity() -> String {
|
||||
// The NVIDIA driver's version line, or the ROCm release the AMD stack
|
||||
// came from (`rocm-core` writes it; the kernel driver has no version
|
||||
// of its own). Absent means neither.
|
||||
// The NVIDIA driver's version line, the ROCm release the AMD stack came
|
||||
// from (`rocm-core` writes it; the kernel driver has no version of its
|
||||
// own), and the OpenCL drivers registered — OpenVINO reaches the GPU
|
||||
// through one, and installing Intel's is what makes the Iris Xe a rung.
|
||||
let mut parts = Vec::new();
|
||||
if let Some(line) = std::fs::read_to_string("/proc/driver/nvidia/version")
|
||||
.ok()
|
||||
.and_then(|s| s.lines().next().map(str::to_string))
|
||||
{
|
||||
return line;
|
||||
parts.push(line);
|
||||
}
|
||||
if let Ok(rocm) = std::fs::read_to_string("/opt/rocm/.info/version") {
|
||||
return format!("rocm {}", rocm.trim());
|
||||
parts.push(format!("rocm {}", rocm.trim()));
|
||||
}
|
||||
let mut icds: Vec<String> = std::fs::read_dir("/etc/OpenCL/vendors")
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
icds.sort();
|
||||
if !icds.is_empty() {
|
||||
parts.push(format!("opencl {}", icds.join(" ")));
|
||||
}
|
||||
if parts.is_empty() {
|
||||
"no nvidia driver, no rocm, no opencl".into()
|
||||
} else {
|
||||
parts.join("; ")
|
||||
}
|
||||
"no nvidia driver, no rocm".into()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
@@ -296,7 +452,7 @@ fn device_identity() -> String {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn system_property(name: &str) -> String {
|
||||
pub(crate) fn system_property(name: &str) -> String {
|
||||
extern "C" {
|
||||
fn __system_property_get(
|
||||
name: *const std::ffi::c_char,
|
||||
@@ -310,7 +466,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 +537,79 @@ 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));
|
||||
}
|
||||
|
||||
/// The tablet's cache after 0.22.0's first launch, as 0.22.0 wrote it:
|
||||
/// no `attempts`, the Hexagon "rejected", the CPU selected.
|
||||
const TABLET: &str = r#"{"fingerprint":"f","rung":"Cpu","reason":"Hexagon NPU 28.5 ms, slower than the CPU's 19.4 ms","compiled":[],"failed":[["Hexagon","28.5 ms, slower than the CPU's 19.4 ms"]]}"#;
|
||||
|
||||
#[test]
|
||||
fn a_fall_back_to_the_cpu_is_probed_again_a_few_times() {
|
||||
let mut cache: Cache = serde_json::from_str(TABLET).unwrap();
|
||||
assert_eq!(cache.attempts, 0, "a 0.22.0 cache reads as never retried");
|
||||
assert_eq!(reuse(&cache, "f"), Reuse::Again(0));
|
||||
cache.attempts = RETRIES - 1;
|
||||
assert_eq!(reuse(&cache, "f"), Reuse::Again(RETRIES - 1));
|
||||
cache.attempts = RETRIES;
|
||||
assert_eq!(reuse(&cache, "f"), Reuse::Keep, "then it is kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_accelerator_chosen_or_a_cpu_only_device_is_kept() {
|
||||
let mut cache: Cache = serde_json::from_str(TABLET).unwrap();
|
||||
cache.rung = Some(Rung::Hexagon);
|
||||
assert_eq!(reuse(&cache, "f"), Reuse::Keep);
|
||||
cache.rung = Some(Rung::Cpu);
|
||||
cache.failed.clear();
|
||||
assert_eq!(
|
||||
reuse(&cache, "f"),
|
||||
Reuse::Keep,
|
||||
"nothing failed: the only rung"
|
||||
);
|
||||
assert_eq!(reuse(&cache, "other"), Reuse::Fresh);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,30 +13,105 @@ use crate::{Config, Role, Rung};
|
||||
/// its clock instead (§4): a provider that hands real work to the CPU is
|
||||
/// slower than the CPU floor and rejected by the same measurement.
|
||||
pub fn build(rung: Rung, role: Role, bytes: &[u8], cfg: &Config) -> ort::Result<Session> {
|
||||
build_with(rung, role, bytes, cfg, false)
|
||||
}
|
||||
|
||||
/// [`build`] for the probe: on the Hexagon, a session that cannot put the
|
||||
/// whole graph on the NPU fails instead of running the rest on the CPU.
|
||||
///
|
||||
/// The probe times a rung by its session, and a QNN provider that could not
|
||||
/// create its device still builds one — with every node on the CPU behind
|
||||
/// it. 0.22.0's first launch on the tablet timed that (28.5 ms against the
|
||||
/// CPU's own 19.4) and put every model on the CPU. Only the probe is strict:
|
||||
/// some shipped graphs keep a few nodes on the CPU on purpose
|
||||
/// (`tools/quantise-models.py`, `float_nodes`), and the probe's detector is
|
||||
/// not one of them.
|
||||
pub fn build_probe(rung: Rung, role: Role, bytes: &[u8], cfg: &Config) -> ort::Result<Session> {
|
||||
build_with(rung, role, bytes, cfg, rung == Rung::Hexagon)
|
||||
}
|
||||
|
||||
fn build_with(
|
||||
rung: Rung,
|
||||
role: Role,
|
||||
bytes: &[u8],
|
||||
cfg: &Config,
|
||||
strict: bool,
|
||||
) -> ort::Result<Session> {
|
||||
// No optimisation level named. ONNX Runtime's default is already its
|
||||
// fullest, and on tract any level but "disabled" means `into_optimized`,
|
||||
// whose optimiser divides by zero inside yolo26n-seg (tract-data
|
||||
// `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)?;
|
||||
}
|
||||
if strict {
|
||||
b = b.with_config_entry("session.disable_cpu_ep_fallback", "1")?;
|
||||
}
|
||||
// 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 or OpenVINO compiles into.
|
||||
let per_model = match rung {
|
||||
Rung::CoreMl => Some(crate::engines::coreml_dir(cfg, bytes)),
|
||||
Rung::TensorRt if role == Role::WholeDenoiser => {
|
||||
Some(crate::engines::tensorrt_whole_dir(cfg, bytes))
|
||||
}
|
||||
Rung::OpenVino => Some(crate::engines::openvino_dir(cfg, bytes, fp16(role))),
|
||||
_ 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)?)
|
||||
}
|
||||
|
||||
/// Whether `role` runs in fp16 on a rung that offers it: everything but the
|
||||
/// embedder, whose comparability across devices is worth more than its
|
||||
/// fraction of a millisecond (§7).
|
||||
fn fp16(role: Role) -> bool {
|
||||
role != Role::Embedder
|
||||
}
|
||||
|
||||
/// 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,14 +129,20 @@ 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()])?)
|
||||
}
|
||||
Rung::TensorRt if role == Role::WholeDenoiser => {
|
||||
let mut b = b;
|
||||
tensorrt_whole(&mut b, per_model.expect("a whole-frame engine directory"))?;
|
||||
Ok(b.with_execution_providers([ep::CUDA::default().build()])?)
|
||||
}
|
||||
Rung::TensorRt => {
|
||||
let cache = cfg.cache_dir.join("tensorrt");
|
||||
let _ = std::fs::create_dir_all(&cache);
|
||||
@@ -72,7 +153,7 @@ fn providers(
|
||||
// (NFR-RES-2). CUDA behind it takes any node TensorRT declines.
|
||||
Ok(b.with_execution_providers([
|
||||
ep::TensorRT::default()
|
||||
.with_fp16(role != Role::Embedder)
|
||||
.with_fp16(fp16(role))
|
||||
.with_engine_cache(true)
|
||||
.with_engine_cache_path(&cache)
|
||||
.with_timing_cache(true)
|
||||
@@ -89,7 +170,7 @@ fn providers(
|
||||
// directory, keyed on the graph, the GPU and its own version
|
||||
// but not the precision: hence one directory per precision.
|
||||
// The CPU takes any node it declines.
|
||||
let fp16 = role != Role::Embedder;
|
||||
let fp16 = fp16(role);
|
||||
let cache = cfg
|
||||
.cache_dir
|
||||
.join("migraphx")
|
||||
@@ -99,10 +180,57 @@ fn providers(
|
||||
migraphx(&mut b, fp16, &cache)?;
|
||||
Ok(b)
|
||||
}
|
||||
Rung::OpenVino => {
|
||||
let mut b = b;
|
||||
openvino(&mut b, fp16(role), per_model)?;
|
||||
Ok(b)
|
||||
}
|
||||
Rung::WebGpu => {
|
||||
let mut b = b;
|
||||
webgpu(&mut b)?;
|
||||
Ok(b)
|
||||
}
|
||||
Rung::Hexagon => unreachable!("the Hexagon rung is not on a desktop ladder"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -117,15 +245,145 @@ fn migraphx(
|
||||
b: &mut ort::session::builder::SessionBuilder,
|
||||
fp16: bool,
|
||||
cache: &std::path::Path,
|
||||
) -> ort::Result<()> {
|
||||
append(
|
||||
b,
|
||||
c"MIGraphX",
|
||||
&[
|
||||
("migraphx_fp16_enable", if fp16 { "1" } else { "0" }.into()),
|
||||
(
|
||||
"migraphx_model_cache_dir",
|
||||
cache.to_string_lossy().into_owned(),
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// TensorRT for a whole-frame model: one engine for every input size up to
|
||||
/// [`crate::WHOLE_FRAME_MAX`], kept in its own directory.
|
||||
///
|
||||
/// `ort`'s builder has no profile options, so this registers through the
|
||||
/// runtime's TensorRT V2 options, with the names 1.30 reads
|
||||
/// (`tensorrt_execution_provider_info.cc`): `trt_profile_{min,opt,max}_shapes`.
|
||||
/// Without a profile a dynamic input compiles a new engine per size at run
|
||||
/// time — 156 s on the first frame, measured — so the profile is the
|
||||
/// difference between a whole-frame engine and a stall. fp16, as for every
|
||||
/// role but the embedder (§7); the denoiser measured 0.00 dB from f32.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn tensorrt_whole(
|
||||
b: &mut ort::session::builder::SessionBuilder,
|
||||
cache: &std::path::Path,
|
||||
) -> ort::Result<()> {
|
||||
use ort::AsPointer;
|
||||
use std::ffi::CString;
|
||||
let keys = [c"migraphx_fp16_enable", c"migraphx_model_cache_dir"];
|
||||
let values = [
|
||||
CString::new(if fp16 { "1" } else { "0" }).unwrap(),
|
||||
CString::new(cache.to_string_lossy().as_bytes())
|
||||
.map_err(|e| ort::Error::new(e.to_string()))?,
|
||||
let _ = std::fs::create_dir_all(cache);
|
||||
let shapes = |(h, w): (usize, usize)| format!("mosaic:1x1x{h}x{w},sigma:1x1x{h}x{w}");
|
||||
let dir = cache.to_string_lossy().into_owned();
|
||||
let options = [
|
||||
("trt_fp16_enable", "1".to_string()),
|
||||
("trt_engine_cache_enable", "1".to_string()),
|
||||
("trt_engine_cache_path", dir.clone()),
|
||||
("trt_timing_cache_enable", "1".to_string()),
|
||||
("trt_timing_cache_path", dir),
|
||||
("trt_max_workspace_size", (1u64 << 30).to_string()),
|
||||
("trt_profile_min_shapes", shapes((256, 256))),
|
||||
("trt_profile_opt_shapes", shapes(crate::WHOLE_FRAME_OPT)),
|
||||
("trt_profile_max_shapes", shapes(crate::WHOLE_FRAME_MAX)),
|
||||
];
|
||||
let cstr = |s: &str| CString::new(s).map_err(|e| ort::Error::new(e.to_string()));
|
||||
let keys = options
|
||||
.iter()
|
||||
.map(|(k, _)| cstr(k))
|
||||
.collect::<ort::Result<Vec<_>>>()?;
|
||||
let values = options
|
||||
.iter()
|
||||
.map(|(_, v)| cstr(v))
|
||||
.collect::<ort::Result<Vec<_>>>()?;
|
||||
let key_ptrs: Vec<_> = keys.iter().map(|k| k.as_ptr()).collect();
|
||||
let value_ptrs: Vec<_> = values.iter().map(|v| v.as_ptr()).collect();
|
||||
let api = ort::api();
|
||||
// SAFETY: the documented create / update / append / release sequence
|
||||
// `ort`'s own TensorRT builder makes, over arrays that outlive it; the
|
||||
// runtime copies the options into the session before the release.
|
||||
unsafe {
|
||||
let mut trt: *mut ort::sys::OrtTensorRTProviderOptionsV2 = std::ptr::null_mut();
|
||||
ort::Error::result_from_status((api.CreateTensorRTProviderOptions)(&mut trt))?;
|
||||
let result = ort::Error::result_from_status((api.UpdateTensorRTProviderOptions)(
|
||||
trt,
|
||||
key_ptrs.as_ptr(),
|
||||
value_ptrs.as_ptr(),
|
||||
keys.len(),
|
||||
))
|
||||
.and_then(|()| {
|
||||
ort::Error::result_from_status((api.SessionOptionsAppendExecutionProvider_TensorRT_V2)(
|
||||
b.ptr_mut(),
|
||||
trt,
|
||||
))
|
||||
});
|
||||
(api.ReleaseTensorRTProviderOptions)(trt);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenVINO on the GPU, compiling into `cache`.
|
||||
///
|
||||
/// The option names are those `openvino_provider_factory.cc` reads at 1.24,
|
||||
/// the version of Intel's `onnxruntime-openvino` build. `GPU` is OpenVINO's
|
||||
/// first OpenCL GPU: the Intel one on a hybrid laptop with both drivers
|
||||
/// installed, but an NVIDIA card through its OpenCL when Intel's is absent
|
||||
/// — slower than the CPU there, and rejected by the probe's clock. The
|
||||
/// precision is always named: the GPU plugin's own default is fp16, and
|
||||
/// the embedder must not get it (§7).
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn openvino(
|
||||
b: &mut ort::session::builder::SessionBuilder,
|
||||
fp16: bool,
|
||||
cache: Option<&std::path::Path>,
|
||||
) -> ort::Result<()> {
|
||||
let mut options = vec![
|
||||
("device_type", "GPU".to_string()),
|
||||
("precision", if fp16 { "FP16" } else { "FP32" }.into()),
|
||||
];
|
||||
if let Some(dir) = cache {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
options.push(("cache_dir", dir.to_string_lossy().into_owned()));
|
||||
}
|
||||
append(b, c"OpenVINO", &options)
|
||||
}
|
||||
|
||||
/// WebGPU on the high-performance adapter: the discrete GPU where there is
|
||||
/// one, since the integrated one on a machine with both is the one this
|
||||
/// rung is least likely to beat the CPU on. The key is as
|
||||
/// `webgpu_provider_options.h` spells it, without the `ep.<name>.` prefix
|
||||
/// the runtime adds.
|
||||
fn webgpu(b: &mut ort::session::builder::SessionBuilder) -> ort::Result<()> {
|
||||
append(
|
||||
b,
|
||||
c"WebGPU",
|
||||
&[("powerPreference", "high-performance".to_string())],
|
||||
)
|
||||
}
|
||||
|
||||
/// Register the provider `name` with `options` through the runtime's
|
||||
/// generic key/value entry point, which takes every provider by its short
|
||||
/// name and reads options at the runtime's own version — not at the
|
||||
/// version `ort`'s builders were written against (CLAUDE.md, "Providers").
|
||||
fn append(
|
||||
b: &mut ort::session::builder::SessionBuilder,
|
||||
name: &std::ffi::CStr,
|
||||
options: &[(&str, String)],
|
||||
) -> ort::Result<()> {
|
||||
use ort::AsPointer;
|
||||
use std::ffi::CString;
|
||||
let cstr = |s: &str| CString::new(s).map_err(|e| ort::Error::new(e.to_string()));
|
||||
let keys = options
|
||||
.iter()
|
||||
.map(|(k, _)| cstr(k))
|
||||
.collect::<ort::Result<Vec<_>>>()?;
|
||||
let values = options
|
||||
.iter()
|
||||
.map(|(_, v)| cstr(v))
|
||||
.collect::<ort::Result<Vec<_>>>()?;
|
||||
let key_ptrs: Vec<_> = keys.iter().map(|k| k.as_ptr()).collect();
|
||||
let value_ptrs: Vec<_> = values.iter().map(|v| v.as_ptr()).collect();
|
||||
// SAFETY: the documented C call over arrays that outlive it; the
|
||||
@@ -133,7 +391,7 @@ fn migraphx(
|
||||
unsafe {
|
||||
let status = (ort::api().SessionOptionsAppendExecutionProvider)(
|
||||
b.ptr_mut(),
|
||||
c"MIGraphX".as_ptr(),
|
||||
name.as_ptr(),
|
||||
key_ptrs.as_ptr(),
|
||||
value_ptrs.as_ptr(),
|
||||
keys.len(),
|
||||
@@ -175,8 +433,13 @@ fn providers(
|
||||
.build()
|
||||
.error_on_failure()])?)
|
||||
}
|
||||
Rung::Cuda | Rung::TensorRt | Rung::MiGraphX => {
|
||||
unreachable!("no desktop GPU rung on Android")
|
||||
Rung::WebGpu => {
|
||||
let mut b = b;
|
||||
webgpu(&mut b)?;
|
||||
Ok(b)
|
||||
}
|
||||
Rung::Cuda | Rung::TensorRt | Rung::MiGraphX | Rung::CoreMl | Rung::OpenVino => {
|
||||
unreachable!("no desktop rung on Android")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -13,8 +13,13 @@ const MODELS: &[&str] = &[
|
||||
"../../models/keypoints/xfeat-768.onnx",
|
||||
];
|
||||
|
||||
const QUANTISED: &[&str] = &[
|
||||
"../../models/keypoints/xfeat-1024.int8.onnx",
|
||||
"../../models/keypoints/xfeat-768.int8.onnx",
|
||||
];
|
||||
|
||||
fn main() {
|
||||
for m in MODELS {
|
||||
for m in MODELS.iter().chain(QUANTISED) {
|
||||
println!("cargo:rerun-if-changed={m}");
|
||||
}
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
@@ -26,6 +31,12 @@ fn main() {
|
||||
for model in MODELS.iter().copied() {
|
||||
check(model);
|
||||
}
|
||||
// The Hexagon's int8 forms ride only in an Android build.
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") {
|
||||
for model in QUANTISED.iter().copied() {
|
||||
check(model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check(model: &str) {
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -30,7 +30,8 @@ pub struct MiGan {
|
||||
|
||||
impl MiGan {
|
||||
/// From the model file, in whichever form the engine's rung wants
|
||||
/// (`resolve_model` picks an int8 sibling for the Hexagon).
|
||||
/// (`resolve_model` picks the `.a16w16.onnx` sibling on the Hexagon:
|
||||
/// int8 moved the fill 16 dB from f32's, 16-bit about 41).
|
||||
pub fn from_path(path: &std::path::Path) -> Result<Self, PanoError> {
|
||||
use dr_inference_engine::{resolve_model, Role};
|
||||
let (path, form) = resolve_model(Role::Inpainter, path);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -36,18 +36,49 @@ pub struct XFeat {
|
||||
pub options: DecodeOptions,
|
||||
}
|
||||
|
||||
/// The bytes of both exports compiled into the binary, for whoever compiles
|
||||
/// engines ahead of the first request (docs/dev/inference.md §6).
|
||||
/// The Hexagon's forms (docs/dev/inference.md §1.5): int8, from the same
|
||||
/// network spelled for the HTP (the unfold as SpaceToDepth, the bilinear
|
||||
/// resizes as matrix products). Only Android has a Hexagon.
|
||||
#[cfg(all(feature = "embedded-model", target_os = "android"))]
|
||||
const EMBEDDED_LANDSCAPE_INT8: &[u8] =
|
||||
include_bytes!("../../../models/keypoints/xfeat-1024.int8.onnx");
|
||||
#[cfg(all(feature = "embedded-model", target_os = "android"))]
|
||||
const EMBEDDED_PORTRAIT_INT8: &[u8] =
|
||||
include_bytes!("../../../models/keypoints/xfeat-768.int8.onnx");
|
||||
|
||||
/// Every form of both exports compiled into the binary, landscape then
|
||||
/// portrait, for whoever compiles engines ahead of the first request
|
||||
/// (docs/dev/inference.md §6).
|
||||
#[cfg(feature = "embedded-model")]
|
||||
pub fn embedded_model_bytes() -> [&'static [u8]; 2] {
|
||||
[EMBEDDED_LANDSCAPE, EMBEDDED_PORTRAIT]
|
||||
pub fn embedded_models() -> [Vec<(dr_inference_engine::Form, &'static [u8])>; 2] {
|
||||
use dr_inference_engine::Form;
|
||||
#[allow(unused_mut)]
|
||||
let mut forms = [
|
||||
vec![(Form::F32, EMBEDDED_LANDSCAPE)],
|
||||
vec![(Form::F32, EMBEDDED_PORTRAIT)],
|
||||
];
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
forms[0].push((Form::Int8, EMBEDDED_LANDSCAPE_INT8));
|
||||
forms[1].push((Form::Int8, EMBEDDED_PORTRAIT_INT8));
|
||||
}
|
||||
forms
|
||||
}
|
||||
|
||||
impl XFeat {
|
||||
/// The weights compiled into the binary.
|
||||
/// The weights compiled into the binary, in the form the device's
|
||||
/// backend runs.
|
||||
#[cfg(feature = "embedded-model")]
|
||||
pub fn embedded() -> Result<Self, PanoError> {
|
||||
Self::from_bytes(EMBEDDED_LANDSCAPE, EMBEDDED_PORTRAIT)
|
||||
use dr_inference_engine::{choose_embedded, open, Role};
|
||||
let [l, p] = embedded_models();
|
||||
let (l, lf) = choose_embedded(Role::Keypoints, &l);
|
||||
let (p, pf) = choose_embedded(Role::Keypoints, &p);
|
||||
Ok(XFeat {
|
||||
landscape: open(Role::Keypoints, lf, l)?,
|
||||
portrait: open(Role::Keypoints, pf, p)?,
|
||||
options: DecodeOptions::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// From the two exports on disk.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,7 +25,7 @@ vibrance.vibrance = 10
|
||||
blacks_whites.blacks = -8
|
||||
clarity.amount = 12
|
||||
contrast.contrast = 18
|
||||
vibrance.vibrance = 18
|
||||
vibrance.vibrance = 36
|
||||
|
||||
[preset Recover the sky]
|
||||
blacks_whites.whites = -10
|
||||
|
||||
@@ -15,38 +15,38 @@ drpl 1
|
||||
|
||||
[preset Blue sky]
|
||||
colour_mixer.azure_lum = -20
|
||||
colour_mixer.azure_sat = 25
|
||||
colour_mixer.azure_sat = 18
|
||||
colour_mixer.blue_lum = -15
|
||||
colour_mixer.blue_sat = 20
|
||||
colour_mixer.blue_sat = 14
|
||||
highlights_shadows.highlights = -15
|
||||
|
||||
[preset Deep blue sky]
|
||||
colour_mixer.azure_hue = 10
|
||||
colour_mixer.azure_lum = -30
|
||||
colour_mixer.azure_sat = 35
|
||||
colour_mixer.azure_sat = 22
|
||||
colour_mixer.blue_lum = -25
|
||||
colour_mixer.blue_sat = 30
|
||||
colour_mixer.cyan_sat = 10
|
||||
colour_mixer.blue_sat = 19
|
||||
colour_mixer.cyan_sat = 6
|
||||
highlights_shadows.highlights = -30
|
||||
|
||||
[preset Polariser]
|
||||
colour_mixer.azure_hue = 10
|
||||
colour_mixer.azure_lum = -35
|
||||
colour_mixer.azure_sat = 40
|
||||
colour_mixer.azure_sat = 29
|
||||
colour_mixer.blue_lum = -30
|
||||
colour_mixer.blue_sat = 35
|
||||
colour_mixer.blue_sat = 26
|
||||
colour_mixer.cyan_lum = -10
|
||||
colour_mixer.cyan_sat = 15
|
||||
colour_mixer.cyan_sat = 11
|
||||
dehaze.amount = 20
|
||||
highlights_shadows.highlights = -35
|
||||
vibrance.vibrance = 10
|
||||
vibrance.vibrance = 7
|
||||
|
||||
[preset Blue sky, golden land]
|
||||
colour_mixer.azure_lum = -20
|
||||
colour_mixer.azure_sat = 25
|
||||
colour_mixer.azure_sat = 16
|
||||
colour_mixer.blue_lum = -15
|
||||
colour_mixer.blue_sat = 20
|
||||
colour_mixer.orange_sat = 12
|
||||
colour_mixer.blue_sat = 13
|
||||
colour_mixer.orange_sat = 8
|
||||
colour_mixer.yellow_hue = -10
|
||||
colour_mixer.yellow_sat = 15
|
||||
colour_mixer.yellow_sat = 10
|
||||
highlights_shadows.highlights = -20
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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.
|
||||
#
|
||||
# How much colour each adds is measured, not guessed: mean CIELAB chroma on
|
||||
# raws rendered with the default (DNG reference) rendering, as a ratio to that
|
||||
# rendering. For scale, the photographer's earlier exports of the same kind of
|
||||
# raws sit at 1.14 with no look applied and 1.27 with their everyday look.
|
||||
# Vivid 1.30 and Vivid warm 1.30 sit just above that; Vivid landscape 1.38;
|
||||
# Vivid, strong 1.45; Vivid portrait 1.15, with its skin bands held down as
|
||||
# written. Tuned by scaling each preset's colour values together, never its
|
||||
# tone ones.
|
||||
|
||||
[preset Vivid]
|
||||
contrast.contrast = 10
|
||||
saturation.saturation = 11
|
||||
vibrance.vibrance = 42
|
||||
|
||||
[preset Vivid, strong]
|
||||
blacks_whites.blacks = -10
|
||||
clarity.amount = 8
|
||||
contrast.contrast = 18
|
||||
saturation.saturation = 19
|
||||
vibrance.vibrance = 57
|
||||
|
||||
# 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 = 25
|
||||
colour_mixer.blue_lum = -10
|
||||
colour_mixer.blue_sat = 20
|
||||
colour_mixer.chartreuse_sat = 20
|
||||
colour_mixer.green_sat = 25
|
||||
colour_mixer.yellow_sat = 13
|
||||
contrast.contrast = 12
|
||||
saturation.saturation = 7
|
||||
vibrance.vibrance = 32
|
||||
|
||||
# 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 = 17
|
||||
colour_mixer.red_sat = 9
|
||||
colour_mixer.yellow_sat = 17
|
||||
contrast.contrast = 8
|
||||
vibrance.vibrance = 29
|
||||
|
||||
# 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 = 14
|
||||
colour_mixer.blue_sat = 17
|
||||
colour_mixer.green_sat = 17
|
||||
colour_mixer.orange_sat = -10
|
||||
colour_mixer.red_sat = -5
|
||||
contrast.contrast = 6
|
||||
saturation.saturation = -5
|
||||
vibrance.vibrance = 35
|
||||
@@ -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
|
||||
|
||||
+316
-26
@@ -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,
|
||||
/// Which demosaic develops the photograph: a network, or the classical
|
||||
/// one. An edit: published as [`crate::learned_denoise`], captured,
|
||||
/// stored and undone with the rest (FR-DEV-3c).
|
||||
denoise_method: crate::learned_denoise::Method,
|
||||
/// How strongly to denoise, 0–100; what is not taken goes back as grain.
|
||||
denoise_strength: f32,
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3f
|
||||
@@ -219,6 +238,9 @@ impl EditGraph {
|
||||
],
|
||||
lens_profile: None,
|
||||
lens_profile_applied: true,
|
||||
denoise_available: false,
|
||||
denoise_method: crate::learned_denoise::Method::DEFAULT,
|
||||
denoise_strength: 100.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,32 @@ 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_method.learned()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3g
|
||||
/// Which demosaic is asked for.
|
||||
pub fn denoise_method(&self) -> crate::learned_denoise::Method {
|
||||
self.denoise_method
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3g
|
||||
/// The grain to keep, 0–1: what the strength does not take.
|
||||
pub fn denoise_grain(&self) -> f32 {
|
||||
(100.0 - self.denoise_strength) / 100.0
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// Whether the matched profile is being applied.
|
||||
pub fn lens_profile_applied(&self) -> bool {
|
||||
@@ -523,8 +622,38 @@ impl EditGraph {
|
||||
}
|
||||
});
|
||||
|
||||
switch
|
||||
// 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(),
|
||||
}
|
||||
});
|
||||
|
||||
// The learned denoise first: it decides what every control below
|
||||
// is applied to, so it heads the panel (docs/dev/denoise.md §7).
|
||||
denoise
|
||||
.into_iter()
|
||||
.chain(switch)
|
||||
.chain(warps)
|
||||
.chain(ops)
|
||||
.chain(std::iter::once(framing))
|
||||
@@ -617,6 +746,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_method: _,
|
||||
denoise_strength: _,
|
||||
masks,
|
||||
film,
|
||||
spots,
|
||||
@@ -688,6 +822,32 @@ 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::METHOD => {
|
||||
self.denoise_method = crate::learned_denoise::Method::from_index(value)
|
||||
}
|
||||
// 0.21 and 0.22's switch (see `APPLY`): off is the classical
|
||||
// demosaic, on is a network — the one already chosen, if any.
|
||||
p if p == crate::learned_denoise::APPLY => {
|
||||
use crate::learned_denoise::Method;
|
||||
if value == 0.0 {
|
||||
self.denoise_method = Method::Bilinear;
|
||||
} else if !self.denoise_method.learned() {
|
||||
self.denoise_method = Method::DEFAULT;
|
||||
}
|
||||
}
|
||||
p if p == crate::learned_denoise::STRENGTH => {
|
||||
self.denoise_strength = value.clamp(0.0, 100.0)
|
||||
}
|
||||
// 0.21.0's grain, the strength's inverse (see `GRAIN`).
|
||||
p if p == crate::learned_denoise::GRAIN => {
|
||||
self.denoise_strength = 100.0 - 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 +905,17 @@ 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::METHOD => Some(self.denoise_method.index()),
|
||||
p if p == crate::learned_denoise::APPLY => {
|
||||
Some(if self.denoise_applied() { 1.0 } else { 0.0 })
|
||||
}
|
||||
p if p == crate::learned_denoise::STRENGTH => Some(self.denoise_strength),
|
||||
p if p == crate::learned_denoise::GRAIN => Some(100.0 - self.denoise_strength),
|
||||
_ => 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 +962,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 its default network; whether it is
|
||||
// available is the file's and stays.
|
||||
self.denoise_method = crate::learned_denoise::Method::DEFAULT;
|
||||
self.denoise_strength = 100.0;
|
||||
}
|
||||
|
||||
/// Set the crop rectangle. Clamped to keep it inside the frame.
|
||||
@@ -952,46 +1127,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 +1286,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 +1381,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 +2112,117 @@ 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, "on by default");
|
||||
assert_eq!(g.denoise_method(), learned_denoise::Method::Best);
|
||||
assert_eq!(g.denoise_grain(), 0.0, "at full strength");
|
||||
assert_eq!(cap.id, g.capabilities()[0].id, "and first in the panel");
|
||||
g.set_param(
|
||||
learned_denoise::ID,
|
||||
learned_denoise::METHOD,
|
||||
learned_denoise::Method::Bilinear.index(),
|
||||
);
|
||||
g.set_param(learned_denoise::ID, learned_denoise::STRENGTH, 70.0);
|
||||
assert!(!g.denoise_applied());
|
||||
assert!((g.denoise_grain() - 0.3).abs() < 1e-6);
|
||||
g.reset();
|
||||
assert!(g.denoise_applied(), "reset is back to on");
|
||||
assert_eq!(g.denoise_grain(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untouched_raw_writes_nothing_and_develops_through_the_best() {
|
||||
// TRACES: FR-DEV-3g
|
||||
use crate::learned_denoise::{self, Method};
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_denoise_available(true);
|
||||
assert_eq!(g.denoise_method(), Method::Best);
|
||||
let stored = |g: &EditGraph| {
|
||||
crate::Preset::capture_params(g)
|
||||
.params()
|
||||
.keys()
|
||||
.any(|(op, _)| op == learned_denoise::ID.0)
|
||||
};
|
||||
assert!(!stored(&g), "the default is not written");
|
||||
g.set_param(
|
||||
learned_denoise::ID,
|
||||
learned_denoise::METHOD,
|
||||
Method::Fast.index(),
|
||||
);
|
||||
assert!(stored(&g), "a choice is");
|
||||
assert!(
|
||||
!crate::Preset::capture_params(&g).params().contains_key(&(
|
||||
learned_denoise::ID.0.into(),
|
||||
learned_denoise::APPLY.0.into()
|
||||
)),
|
||||
"and the old switch never is"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_saved_with_the_switch_keeps_its_look() {
|
||||
// TRACES: FR-DEV-3g
|
||||
// 0.21 and 0.22 stored on or off; off is the classical demosaic, and
|
||||
// on keeps a network already chosen.
|
||||
use crate::learned_denoise::{self, Method};
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(learned_denoise::ID, learned_denoise::APPLY, 0.0);
|
||||
assert_eq!(g.denoise_method(), Method::Bilinear);
|
||||
g.set_param(learned_denoise::ID, learned_denoise::APPLY, 1.0);
|
||||
assert_eq!(g.denoise_method(), Method::DEFAULT);
|
||||
g.set_param(
|
||||
learned_denoise::ID,
|
||||
learned_denoise::METHOD,
|
||||
Method::Fast.index(),
|
||||
);
|
||||
g.set_param(learned_denoise::ID, learned_denoise::APPLY, 1.0);
|
||||
assert_eq!(g.denoise_method(), Method::Fast);
|
||||
// A number from a newer build with more methods is the default.
|
||||
g.set_param(learned_denoise::ID, learned_denoise::METHOD, 9.0);
|
||||
assert_eq!(g.denoise_method(), Method::DEFAULT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_saved_with_grain_keeps_its_look() {
|
||||
// TRACES: FR-DEV-3g
|
||||
// 0.21.0 stored the grain kept rather than the strength.
|
||||
use crate::learned_denoise;
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_param(learned_denoise::ID, learned_denoise::GRAIN, 25.0);
|
||||
assert_eq!(
|
||||
g.param(learned_denoise::ID, learned_denoise::STRENGTH),
|
||||
Some(75.0)
|
||||
);
|
||||
assert!((g.denoise_grain() - 0.25).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_learned_denoise_travels_in_the_state() {
|
||||
use crate::learned_denoise;
|
||||
let mut g = EditGraph::default_chain();
|
||||
g.set_denoise_available(true);
|
||||
g.set_param(learned_denoise::ID, learned_denoise::STRENGTH, 60.0);
|
||||
g.set_param(
|
||||
learned_denoise::ID,
|
||||
learned_denoise::METHOD,
|
||||
learned_denoise::Method::Fast.index(),
|
||||
);
|
||||
let state = g.state();
|
||||
let mut h = EditGraph::default_chain();
|
||||
h.set_denoise_available(true);
|
||||
let _ = h.set_state(&state);
|
||||
assert_eq!(h.denoise_method(), learned_denoise::Method::Fast);
|
||||
assert!((h.denoise_grain() - 0.4).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! TRACES: FR-DEV-3g
|
||||
//! The learned denoise's settings: which network develops the photograph,
|
||||
//! if any, and how much grain to keep.
|
||||
//!
|
||||
//! Not an [`crate::operation::Operation`]: the learned stage replaces the
|
||||
//! demosaic and runs once per photograph, off the render path
|
||||
//! (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");
|
||||
/// TRACES: FR-DEV-3g
|
||||
/// Which demosaic develops the photograph, a [`Method`] by index.
|
||||
pub const METHOD: ParamId = ParamId("method");
|
||||
/// What 0.21 and 0.22 stored instead of [`METHOD`]: on or off. Still read —
|
||||
/// off is [`Method::Bilinear`], on is the default network — so an edit saved
|
||||
/// by those releases keeps its look; never written, and not offered.
|
||||
pub const APPLY: ParamId = ParamId("apply");
|
||||
/// TRACES: FR-DEV-3g
|
||||
/// How strongly to denoise, 0–100: 100 is the network's result as it is, and
|
||||
/// lower puts the removed noise's brightness back as grain.
|
||||
pub const STRENGTH: ParamId = ParamId("strength");
|
||||
/// What 0.21.0 stored instead of [`STRENGTH`]: the grain kept, its inverse.
|
||||
/// Still read, so an edit saved by that release keeps its look; never
|
||||
/// written, and not offered as a control.
|
||||
pub const GRAIN: ParamId = ParamId("grain");
|
||||
|
||||
/// TRACES: FR-DEV-3g
|
||||
/// The demosaics a photograph can be developed with, in the order the
|
||||
/// sidecar numbers them. Two networks that trade time for quality
|
||||
/// (docs/dev/denoise.md §15) and the classical demosaic, which is no network
|
||||
/// at all.
|
||||
///
|
||||
/// Until 0.24 there were four — Bilinear, Fast, Medium, Best — and the
|
||||
/// sidecar keeps their numbers: 2, which was Medium, is now Best, and 3,
|
||||
/// which was Best, is past the end and reads as the default, which is
|
||||
/// Best. Both land on the network that replaced them, with no migration.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Method {
|
||||
/// The classical demosaic: the noise stays.
|
||||
Bilinear,
|
||||
/// The smallest student: a quarter of Best's work.
|
||||
Fast,
|
||||
/// One network of the first release's size, taught by the mixture of
|
||||
/// experts it replaced: the mixture's edges at a third of its work.
|
||||
Best,
|
||||
}
|
||||
|
||||
impl Method {
|
||||
pub const ALL: [Method; 3] = [Method::Bilinear, Method::Fast, Method::Best];
|
||||
pub const DEFAULT: Method = Method::Best;
|
||||
|
||||
/// The sidecar's number for it.
|
||||
pub fn index(self) -> f32 {
|
||||
Self::ALL.iter().position(|m| *m == self).unwrap_or(0) as f32
|
||||
}
|
||||
|
||||
/// The method a stored number names; out of range is the default, as
|
||||
/// from a newer build with more of them.
|
||||
pub fn from_index(value: f32) -> Method {
|
||||
let i = value.round();
|
||||
if i >= 0.0 && (i as usize) < Self::ALL.len() {
|
||||
Self::ALL[i as usize]
|
||||
} else {
|
||||
Self::DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a network runs at all.
|
||||
pub fn learned(self) -> bool {
|
||||
self != Method::Bilinear
|
||||
}
|
||||
}
|
||||
|
||||
/// The best network by default, at full strength: every Bayer raw is
|
||||
/// developed from the learned demosaic, and the choice and the slider are
|
||||
/// there to take it back, trade it for time, or ease it off. It costs seconds per photograph the first time, while
|
||||
/// the classical demosaic shows; the result is cached, so a photograph
|
||||
/// reopened or exported does not pay again (docs/dev/denoise.md §7).
|
||||
pub(crate) static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||
Arc::new(OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.learned_denoise"),
|
||||
params: vec![
|
||||
ParamDescriptor::choice(
|
||||
"method",
|
||||
"param.learned_denoise.method",
|
||||
vec![
|
||||
LocalizedKey("param.learned_denoise.method.bilinear"),
|
||||
LocalizedKey("param.learned_denoise.method.fast"),
|
||||
LocalizedKey("param.learned_denoise.method.best"),
|
||||
],
|
||||
)
|
||||
.with_default(Method::DEFAULT.index()),
|
||||
ParamDescriptor::scalar(
|
||||
"strength",
|
||||
"param.learned_denoise.strength",
|
||||
0.0,
|
||||
100.0,
|
||||
100.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()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// An edit saved before 0.24 stored Medium as 2 and Best as 3. Both
|
||||
/// now name the network that replaced them, and nothing reads as Fast
|
||||
/// or Bilinear that did not before.
|
||||
#[test]
|
||||
fn the_retired_methods_read_as_best() {
|
||||
assert_eq!(Method::from_index(0.0), Method::Bilinear);
|
||||
assert_eq!(Method::from_index(1.0), Method::Fast);
|
||||
assert_eq!(Method::from_index(2.0), Method::Best, "Medium, before 0.24");
|
||||
assert_eq!(Method::from_index(3.0), Method::Best, "Best, before 0.24");
|
||||
assert_eq!(Method::Best.index(), 2.0);
|
||||
}
|
||||
}
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user